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 { 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.bookingId, { 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: '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 { 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}.`); } } }