This commit is contained in:
natib21
2026-06-29 15:28:04 +00:00
parent d94b73fe02
commit 04bc9eded6
7 changed files with 237 additions and 11 deletions

View File

@@ -0,0 +1,106 @@
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.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<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}.`);
}
}
}

View File

@@ -20,13 +20,17 @@ import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
import { FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileService } from './first-mile.service';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@TrainSchedulingView()
export class FirstMileController {
constructor(private readonly firstMileService: FirstMileService) {}
constructor(
private readonly firstMileService: FirstMileService,
private readonly firstMileInvoiceService: FirstMileInvoiceService,
) {}
@Get()
@ApiOperation({ summary: 'List first-mile legs' })
@@ -73,8 +77,13 @@ export class FirstMileController {
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a first-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
return this.firstMileService.update(id, dto);
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
const record = await this.firstMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) {
await this.firstMileInvoiceService.ensureInvoiceFor(record);
}
return record;
}
@Delete(':id')

View File

@@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
@@ -8,19 +9,21 @@ import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService],
providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
})
export class FirstMileModule {}