mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
107 lines
3.5 KiB
TypeScript
107 lines
3.5 KiB
TypeScript
import { 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;
|
|
}
|
|
|
|
const totalAmount = 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;
|
|
}
|
|
|
|
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: 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}.`);
|
|
}
|
|
}
|
|
}
|