mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
Merge pull request #397 from Tria-plc/feight/fix/ui-ux-issues
Feight/fix/UI ux issues
This commit is contained in:
@@ -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",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user