mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 03:40:56 +00:00
132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
import { OnEvent } from '@nestjs/event-emitter';
|
|
import { Freight } from '@edr/types';
|
|
|
|
import {
|
|
BillingService,
|
|
InvoiceEventPayload,
|
|
} from '../billing/billing.service';
|
|
import { Invoice } from '../billing/entities/invoice.entity';
|
|
import { FirstMileRepository } from './first-mile.repository';
|
|
import { FirstMile } from './entities/first-mile.entity';
|
|
|
|
/**
|
|
* Owns the first-mile ⇄ invoice mapping — the one place that knows how a
|
|
* first-mile record turns into invoices, which type to use, and how it
|
|
* advances when paid. First-mile records are billable entities, so they
|
|
* generate their own invoices directly via {@link BillingService}.
|
|
*/
|
|
@Injectable()
|
|
export class FirstMileInvoiceService {
|
|
private readonly logger = new Logger(FirstMileInvoiceService.name);
|
|
|
|
constructor(
|
|
private readonly billing: BillingService,
|
|
private readonly firstMileRepo: FirstMileRepository,
|
|
) {}
|
|
|
|
/**
|
|
* Ensure the first-mile record has its invoice, generating one from the
|
|
* remaining payment if absent. Called when a first-mile record reaches a
|
|
* billable state. Idempotent — returns the existing open invoice instead
|
|
* of a duplicate. Returns `null` (and logs) when the record is not billable:
|
|
* no company to bill.
|
|
*/
|
|
async ensureInvoiceFor(record: FirstMile): Promise<Invoice | null> {
|
|
const existing = await this.billing.findPayable(
|
|
'first_mile' as Freight.InvoiceSource,
|
|
record.id,
|
|
'DELIVERY_FEE',
|
|
);
|
|
if (existing) return existing;
|
|
|
|
if (!record.bookingId) {
|
|
this.logger.warn(
|
|
`Skipping invoice for first-mile record ${record.id}: no booking to reference.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Fetch the booking to get the companyId and companyProfileId
|
|
const fm = record.booking ? record : (await this.firstMileRepo.findById(record.id, { relations: { booking: true } }));
|
|
if (!fm) return null;
|
|
if (!fm.booking?.companyId) {
|
|
this.logger.warn(
|
|
`Skipping invoice for first-mile record ${record.id}: no company to bill.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// numeric columns come back as strings — coerce before the finite/>0 check.
|
|
const totalAmount = Number(record.remainingPayment) || 0;
|
|
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
|
this.logger.warn(
|
|
`Skipping invoice for first-mile record ${record.id}: no remaining payment.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Reject mixed-currency truck sets — a single invoice can only be one
|
|
// currency, and amounts across currencies can't be summed.
|
|
const billableTrucks = (record.vehicleAssignments ?? []).filter(
|
|
(a) => Number(a.distanceKm) > 0,
|
|
);
|
|
const currencies = [
|
|
...new Set(
|
|
billableTrucks
|
|
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
|
|
.filter((c): c is string => Boolean(c)),
|
|
),
|
|
];
|
|
if (currencies.length > 1) {
|
|
throw new BadRequestException(
|
|
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
|
|
);
|
|
}
|
|
|
|
// Currency follows the truck (price/km is quoted per vehicle), falling back
|
|
// to the booking's currency, then ETB.
|
|
const truckCurrency =
|
|
(record.vehicle as { currency?: string } | undefined)?.currency ||
|
|
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
|
|
|
|
return this.billing.generateInvoice({
|
|
source: 'first_mile' as Freight.InvoiceSource,
|
|
sourceId: record.id,
|
|
type: 'DELIVERY_FEE',
|
|
companyId: fm.booking!.companyId,
|
|
companyProfileId: fm.booking!.companyProfileId || '',
|
|
currency: truckCurrency || fm.booking!.paymentCurrency || 'ETB',
|
|
lines: [
|
|
{
|
|
chargeType: 'DELIVERY',
|
|
description: 'First-mile delivery',
|
|
quantity: 1,
|
|
unitRate: totalAmount,
|
|
amount: totalAmount,
|
|
},
|
|
],
|
|
totalAmount,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* React to a first-mile invoice being paid — the settlement branch point.
|
|
* Mark the first-mile record as having completed post-payment processing.
|
|
*/
|
|
@OnEvent('first_mile.invoice.paid')
|
|
async onPaid(payload: InvoiceEventPayload): Promise<void> {
|
|
if (payload.type === 'DELIVERY_FEE') {
|
|
const record = await this.firstMileRepo.findById(payload.sourceId);
|
|
if (!record) {
|
|
this.logger.warn(
|
|
`Cannot mark unknown first-mile record ${payload.sourceId} as paid.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`);
|
|
}
|
|
}
|
|
}
|