mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add expiration to invoice
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add the `EXPIRED` invoice status. An invoice expires when its source's pay
|
||||
* window closes before settlement (e.g. a booking whose `paymentDeadline`
|
||||
* lapses) — driven event-style from the domain via `BillingService.expirePayable`,
|
||||
* which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out
|
||||
* of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and
|
||||
* `OVERDUE` (still payable).
|
||||
*
|
||||
* Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and
|
||||
* not referenced in this same transaction, so it is PG 12+ safe.
|
||||
*/
|
||||
export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface {
|
||||
name = "AddExpiredInvoiceStatus1830000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Postgres cannot drop individual enum values; EXPIRED is left on
|
||||
// freight.invoices_status_enum (harmless, unused after down).
|
||||
}
|
||||
}
|
||||
@@ -628,6 +628,58 @@ export class BillingService {
|
||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire a source's currently-open invoice (its pay window closed before
|
||||
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
||||
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
||||
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
|
||||
* (already paid/cancelled/expired).
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
||||
* the batch engine) to enlist in its DB transaction.
|
||||
*/
|
||||
async expirePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.transition(
|
||||
invoice.id,
|
||||
Freight.InvoiceStatus.Expired,
|
||||
"expired",
|
||||
{},
|
||||
mg,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
|
||||
* booking invoice is generated before the pay window opens (at booking
|
||||
* creation/approval), so its printed due date is refreshed when the batch engine
|
||||
* sets `paymentDeadline`. No-op when the source has no open invoice.
|
||||
*/
|
||||
async syncPayableDueDate(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
dueAt: Date,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(Invoice, { id: invoice.id }, { dueAt });
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
@@ -179,6 +181,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly notifier: BookingNotifierService,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
@@ -972,6 +975,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
// The invoice was generated at booking creation/approval, before this pay
|
||||
// window opened — refresh its printed due date to the real deadline.
|
||||
await this.billing.syncPayableDueDate(
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
deadline,
|
||||
);
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
|
||||
@@ -1018,6 +1028,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
// Pay window closed before settlement → expire the booking's open invoice too
|
||||
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
||||
// source-agnostic.
|
||||
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id);
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
@@ -1057,6 +1071,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
// Displaced → EXPIRED: close its open invoice too, so a dead booking
|
||||
// can't still be paid (mirrors `expire()`; enlisted in this txn).
|
||||
await this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
victim.id,
|
||||
manager,
|
||||
);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
freed = this.add(freed, this.needFor(victim, wagonLengths));
|
||||
|
||||
@@ -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 { Container } from '../container-management/entities/container.entity';
|
||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||
@@ -42,6 +43,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
ImportDjiboutiOperation,
|
||||
]),
|
||||
forwardRef(() => BookingsModule),
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
|
||||
@@ -140,6 +140,8 @@ export enum InvoiceStatus {
|
||||
Overdue = "OVERDUE",
|
||||
Cancelled = "CANCELLED",
|
||||
Refunded = "REFUNDED",
|
||||
/** Pay window closed before settlement; terminal, cannot be paid. */
|
||||
Expired = "EXPIRED",
|
||||
}
|
||||
|
||||
/** Originating subsystem an invoice bills for; namespaces invoice events. */
|
||||
|
||||
Reference in New Issue
Block a user