Merge pull request #397 from Tria-plc/feight/fix/ui-ux-issues

Feight/fix/UI ux issues
This commit is contained in:
Nathnael Wondisha
2026-07-02 12:49:07 +03:00
committed by GitHub
18 changed files with 478 additions and 392 deletions

View File

@@ -118,6 +118,8 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
migrationsRun: true, migrationsRun: true,
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false, synchronize: false,
logging: process.env.NODE_ENV === "development", logging: process.env.DB_LOG
? process.env.DB_LOG === "true"
: process.env.NODE_ENV === "development",
}; };
}); });

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller"; import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller"; import { PortalBillingController } from "./portal-billing.controller";
import { PaymentController } from "./payment.controller";
import { BillingService } from "./billing.service"; import { BillingService } from "./billing.service";
import { DocumentsModule } from "./documents/documents.module"; import { DocumentsModule } from "./documents/documents.module";
import { Invoice } from "./entities/invoice.entity"; import { Invoice } from "./entities/invoice.entity";
@@ -19,7 +20,7 @@ import { CompaniesModule } from "../companies/companies.module";
CompaniesModule, CompaniesModule,
DocumentsModule, DocumentsModule,
], ],
controllers: [BillingController, PortalBillingController], controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository], providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService], exports: [BillingService],
}) })

View File

@@ -116,12 +116,14 @@ describe("BillingService.generateInvoice", () => {
}); });
describe("BillingService.markInvoiceAsPaid", () => { describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => { it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
const open = { const open = {
id: "inv-1", id: "inv-1",
status: Freight.InvoiceStatus.Pending, status: Freight.InvoiceStatus.Pending,
source: "booking", source: "booking",
sourceId: "booking-1", sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
}; };
const mg = { const mg = {
findOne: jest.fn().mockResolvedValue(open), findOne: jest.fn().mockResolvedValue(open),
@@ -143,7 +145,22 @@ describe("BillingService.markInvoiceAsPaid", () => {
expect(mg.update).toHaveBeenCalledWith( expect(mg.update).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
{ id: "inv-1" }, { id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, {
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
paidAt: expect.any(Date),
paidAmount: 1500,
balanceAmount: 0,
payments: [
{
amount: 1500,
method: "GATEWAY",
reference: "pay-1",
paidAt: expect.any(String),
metadata: null,
},
],
},
); );
expect(events.emit).toHaveBeenCalledWith( expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid", "booking.invoice.paid",
@@ -190,8 +207,14 @@ describe("BillingService.recordPayment", () => {
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
}; };
const events = makeEvents(); const events = makeEvents();
const dataSource = {
manager: mg,
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)),
};
const service = new BillingService( const service = new BillingService(
{ manager: mg } as never, dataSource as never,
{} as never, {} as never,
{} as never, {} as never,
events as never, events as never,
@@ -257,6 +280,14 @@ describe("BillingService.recordPayment", () => {
expect(mg.update).not.toHaveBeenCalled(); expect(mg.update).not.toHaveBeenCalled();
}); });
it("rejects a payment that exceeds the outstanding balance", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(
service.recordPayment("inv-1", { amount: 1500 }),
).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects payment against a cancelled invoice", async () => { it("rejects payment against a cancelled invoice", async () => {
const { service, mg } = serviceFor( const { service, mg } = serviceFor(
openInvoice({ status: Freight.InvoiceStatus.Cancelled }), openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
@@ -265,74 +296,3 @@ describe("BillingService.recordPayment", () => {
expect(mg.update).not.toHaveBeenCalled(); expect(mg.update).not.toHaveBeenCalled();
}); });
}); });
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.anything(),
);
});
it("is a no-op (returns null) when the source has no open invoice", async () => {
const mg = {
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
});

View File

@@ -49,7 +49,6 @@ const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.PartiallyPaid,
@@ -285,20 +284,15 @@ export class BillingService {
/** /**
* Initiate gateway payment for one of the customer's own invoices. Verifies * Initiate gateway payment for one of the customer's own invoices. Verifies
* ownership, then charges whichever open invoice the source currently has * ownership, then charges the invoice directly by ID (see {@link payInvoice}).
* (see {@link payInvoice}).
*/ */
async payInvoiceForUser( async payInvoiceForUser(
id: string, id: string,
userId: string, userId: string,
opts: PayInvoiceOptions = {}, opts: PayInvoiceOptions = {},
): Promise<InitiateResponseDto> { ): Promise<InitiateResponseDto> {
const invoice = await this.findByIdForUser(id, userId); await this.findByIdForUser(id, userId);
return this.payInvoice( return this.payInvoice(id, opts);
invoice.source as Freight.InvoiceSource,
invoice.sourceId,
opts,
);
} }
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
@@ -423,23 +417,89 @@ export class BillingService {
// ── State transitions ──────────────────────────────────────────────────────── // ── State transitions ────────────────────────────────────────────────────────
/** /**
* Mark an invoice paid and link the gateway payment, then emit * Run `fn` inside a transaction and only emit its returned domain event
* `${source}.invoice.paid`. Full-payment only — no partial settlement. * after commit. When the caller passes their own `manager`, they own commit
* No-op when the invoice is already paid. Pass `manager` to enlist in a * timing — `fn`'s event fires inline as soon as it resolves (the outer
* caller's transaction. * transaction may still roll back afterwards; this is the caller's
* documented tradeoff). When no `manager` is given, this opens its own
* transaction and defers the emit until after that transaction commits, so
* listeners (e.g. booking advancement) can never observe an invoice change
* that then rolls back.
*/
private async runTransition<T>(
manager: EntityManager | undefined,
fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>,
): Promise<T> {
if (manager) {
const { result, emit } = await fn(manager);
emit?.();
return result;
}
let pending: (() => void) | undefined;
const result = await this.dataSource.transaction(async (mg) => {
const out = await fn(mg);
pending = out.emit;
return out.result;
});
pending?.();
return result;
}
/**
* Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts,
* append the settlement to the `payments` ledger, link the gateway payment,
* then emit `${source}.invoice.paid`. Full-payment only — no partial
* settlement. No-op when the invoice is already paid. Pass `manager` to
* enlist in a caller's transaction; otherwise locks the row for update and
* emits only after commit (see {@link runTransition}).
*/ */
async markInvoiceAsPaid( async markInvoiceAsPaid(
invoiceId: string, invoiceId: string,
paymentId: string | null = null, paymentId: string | null = null,
manager?: EntityManager, manager?: EntityManager,
settlement: { providerTxnId?: string; paidAt?: Date } = {},
): Promise<Invoice | null> { ): Promise<Invoice | null> {
return this.transition( return this.runTransition(manager, async (mg) => {
invoiceId, const invoice = await mg.findOne(Invoice, {
Freight.InvoiceStatus.Paid, where: { id: invoiceId },
"paid", lock: { mode: "pessimistic_write" },
{ paymentId: paymentId ?? undefined }, });
manager, if (!invoice) {
); throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
return { result: invoice };
}
const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date();
const settledAmount = round2(
Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0),
);
const entry: InvoicePayment = {
amount: settledAmount,
method: "GATEWAY",
reference: settlement.providerTxnId ?? paymentId ?? null,
paidAt: paidAt.toISOString(),
metadata: null,
};
const payments = [...(invoice.payments ?? []), entry];
const patch = {
status: Freight.InvoiceStatus.Paid,
paymentId,
paidAt,
paidAmount: invoice.totalAmount,
balanceAmount: 0,
payments,
};
await mg.update(Invoice, { id: invoiceId }, patch as never);
const updated = { ...invoice, ...patch } as Invoice;
return {
result: updated,
emit: () => this.emitInvoiceEvent("paid", updated),
};
});
} }
/** /**
@@ -451,9 +511,11 @@ export class BillingService {
* at the warehouse counter); gateway settlement goes through * at the warehouse counter); gateway settlement goes through
* {@link markInvoiceAsPaid}. * {@link markInvoiceAsPaid}.
* *
* Throws when the invoice is missing, cancelled, refunded, already fully paid, * Throws when the invoice is missing, cancelled, refunded, already fully
* or when `amount` is not positive. Pass `manager` to enlist in a caller's * paid, `amount` is not positive, or `amount` exceeds the outstanding
* transaction. * balance. Pass `manager` to enlist in a caller's transaction; otherwise
* locks the row for update and emits only after commit (see
* {@link runTransition}).
*/ */
async recordPayment( async recordPayment(
invoiceId: string, invoiceId: string,
@@ -466,62 +528,71 @@ export class BillingService {
); );
} }
const mg = manager ?? this.dataSource.manager; return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); const invoice = await mg.findOne(Invoice, {
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); where: { id: invoiceId },
if (invoice.status === Freight.InvoiceStatus.Cancelled) { lock: { mode: "pessimistic_write" },
throw new BadRequestException("Cannot pay a cancelled invoice."); });
} if (!invoice) {
if (invoice.status === Freight.InvoiceStatus.Refunded) { throw new NotFoundException(`Invoice ${invoiceId} not found`);
throw new BadRequestException("Cannot pay a refunded invoice."); }
} if (invoice.status === Freight.InvoiceStatus.Cancelled) {
if (invoice.status === Freight.InvoiceStatus.Paid) { throw new BadRequestException("Cannot pay a cancelled invoice.");
throw new BadRequestException("Invoice is already fully paid."); }
} if (invoice.status === Freight.InvoiceStatus.Refunded) {
throw new BadRequestException("Cannot pay a refunded invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
if (round2(input.amount) > Number(invoice.balanceAmount)) {
throw new BadRequestException(
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
);
}
const at = input.paidAt ?? new Date(); const at = input.paidAt ?? new Date();
const { paidAmount, balanceAmount, fullyPaid } = applySettlement( const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
invoice.totalAmount, invoice.totalAmount,
invoice.paidAmount, invoice.paidAmount,
input.amount, input.amount,
); );
const status = fullyPaid const status = fullyPaid
? Freight.InvoiceStatus.Paid ? Freight.InvoiceStatus.Paid
: Freight.InvoiceStatus.PartiallyPaid; : Freight.InvoiceStatus.PartiallyPaid;
const entry: InvoicePayment = { const entry: InvoicePayment = {
amount: round2(input.amount), amount: round2(input.amount),
method: input.method ?? null, method: input.method ?? null,
reference: input.reference ?? null, reference: input.reference ?? null,
paidAt: at.toISOString(), paidAt: at.toISOString(),
metadata: input.metadata ?? null, metadata: input.metadata ?? null,
}; };
const payments = [...(invoice.payments ?? []), entry]; const payments = [...(invoice.payments ?? []), entry];
await mg.update(Invoice, { id: invoice.id }, { const patch = {
paidAmount, paidAmount,
balanceAmount, balanceAmount,
status, status,
payments, payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null), paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
} as never); };
await mg.update(Invoice, { id: invoice.id }, patch as never);
const updated = { const updated = { ...invoice, ...patch } as Invoice;
...invoice, return {
paidAmount, result: updated,
balanceAmount, emit: fullyPaid
status, ? () => this.emitInvoiceEvent("paid", updated)
payments, : undefined,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null), };
} as Invoice; });
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
return updated;
} }
/** /**
* Mark an invoice refunded and emit `${source}.invoice.refunded`. * Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded. * No-op when already refunded. Throws when the invoice has no recorded
* payment (nothing to refund).
*/ */
async markInvoiceAsRefunded( async markInvoiceAsRefunded(
invoiceId: string, invoiceId: string,
@@ -533,12 +604,20 @@ export class BillingService {
"refunded", "refunded",
{}, {},
manager, manager,
(invoice) => {
if (!(Number(invoice.paidAmount) > 0)) {
throw new BadRequestException(
"Cannot refund an invoice with no recorded payment.",
);
}
},
); );
} }
/** /**
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`. * Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
* No-op when already cancelled. * No-op when already cancelled. Throws when the invoice has payments
* recorded against it (refund it instead).
*/ */
async cancelInvoice( async cancelInvoice(
invoiceId: string, invoiceId: string,
@@ -550,16 +629,23 @@ export class BillingService {
"cancelled", "cancelled",
{}, {},
manager, manager,
(invoice) => {
if (Number(invoice.paidAmount) > 0) {
throw new BadRequestException(
"Cannot cancel an invoice that has payments recorded against it.",
);
}
},
); );
} }
/** /**
* Load the invoice, apply the new status (+ extra columns), then emit * Load the invoice, apply the new status (+ extra columns), then emit
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already * `${source}.invoice.<event>`. No-op (returns the invoice, skipping `guard`)
* in the target status. Throws when the invoice does not exist. * when it is already in the target status. Throws when the invoice does not
* * exist or `guard` rejects the current state. Pass `manager` to enlist in a
* Note: the event fires in-process synchronously. When a `manager` from an * caller's transaction; otherwise locks the row for update and emits only
* outer transaction is passed, listeners run before that transaction commits. * after commit (see {@link runTransition}).
*/ */
private async transition( private async transition(
invoiceId: string, invoiceId: string,
@@ -567,17 +653,27 @@ export class BillingService {
event: string, event: string,
extra: { paymentId?: string }, extra: { paymentId?: string },
manager?: EntityManager, manager?: EntityManager,
guard?: (invoice: Invoice) => void,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager; return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); const invoice = await mg.findOne(Invoice, {
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); where: { id: invoiceId },
if (invoice.status === status) return invoice; lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === status) return { result: invoice };
guard?.(invoice);
await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
const updated = { ...invoice, ...extra, status } as Invoice; const updated = { ...invoice, ...extra, status } as Invoice;
this.emitInvoiceEvent(event, updated); return {
return updated; result: updated,
emit: () => this.emitInvoiceEvent(event, updated),
};
});
} }
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */ /** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
@@ -601,16 +697,17 @@ export class BillingService {
// ── Payment reconciliation (by source) ─────────────────────────────────────── // ── Payment reconciliation (by source) ───────────────────────────────────────
/** /**
* The invoice a gateway payment should settle for a source record, or null if * The invoice a source record already has open, or null if it needs a new
* none. This is the billing document of record for "what is owed" — callers * one. This is the idempotency check every `ensureInvoiceFor*` (booking,
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than * first-mile, last-mile) runs before generating — it must see DRAFT
* recomputing from the source's own total, so discounts/penalties/adjustments * invoices too, not just issued ones, otherwise a source that already has
* carried on the invoice are honored. * an unissued draft gets a second, duplicate invoice minted alongside it
* instead of that draft being reused and then issued.
* *
* Pass `type` to select a specific invoice when a source carries several (e.g. * Pass `type` to select a specific invoice when a source carries several (e.g.
* a booking's up-front vs final charge); omit it to settle whichever single * a booking's up-front vs final charge); omit it to settle whichever single
* invoice is currently open. Returns the most recent matching open (unpaid, * invoice is currently open. Returns the most recent matching draft-or-open
* non-cancelled) invoice. * (unpaid, non-cancelled) invoice.
*/ */
findPayable( findPayable(
source: Freight.InvoiceSource, source: Freight.InvoiceSource,
@@ -621,7 +718,7 @@ export class BillingService {
where: { where: {
source, source,
sourceId, sourceId,
status: In(OPEN_STATUSES), status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}), ...(type ? { type } : {}),
}, },
order: { issuedAt: "DESC" }, order: { issuedAt: "DESC" },
@@ -629,56 +726,24 @@ export class BillingService {
} }
/** /**
* Settle a source's currently-open invoice as paid and link the gateway * Pass `type` to select a specific invoice when a source carries several (e.g.
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then * a booking's up-front vs final charge); omit it to settle whichever single
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial * invoice is currently open. Returns the most recent matching open (unpaid,
* settlement. No-op (returns null) when the source has no open invoice. * non-cancelled) invoice.
*
* Type-blind by design: settles whichever invoice is due; any per-type reaction
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
* Pass the caller's transaction `manager` to enlist in its DB transaction.
*
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
* for callers that settle by source rather than by gateway intent id.
*/ */
async settlePayable( findInvoice(
source: Freight.InvoiceSource, source: Freight.InvoiceSource,
sourceId: string, sourceId: string,
paymentId: string | null, type?: string,
manager?: EntityManager,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager; return this.dataSource.getRepository(Invoice).findOne({
const invoice = await mg.findOne(Invoice, { where: {
where: { source, sourceId, status: In(OPEN_STATUSES) }, source,
sourceId,
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" }, order: { issuedAt: "DESC" },
}); });
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
}
/**
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
* No-op (returns null) when the source has no paid invoice.
*
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
* to enlist in its DB transaction.
*/
async refundPayable(
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: Freight.InvoiceStatus.Paid },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsRefunded(invoice.id, mg);
} }
/** /**
@@ -694,11 +759,17 @@ export class BillingService {
async expirePayable( async expirePayable(
source: Freight.InvoiceSource, source: Freight.InvoiceSource,
sourceId: string, sourceId: string,
type?: string,
manager?: EntityManager, manager?: EntityManager,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager; const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) }, where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" }, order: { issuedAt: "DESC" },
}); });
if (!invoice) return null; if (!invoice) return null;
@@ -722,17 +793,29 @@ export class BillingService {
source: Freight.InvoiceSource, source: Freight.InvoiceSource,
sourceId: string, sourceId: string,
dueAt: Date, dueAt: Date,
type?: string,
manager?: EntityManager, manager?: EntityManager,
): Promise<void> { ): Promise<void> {
const mg = manager ?? this.dataSource.manager; const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) }, where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" }, order: { issuedAt: "DESC" },
}); });
if (!invoice) return; if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt }); await mg.update(Invoice, { id: invoice.id }, { dueAt });
} }
/**
* Force an invoice to `status`, including issuing a still-DRAFT invoice
* (stamping `issuedAt`) — unlike the other transitions here, this is a
* blunt admin/workflow override, not a settlement. No-op when the invoice
* is missing or already terminal (paid/cancelled/refunded/expired).
*/
async updateStatus( async updateStatus(
invoiceId: string, invoiceId: string,
status: Freight.InvoiceStatus, status: Freight.InvoiceStatus,
@@ -740,29 +823,33 @@ export class BillingService {
): Promise<void> { ): Promise<void> {
const mg = manager ?? this.dataSource.manager; const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId, status: In(OPEN_STATUSES) }, where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) },
order: { issuedAt: "DESC" },
}); });
if (!invoice) return; if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { status }); await mg.update(
Invoice,
{ id: invoice.id },
{ status, issuedAt: invoice.issuedAt ?? new Date() },
);
} }
// ── Payment initiation & settlement (the gateway boundary) ─────────────────── // ── Payment initiation & settlement (the gateway boundary) ───────────────────
/** /**
* Charge a source's open invoice through the payment gateway. Billing is the * Charge an invoice through the payment gateway. Billing is the single place
* single place that turns "what is owed" (the invoice) into a payment intent — * that turns "what is owed" (the invoice) into a payment intent — the domain
* the domain never talks to the payment service directly. Resolves the open * never talks to the payment service directly. Resolves the invoice by ID,
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on * opens an intent for `invoice.balanceAmount` (so partial payments are honored),
* the invoice (the settlement correlation key), and returns the client action. * records the intent id on the invoice (the settlement correlation key), and
* returns the client action.
* *
* When the provider settles synchronously, the invoice is settled inline here — * When the provider settles synchronously, the invoice is settled inline here —
* after the intent id is stored — so the `payment.succeeded` correlation can * after the intent id is stored — so the `payment.succeeded` correlation can
* never fire before the link exists. Throws when the source has no open invoice. * never fire before the link exists. Throws when the invoice is not found or
* not in an open/payable status.
*/ */
async payInvoice( async payInvoice(
source: Freight.InvoiceSource, invoiceId: string,
sourceId: string,
opts: { opts: {
method?: string; method?: string;
platform?: "web" | "mobile"; platform?: "web" | "mobile";
@@ -771,15 +858,17 @@ export class BillingService {
failureUrl?: string; failureUrl?: string;
} = {}, } = {},
): Promise<InitiateResponseDto> { ): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId); const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) },
});
if (!invoice) { if (!invoice) {
throw new NotFoundException( throw new NotFoundException(
`No open invoice to charge for ${source}:${sourceId}`, `Invoice ${invoiceId} not found or not in a payable status`,
); );
} }
const result = await this.payment.initiate({ const result = await this.payment.initiate({
referenceId: sourceId, referenceId: invoice.sourceId,
source: invoice.source, source: invoice.source,
// Freight payments settle under the generic SHIPMENT reference — how the // Freight payments settle under the generic SHIPMENT reference — how the
// payment service attributes them to the freight API. The payment ↔ invoice // payment service attributes them to the freight API. The payment ↔ invoice
@@ -788,7 +877,7 @@ export class BillingService {
// service branches on a domain-specific reference type. // service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT, referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber, orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)), amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency, currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`, reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR", method: opts.method ?? "TELEBIRR",
@@ -823,8 +912,8 @@ export class BillingService {
*/ */
async settleByPaymentId( async settleByPaymentId(
paymentId: string, paymentId: string,
_providerTxnId?: string, providerTxnId?: string,
_paidAt?: Date, paidAt?: Date,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({ const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId, status: In(OPEN_STATUSES) }, where: { paymentId, status: In(OPEN_STATUSES) },
@@ -832,6 +921,9 @@ export class BillingService {
}); });
if (!invoice) return null; if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId); return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
providerTxnId,
paidAt,
});
} }
} }

View File

@@ -11,7 +11,7 @@
/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */ /** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */
export interface SqlRunner { export interface SqlRunner {
query(sql: string, params?: unknown[]): Promise<Array<{ seq: number | string }>>; query(sql: string, params?: unknown[]): Promise<unknown>;
} }
export interface InvoiceNumberOptions { export interface InvoiceNumberOptions {
@@ -34,11 +34,18 @@ export async function nextDailyInvoiceNumber(
const prefix = `${opts.code}-${ymd}-`; const prefix = `${opts.code}-${ymd}-`;
const column = opts.column ?? "invoice_number"; const column = opts.column ?? "invoice_number";
const [row] = await runner.query( // Serialize concurrent allocation for this exact day+code prefix so two
// simultaneous transactions can't both read the same MAX(seq) and mint a
// duplicate number. Session-scoped to the caller's transaction — released
// automatically on commit/rollback. Different prefixes hash to different
// keys and never contend with each other.
await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]);
const rows = (await runner.query(
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
FROM ${opts.table} WHERE ${column} LIKE $1`, FROM ${opts.table} WHERE ${column} LIKE $1`,
[`${prefix}%`], [`${prefix}%`],
); )) as Array<{ seq: number | string }>;
const next = Number(row?.seq ?? 0) + 1; const next = Number(rows[0]?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`; return `${prefix}${String(next).padStart(5, "0")}`;
} }

View File

@@ -16,9 +16,8 @@ import {
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Response } from "express"; import { Response } from "express";
import { Public } from "@edr/api-common"; import { Public } from "@edr/api-common";
import { Freight } from "@edr/types";
import { BillingService } from "../billing/billing.service"; import { BillingService } from "./billing.service";
import { import {
InitiatePaymentDto, InitiatePaymentDto,
InitiateResponseDto, InitiateResponseDto,
@@ -27,25 +26,24 @@ import {
} from "../payment/payments.dto"; } from "../payment/payments.dto";
/** /**
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a * Central payment entrypoints. Domain-agnostic the caller supplies an
* booking it maps the request to {@link Freight.InvoiceSource.Booking} and hands * invoice ID and the billing service resolves the amount and drives the
* off to billing, which resolves the invoice/amount and drives the gateway. Billing * gateway. The domain never talks to the payment service directly.
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
* Routes are unchanged (`/payments/*`) so the portal is unaffected. * Routes are unchanged (`/payments/*`) so the portal is unaffected.
*/ */
@ApiTags("Payment") @ApiTags("Payment")
@Controller("payments") @Controller("payments")
export class BookingPaymentController { export class PaymentController {
constructor(private readonly billing: BillingService) { } constructor(private readonly billing: BillingService) { }
@Post("initiate") @Post("initiate")
@ApiOperation({ @ApiOperation({
summary: "Initiate payment for a freight booking", summary: "Initiate payment for an invoice",
description: "Charges the booking's open invoice through the payment gateway.", description: "Charges the invoice through the payment gateway.",
}) })
@ApiOkResponse({ type: InitiateResponseDto }) @ApiOkResponse({ type: InitiateResponseDto })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> { initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { return this.billing.payInvoice(dto.invoiceId, {
method: dto.method, method: dto.method,
platform: dto.platform, platform: dto.platform,
payerAccount: dto.payerAccount, payerAccount: dto.payerAccount,
@@ -59,23 +57,23 @@ export class BookingPaymentController {
@ApiOperation({ @ApiOperation({
summary: "Browser checkout redirect", summary: "Browser checkout redirect",
description: description:
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", "Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
}) })
@ApiQuery({ name: "bookingId", required: true }) @ApiQuery({ name: "invoiceId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html") @ApiProduces("text/html")
async checkout( async checkout(
@Query("bookingId") bookingId: string, @Query("invoiceId") invoiceId: string,
@Query("method") method: PaymentMethodTypeEnum, @Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web", @Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response, @Res() res: Response,
) { ) {
if (!bookingId) { if (!invoiceId) {
return res return res
.status(HttpStatus.BAD_REQUEST) .status(HttpStatus.BAD_REQUEST)
.type("html") .type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId")); .send(this.buildErrorHtml("Missing required query parameter: invoiceId"));
} }
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res return res
@@ -86,8 +84,7 @@ export class BookingPaymentController {
try { try {
const result = await this.billing.payInvoice( const result = await this.billing.payInvoice(
Freight.InvoiceSource.Booking, invoiceId,
bookingId,
{ method, platform }, { method, platform },
); );
const url = const url =

View File

@@ -1,7 +1,13 @@
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter"; import { OnEvent } from "@nestjs/event-emitter";
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import { DataSource } from "typeorm"; import { DataSource, EntityManager } from "typeorm";
import { import {
BillingService, BillingService,
@@ -58,9 +64,9 @@ export class BookingInvoiceService {
* Ensure the booking has its invoice, generating one from the snapshotted * Ensure the booking has its invoice, generating one from the snapshotted
* pricing breakdown if absent. Called when a booking reaches a billable state. * pricing breakdown if absent. Called when a booking reaches a billable state.
* Idempotent — returns the existing open invoice instead of a duplicate. * Idempotent — returns the existing open invoice instead of a duplicate.
* Returns `null` (and logs) when the booking is not billable: no company to * Throws `BadRequestException` when the booking is not billable: no company
* bill (e.g. government bookings whose `companyId` is null, which the invoices * to bill (e.g. government bookings whose `companyId` is null, which the
* FK requires), or no priced amount. * invoices FK requires), or no priced amount.
*/ */
async ensureInvoiceForBooking( async ensureInvoiceForBooking(
booking: Booking, booking: Booking,
@@ -74,8 +80,8 @@ export class BookingInvoiceService {
if (existing) return existing; if (existing) return existing;
if (!booking.companyId) { if (!booking.companyId) {
this.logger.warn( throw new BadRequestException(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
); );
} }
@@ -102,7 +108,13 @@ export class BookingInvoiceService {
} }
} }
updateStatus = this.billing.updateStatus; updateStatus(
invoiceId: string,
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
return this.billing.updateStatus(invoiceId, status, manager);
}
/** /**
* Advance a booking once its prepaid invoice settles — the domain side-effect * Advance a booking once its prepaid invoice settles — the domain side-effect
@@ -165,7 +177,11 @@ export class BookingInvoiceService {
// Fall back to a single freight line when no breakdown was snapshotted. // Fall back to a single freight line when no breakdown was snapshotted.
if (lines.length === 0) { if (lines.length === 0) {
const amount = Number(booking.totalAmount); const amount = Number(booking.totalAmount);
if (!Number.isFinite(amount) || amount <= 0) throw new Error("No price"); if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException(
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
);
}
lines.push({ lines.push({
chargeType: "FREIGHT", chargeType: "FREIGHT",
description: "Rail freight", description: "Rail freight",

View File

@@ -1,43 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { BillingService } from '../billing/billing.service';
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly billing: BillingService,
) { }
/**
* Start payment for a booking. The booking never touches the payment gateway
* directly — it charges its invoice through billing, which resolves the amount
* and drives the provider. Returns the provider redirect URL (empty when none).
*/
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
method: PaymentMethodTypeEnum.TELEBIRR,
platform: 'web',
});
const action = resp.clientAction as { type?: string; url?: string } | undefined;
return {
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
};
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return booking;
}
}

View File

@@ -447,6 +447,7 @@ export class BookingTransitionService {
"CHANGES_REQUESTED", "CHANGES_REQUESTED",
"PENDING_APPROVAL", "PENDING_APPROVAL",
"CONTRACT_READY", "CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
]); ]);
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(

View File

@@ -14,13 +14,10 @@ import { BillingModule } from "../billing/billing.module";
import { FirstMileModule } from "../first-mile/first-mile.module"; import { FirstMileModule } from "../first-mile/first-mile.module";
import { BookingContractService } from "./booking-contract.service"; import { BookingContractService } from "./booking-contract.service";
import { BookingInvoiceService } from "./booking-invoice.service"; import { BookingInvoiceService } from "./booking-invoice.service";
import { BookingPaymentController } from "./booking-payment.controller";
import { BookingPaymentService } from "./booking-payment.service";
import { BookingPricingService } from "./booking-pricing.service"; import { BookingPricingService } from "./booking-pricing.service";
import { BookingReferenceDataService } from "./booking-reference-data.service"; import { BookingReferenceDataService } from "./booking-reference-data.service";
import { BookingTransitionService } from "./booking-transition.service"; import { BookingTransitionService } from "./booking-transition.service";
import { BookingsController } from "./bookings.controller"; import { BookingsController } from "./bookings.controller";
import { PayController } from "./pay.controller";
import { BookingsRepository } from "./bookings.repository"; import { BookingsRepository } from "./bookings.repository";
import { ConsolidationService } from "./consolidation.service"; import { ConsolidationService } from "./consolidation.service";
import { BookingsService } from "./bookings.service"; import { BookingsService } from "./bookings.service";
@@ -69,7 +66,7 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu
config.get<ExchangeOptions>("app.cbeExchange") ?? {}, config.get<ExchangeOptions>("app.cbeExchange") ?? {},
}), }),
], ],
controllers: [BookingsController, PayController, BookingPaymentController], controllers: [BookingsController],
providers: [ providers: [
BookingsService, BookingsService,
BookingsRepository, BookingsRepository,
@@ -79,7 +76,6 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu
BookingTransitionService, BookingTransitionService,
BookingContractService, BookingContractService,
BookingInvoiceService, BookingInvoiceService,
BookingPaymentService,
ContractTemplateResolver, ContractTemplateResolver,
ContractViewModelBuilder, ContractViewModelBuilder,
ContractPricingScheduleBuilder, ContractPricingScheduleBuilder,

View File

@@ -1,27 +0,0 @@
import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingPaymentService } from './booking-payment.service';
// import { BookingTransitionService } from './booking-transition.service';
// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
// import { Booking } from './entities/booking.entity';
// import { BookingNextStep } from './booking-next-step.util';
@ApiTags('payments')
@ApiBearerAuth()
@Controller('bookings')
export class PayController {
constructor(
private readonly paymentService: BookingPaymentService,
// private readonly transitionService: BookingTransitionService,
) { }
@Post(':id/payment/pay')
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
async pay(@Param('id', ParseUUIDPipe) id: string) {
return await this.paymentService.pay(id);
// const abstract = await this.transitionService.enrichBookingResponse(booking);
// return { ...abstract, paymentReceipt: receipt };
}
}

View File

@@ -48,7 +48,7 @@ export class FirstMileInvoiceService {
} }
// Fetch the booking to get the companyId and companyProfileId // Fetch the booking to get the companyId and companyProfileId
const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } })); const fm = record.booking ? record : (await this.firstMileRepo.findById(record.id, { relations: { booking: true } }));
if (!fm) return null; if (!fm) return null;
if (!fm.booking?.companyId) { if (!fm.booking?.companyId) {
this.logger.warn( this.logger.warn(

View File

@@ -15,9 +15,9 @@ export enum PaymentMethodTypeEnum {
} }
export class InitiatePaymentDto { export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" }) @ApiProperty({ example: "invoice-uuid" })
@IsString() @IsString()
bookingId!: string; invoiceId!: string;
@ApiProperty({ @ApiProperty({
enum: PaymentMethodTypeEnum, enum: PaymentMethodTypeEnum,

View File

@@ -1069,6 +1069,7 @@ export class BookingBatchService implements OnModuleInit {
Freight.InvoiceSource.Booking, Freight.InvoiceSource.Booking,
booking.id, booking.id,
deadline, deadline,
"PREPAID",
); );
await this.notifier.payNow(booking, deadline); await this.notifier.payNow(booking, deadline);
} }
@@ -1120,7 +1121,7 @@ export class BookingBatchService implements OnModuleInit {
// Pay window closed before settlement → expire the booking's open invoice too // Pay window closed before settlement → expire the booking's open invoice too
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// source-agnostic. // source-agnostic.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id); await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
this.notifier.expired(booking); this.notifier.expired(booking);
} }
@@ -1167,6 +1168,7 @@ export class BookingBatchService implements OnModuleInit {
await this.billing.expirePayable( await this.billing.expirePayable(
Freight.InvoiceSource.Booking, Freight.InvoiceSource.Booking,
victim.id, victim.id,
"PREPAID",
manager, manager,
); );
}); });

View File

@@ -15,13 +15,22 @@ import {
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { ArrowLeft, CreditCard, Download, ExternalLink, Receipt } from "lucide-react"; import {
ArrowLeft,
CreditCard,
Download,
ExternalLink,
Receipt,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service"; import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service"; import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { saveBlob } from "@/utils/download"; import { saveBlob } from "@/utils/download";
@@ -38,7 +47,12 @@ import {
function MetaItem({ label, value }: { label: string; value: string }) { function MetaItem({ label, value }: { label: string; value: string }) {
return ( return (
<Box> <Box>
<Text fz={11} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}> <Text
fz={11}
fw={700}
c={MUTED}
style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}
>
{label} {label}
</Text> </Text>
<Text fz={14} mt={4} style={{ color: INK }}> <Text fz={14} mt={4} style={{ color: INK }}>
@@ -52,30 +66,25 @@ export default function InvoiceDetailPage() {
const { id = "" } = useParams(); const { id = "" } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: invoice, isLoading, isError } = useQuery( const {
api.invoices.get.queryOptions({ input: { id } }), data: invoice,
); isLoading,
isError,
} = useQuery(api.invoices.get.queryOptions({ input: { id } }));
const [payModalOpen, setPayModalOpen] = useState(false); const [payModalOpen, setPayModalOpen] = useState(false);
// Extracted for payMutation callbacks — guaranteed defined when they run // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
// (guarded by the early return below). // one of the signed-in customer's own invoices (unlike the admin-facing
const invSource = invoice?.source; // /payments/initiate, which takes any invoiceId with no ownership check).
const invSourceId = invoice?.sourceId;
const payMutation = useMutation({ const payMutation = useMutation({
mutationFn: async (method: PaymentMethod) => { mutationFn: (method: PaymentMethod) =>
const bookingId = api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
invSource === "warehouse"
? (await warehouseInvoicesService.get(id)).bookingId ?? invSourceId!
: invSourceId!;
return api.payments.initiate.call({ bookingId, method });
},
onSuccess: (data, method) => { onSuccess: (data, method) => {
const redirectUrl = const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url ? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId: invSourceId!, method }); : paymentsService.checkoutUrlForInvoice({ invoiceId: id, method });
window.location.href = redirectUrl; window.location.href = redirectUrl;
}, },
}); });
@@ -180,7 +189,12 @@ export default function InvoiceDetailPage() {
{/* Header */} {/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md"> <Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap={12} align="center" wrap="wrap"> <Group gap={12} align="center" wrap="wrap">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}> <Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
{invoice.invoiceNumber} {invoice.invoiceNumber}
</Title> </Title>
<InvoiceStatusBadge status={invoice.status} /> <InvoiceStatusBadge status={invoice.status} />
@@ -193,7 +207,9 @@ export default function InvoiceDetailPage() {
size="md" size="md"
leftSection={<ExternalLink size={16} />} leftSection={<ExternalLink size={16} />}
onClick={viewSource} onClick={viewSource}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }} styles={{
root: { fontWeight: 600, height: 42, paddingInline: 16 },
}}
> >
View source View source
</Button> </Button>
@@ -204,7 +220,9 @@ export default function InvoiceDetailPage() {
size="md" size="md"
leftSection={<Download size={16} />} leftSection={<Download size={16} />}
onClick={downloadInvoice} onClick={downloadInvoice}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }} styles={{
root: { fontWeight: 600, height: 42, paddingInline: 16 },
}}
> >
Download invoice Download invoice
</Button> </Button>
@@ -216,7 +234,9 @@ export default function InvoiceDetailPage() {
size="md" size="md"
leftSection={<Receipt size={16} />} leftSection={<Receipt size={16} />}
onClick={downloadReceipt} onClick={downloadReceipt}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 16 } }} styles={{
root: { fontWeight: 600, height: 42, paddingInline: 16 },
}}
> >
Receipt Receipt
</Button> </Button>
@@ -229,9 +249,12 @@ export default function InvoiceDetailPage() {
leftSection={<CreditCard size={16} />} leftSection={<CreditCard size={16} />}
loading={payMutation.isPending} loading={payMutation.isPending}
onClick={handlePay} onClick={handlePay}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }} styles={{
root: { fontWeight: 600, height: 42, paddingInline: 18 },
}}
> >
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)} Pay{" "}
{formatCurrency(Number(invoice.totalAmount), invoice.currency)}
</Button> </Button>
)} )}
</Group> </Group>
@@ -241,7 +264,10 @@ export default function InvoiceDetailPage() {
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}> <Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg"> <SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
<MetaItem label="Billed To" value={billedTo(invoice)} /> <MetaItem label="Billed To" value={billedTo(invoice)} />
<MetaItem label="Source" value={`${titleCase(invoice.source)} · ${invoice.type}`} /> <MetaItem
label="Source"
value={`${titleCase(invoice.source)} · ${invoice.type}`}
/>
<MetaItem label="Issued" value={fmtDate(invoice.issuedAt)} /> <MetaItem label="Issued" value={fmtDate(invoice.issuedAt)} />
<MetaItem label="Due" value={fmtDate(invoice.dueAt)} /> <MetaItem label="Due" value={fmtDate(invoice.dueAt)} />
</SimpleGrid> </SimpleGrid>
@@ -249,7 +275,12 @@ export default function InvoiceDetailPage() {
<Divider my="lg" color={BORDER} /> <Divider my="lg" color={BORDER} />
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text fz={14} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}> <Text
fz={14}
fw={700}
c={MUTED}
style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}
>
Total Total
</Text> </Text>
<Text fz={24} fw={800} style={{ color: INK }}> <Text fz={24} fw={800} style={{ color: INK }}>
@@ -349,7 +380,10 @@ export default function InvoiceDetailPage() {
payMutation.reset(); payMutation.reset();
} }
}} }}
amountLabel={formatCurrency(Number(invoice.totalAmount), invoice.currency)} amountLabel={formatCurrency(
Number(invoice.totalAmount),
invoice.currency,
)}
currency={invoice.currency} currency={invoice.currency}
processing={payMutation.isPending} processing={payMutation.isPending}
error={ error={

View File

@@ -1,5 +1,5 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, Download, Eye } from "lucide-react"; import { CreditCard, Download, Eye } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -8,7 +8,9 @@ import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig"; import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -51,17 +53,41 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
: "/contracts/new"; : "/contracts/new";
const onRebook = () => navigate(rebookTo); const onRebook = () => navigate(rebookTo);
// POST /payments/initiate creates the intent and returns the provider's // Billing is invoice-centric — resolve the booking's currently payable
// redirect URL (clientAction.url). Send the browser straight there; fall back // invoice (same query/key BookingPaymentPanel uses, so this shares its
// to the public /payments/checkout page if no redirect URL came back. // cache) and pay it through the ownership-checked portal route.
const { data: bookingInvoices = [] } = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
});
const payableInvoiceId = bookingInvoices.find((inv) =>
isPayable(inv.status),
)?.id;
// POST /billing/my-invoices/:id/pay creates the intent and returns the
// provider's redirect URL (clientAction.url). Send the browser straight
// there; fall back to the public /payments/checkout page if no redirect
// URL came back.
const payMutation = useMutation({ const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => mutationFn: (method: PaymentMethod) => {
api.payments.initiate.call({ bookingId: booking.id, method }), if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => { onSuccess: (data, method) => {
const redirectUrl = const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url ? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId: booking.id, method }); : paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = redirectUrl; window.location.href = redirectUrl;
}, },
}); });

View File

@@ -17,7 +17,7 @@ export type PaymentMethod =
export type PaymentPlatform = "web" | "mobile"; export type PaymentPlatform = "web" | "mobile";
export interface InitiatePaymentPayload { export interface InitiatePaymentPayload {
bookingId: string; invoiceId: string;
method: PaymentMethod; method: PaymentMethod;
platform?: PaymentPlatform; platform?: PaymentPlatform;
payerAccount?: string; payerAccount?: string;
@@ -67,6 +67,25 @@ function buildCheckoutUrl(payload: {
return `${base}${P.CHECKOUT}?${params.toString()}`; return `${base}${P.CHECKOUT}?${params.toString()}`;
} }
/**
* Checkout fallback keyed by invoice id — matches `GET /payments/checkout`,
* which reads `invoiceId` (billing is invoice-centric; there is no
* `bookingId` param on that route).
*/
function buildCheckoutUrlForInvoice(payload: {
invoiceId: string;
method: PaymentMethod;
platform?: PaymentPlatform;
}): string {
const base = API_BASE_URL.replace(/\/$/, "");
const params = new URLSearchParams({
invoiceId: payload.invoiceId,
method: payload.method,
platform: payload.platform ?? "web",
});
return `${base}${P.CHECKOUT}?${params.toString()}`;
}
export const paymentsService = { export const paymentsService = {
initiate: async ( initiate: async (
payload: InitiatePaymentPayload, payload: InitiatePaymentPayload,
@@ -84,4 +103,5 @@ export const paymentsService = {
}, },
checkoutUrl: buildCheckoutUrl, checkoutUrl: buildCheckoutUrl,
checkoutUrlForInvoice: buildCheckoutUrlForInvoice,
}; };

View File

@@ -1,15 +1,15 @@
'use client'; "use client";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { useTheme } from '@/lib/theme-store'; import { useTheme } from "@/lib/theme-store";
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from "@/lib/auth-store";
function ThemeProvider({ children }: { children: React.ReactNode }) { function ThemeProvider({ children }: { children: React.ReactNode }) {
const { isDark, setTheme } = useTheme(); const { isDark, setTheme } = useTheme();
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle('dark', isDark); document.documentElement.classList.toggle("dark", isDark);
}, [isDark]); }, [isDark]);
return <>{children}</>; return <>{children}</>;
@@ -26,14 +26,16 @@ function AuthProvider({ children }: { children: React.ReactNode }) {
} }
export default function Providers({ children }: { children: React.ReactNode }) { export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({ const [queryClient] = useState(
defaultOptions: { () =>
queries: { new QueryClient({
staleTime: 60 * 1000, defaultOptions: {
refetchOnWindowFocus: false, queries: {
}, staleTime: 60 * 1000,
}, },
})); },
}),
);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>