Files
edr-platform/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts
Hagernesh 5c8c68e990 feat(rates): last-mile rate rules with bulk per-ton-km and container distance bands
- rates: min_km/max_km columns, PER_TON_KM unit, ETB|USD currency for last mile
- extend UQ_rates_pattern with band start; overlap + shape validation
- shared last-mile charge resolver; approve-dialog price estimate endpoint
- delivery-fee invoice prices via rules, falls back to vehicle price/km
- backoffice: last-mile rate form (mode, container type, band, currency)
2026-08-05 22:13:42 +00:00

171 lines
6.3 KiB
TypeScript

import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { RatesService } from '../rule-engine/services/rates.service';
import { LastMileRepository } from './last-mile.repository';
import { LastMile } from './entities/last-mile.entity';
/**
* Owns the last-mile ⇄ invoice mapping — the one place that knows how a last-mile
* record turns into invoices, which type to use, and how it advances when paid.
* Last-mile records are billable business entities for delivery fees, so they
* generate their own invoices directly via {@link BillingService}. All last-mile-specific
* type branching lives here, at the two points it belongs: invoice creation and
* settlement (the paid handler).
*/
@Injectable()
export class LastMileInvoiceService {
private readonly logger = new Logger(LastMileInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly lastMileRepo: LastMileRepository,
private readonly ratesService: RatesService,
private readonly dataSource: DataSource,
) {}
/**
* Ensure the last-mile record has its invoice, generating one from the
* remainingPayment if absent. Called when a last-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 (invoices FK requires a companyId).
*/
async ensureInvoiceFor(record: LastMile): Promise<Invoice | null> {
// Check if invoice already exists
const existing = await this.billing.findPayable(
'last_mile' as Freight.InvoiceSource,
record.id,
'DELIVERY_FEE',
);
if (existing) return existing;
// Can't bill without company
const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } }));
if (!lm) return null;
if (!lm.booking?.companyId) {
this.logger.warn(
`Skipping invoice for last-mile record ${record.id}: no company to bill.`,
);
return null;
}
// Rule-based pricing first (bulk per-ton-km / container distance bands
// against the exact km): when a LIVE last-mile rate covers the job, it —
// not the per-vehicle price/km — is the delivery fee, with its own
// currency and per-size breakdown. Same resolver setDistances used to
// write remainingPayment, recomputed here so a rate change between the
// two moments settles on the invoice's side.
const exactKm = Number(record.exactKm) || 0;
const rule =
exactKm > 0
? await ruleBasedLastMileCharge(
this.dataSource,
await this.ratesService.findLiveRatesDetailed(),
record.id,
exactKm,
)
: null;
if (rule && rule.total > 0) {
return this.billing.generateInvoice({
source: 'last_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: rule.currency,
lines: rule.lines.map((line) => ({
chargeType: 'DELIVERY',
description: line.description,
quantity: line.quantity,
unitRate: line.unitRate,
amount: line.amount,
})),
totalAmount: rule.total,
});
}
// numeric columns come back as strings — coerce before billing.
const totalAmount = Number(record.remainingPayment) || 0;
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
this.logger.warn(
`Skipping invoice for last-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;
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',
description: 'Last-mile delivery',
quantity: 1,
unitRate: totalAmount,
amount: totalAmount,
},
],
totalAmount,
};
return this.billing.generateInvoice(input);
}
/**
* React to a last-mile invoice being paid — the settlement branch point.
* Advances the last-mile record to mark post-payment as completed.
*/
@OnEvent('last_mile.invoice.paid')
async onPaid(payload: InvoiceEventPayload): Promise<void> {
if (payload.type === 'DELIVERY_FEE') {
const record = await this.lastMileRepo.findById(payload.sourceId);
if (record) {
this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`);
} else {
this.logger.warn(
`Cannot mark last-mile record ${payload.sourceId} as paid: not found.`,
);
}
}
}
}