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

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

View File

@@ -116,12 +116,14 @@ describe("BillingService.generateInvoice", () => {
});
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 = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
@@ -143,7 +145,22 @@ describe("BillingService.markInvoiceAsPaid", () => {
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ 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(
"booking.invoice.paid",
@@ -190,8 +207,14 @@ describe("BillingService.recordPayment", () => {
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const dataSource = {
manager: mg,
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)),
};
const service = new BillingService(
{ manager: mg } as never,
dataSource as never,
{} as never,
{} as never,
events as never,
@@ -257,6 +280,14 @@ describe("BillingService.recordPayment", () => {
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 () => {
const { service, mg } = serviceFor(
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
@@ -265,74 +296,3 @@ describe("BillingService.recordPayment", () => {
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. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
@@ -285,20 +284,15 @@ export class BillingService {
/**
* Initiate gateway payment for one of the customer's own invoices. Verifies
* ownership, then charges whichever open invoice the source currently has
* (see {@link payInvoice}).
* ownership, then charges the invoice directly by ID (see {@link payInvoice}).
*/
async payInvoiceForUser(
id: string,
userId: string,
opts: PayInvoiceOptions = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findByIdForUser(id, userId);
return this.payInvoice(
invoice.source as Freight.InvoiceSource,
invoice.sourceId,
opts,
);
await this.findByIdForUser(id, userId);
return this.payInvoice(id, opts);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
@@ -423,23 +417,89 @@ export class BillingService {
// ── State transitions ────────────────────────────────────────────────────────
/**
* Mark an invoice paid and 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.
* Run `fn` inside a transaction and only emit its returned domain event
* after commit. When the caller passes their own `manager`, they own commit
* timing — `fn`'s event fires inline as soon as it resolves (the outer
* 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(
invoiceId: string,
paymentId: string | null = null,
manager?: EntityManager,
settlement: { providerTxnId?: string; paidAt?: Date } = {},
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Paid,
"paid",
{ paymentId: paymentId ?? undefined },
manager,
);
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
lock: { mode: "pessimistic_write" },
});
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
* {@link markInvoiceAsPaid}.
*
* Throws when the invoice is missing, cancelled, refunded, already fully paid,
* or when `amount` is not positive. Pass `manager` to enlist in a caller's
* transaction.
* Throws when the invoice is missing, cancelled, refunded, already fully
* paid, `amount` is not positive, or `amount` exceeds the outstanding
* 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(
invoiceId: string,
@@ -466,62 +528,71 @@ export class BillingService {
);
}
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Cannot pay a cancelled invoice.");
}
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.");
}
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Cannot pay a cancelled invoice.");
}
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 { paidAmount, balanceAmount, fullyPaid } = applySettlement(
invoice.totalAmount,
invoice.paidAmount,
input.amount,
);
const status = fullyPaid
? Freight.InvoiceStatus.Paid
: Freight.InvoiceStatus.PartiallyPaid;
const at = input.paidAt ?? new Date();
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
invoice.totalAmount,
invoice.paidAmount,
input.amount,
);
const status = fullyPaid
? Freight.InvoiceStatus.Paid
: Freight.InvoiceStatus.PartiallyPaid;
const entry: InvoicePayment = {
amount: round2(input.amount),
method: input.method ?? null,
reference: input.reference ?? null,
paidAt: at.toISOString(),
metadata: input.metadata ?? null,
};
const payments = [...(invoice.payments ?? []), entry];
const entry: InvoicePayment = {
amount: round2(input.amount),
method: input.method ?? null,
reference: input.reference ?? null,
paidAt: at.toISOString(),
metadata: input.metadata ?? null,
};
const payments = [...(invoice.payments ?? []), entry];
await mg.update(Invoice, { id: invoice.id }, {
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
} as never);
const patch = {
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
};
await mg.update(Invoice, { id: invoice.id }, patch as never);
const updated = {
...invoice,
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
} as Invoice;
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
return updated;
const updated = { ...invoice, ...patch } as Invoice;
return {
result: updated,
emit: fullyPaid
? () => this.emitInvoiceEvent("paid", updated)
: undefined,
};
});
}
/**
* 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(
invoiceId: string,
@@ -533,12 +604,20 @@ export class BillingService {
"refunded",
{},
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`.
* No-op when already cancelled.
* No-op when already cancelled. Throws when the invoice has payments
* recorded against it (refund it instead).
*/
async cancelInvoice(
invoiceId: string,
@@ -550,16 +629,23 @@ export class BillingService {
"cancelled",
{},
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
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
* in the target status. Throws when the invoice does not exist.
*
* Note: the event fires in-process synchronously. When a `manager` from an
* outer transaction is passed, listeners run before that transaction commits.
* `${source}.invoice.<event>`. No-op (returns the invoice, skipping `guard`)
* 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
* caller's transaction; otherwise locks the row for update and emits only
* after commit (see {@link runTransition}).
*/
private async transition(
invoiceId: string,
@@ -567,17 +653,27 @@ export class BillingService {
event: string,
extra: { paymentId?: string },
manager?: EntityManager,
guard?: (invoice: Invoice) => void,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === status) return invoice;
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
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;
this.emitInvoiceEvent(event, updated);
return updated;
const updated = { ...invoice, ...extra, status } as Invoice;
return {
result: updated,
emit: () => this.emitInvoiceEvent(event, updated),
};
});
}
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
@@ -601,16 +697,17 @@ export class BillingService {
// ── Payment reconciliation (by source) ───────────────────────────────────────
/**
* The invoice a gateway payment should settle for a source record, or null if
* none. This is the billing document of record for "what is owed" — callers
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
* recomputing from the source's own total, so discounts/penalties/adjustments
* carried on the invoice are honored.
* The invoice a source record already has open, or null if it needs a new
* one. This is the idempotency check every `ensureInvoiceFor*` (booking,
* first-mile, last-mile) runs before generating — it must see DRAFT
* invoices too, not just issued ones, otherwise a source that already has
* 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.
* 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,
* non-cancelled) invoice.
* invoice is currently open. Returns the most recent matching draft-or-open
* (unpaid, non-cancelled) invoice.
*/
findPayable(
source: Freight.InvoiceSource,
@@ -621,7 +718,7 @@ export class BillingService {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
@@ -629,56 +726,24 @@ export class BillingService {
}
/**
* Settle a source's currently-open invoice as paid and link the gateway
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
* settlement. No-op (returns null) when the source has no open 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.
* 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
* invoice is currently open. Returns the most recent matching open (unpaid,
* non-cancelled) invoice.
*/
async settlePayable(
findInvoice(
source: Freight.InvoiceSource,
sourceId: string,
paymentId: string | null,
manager?: EntityManager,
type?: string,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
return this.dataSource.getRepository(Invoice).findOne({
where: {
source,
sourceId,
...(type ? { type } : {}),
},
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(
source: Freight.InvoiceSource,
sourceId: string,
type?: string,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
@@ -722,17 +793,29 @@ export class BillingService {
source: Freight.InvoiceSource,
sourceId: string,
dueAt: Date,
type?: string,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
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" },
});
if (!invoice) return;
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(
invoiceId: string,
status: Freight.InvoiceStatus,
@@ -740,29 +823,33 @@ export class BillingService {
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) },
});
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) ───────────────────
/**
* Charge a source's open invoice through the payment gateway. Billing is the
* single place that turns "what is owed" (the invoice) into a payment intent —
* the domain never talks to the payment service directly. Resolves the open
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
* the invoice (the settlement correlation key), and returns the client action.
* Charge an invoice through the payment gateway. Billing is the single place
* that turns "what is owed" (the invoice) into a payment intent — the domain
* never talks to the payment service directly. Resolves the invoice by ID,
* opens an intent for `invoice.balanceAmount` (so partial payments are honored),
* 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 —
* 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(
source: Freight.InvoiceSource,
sourceId: string,
invoiceId: string,
opts: {
method?: string;
platform?: "web" | "mobile";
@@ -771,15 +858,17 @@ export class BillingService {
failureUrl?: string;
} = {},
): 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) {
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({
referenceId: sourceId,
referenceId: invoice.sourceId,
source: invoice.source,
// Freight payments settle under the generic SHIPMENT reference — how the
// 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.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)),
amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
@@ -823,8 +912,8 @@ export class BillingService {
*/
async settleByPaymentId(
paymentId: string,
_providerTxnId?: string,
_paidAt?: Date,
providerTxnId?: string,
paidAt?: Date,
): Promise<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId, status: In(OPEN_STATUSES) },
@@ -832,6 +921,9 @@ export class BillingService {
});
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`. */
export interface SqlRunner {
query(sql: string, params?: unknown[]): Promise<Array<{ seq: number | string }>>;
query(sql: string, params?: unknown[]): Promise<unknown>;
}
export interface InvoiceNumberOptions {
@@ -34,11 +34,18 @@ export async function nextDailyInvoiceNumber(
const prefix = `${opts.code}-${ymd}-`;
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
FROM ${opts.table} WHERE ${column} LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
)) as Array<{ seq: number | string }>;
const next = Number(rows[0]?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
}

View File

@@ -0,0 +1,177 @@
import {
Body,
Controller,
Get,
HttpStatus,
Post,
Query,
Res,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiQuery,
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { BillingService } from "./billing.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
} from "../payment/payments.dto";
/**
* Central payment entrypoints. Domain-agnostic — the caller supplies an
* invoice ID and the billing service resolves the amount and drives the
* gateway. The domain never talks to the payment service directly.
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
*/
@ApiTags("Payment")
@Controller("payments")
export class PaymentController {
constructor(private readonly billing: BillingService) { }
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for an invoice",
description: "Charges the invoice through the payment gateway.",
})
@ApiOkResponse({ type: InitiateResponseDto })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.billing.payInvoice(dto.invoiceId, {
method: dto.method,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
@Get("checkout")
@Public()
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
})
@ApiQuery({ name: "invoiceId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query("invoiceId") invoiceId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!invoiceId) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing required query parameter: invoiceId"));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
}
try {
const result = await this.billing.payInvoice(
invoiceId,
{ method, platform },
);
const url =
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
if (url) {
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
}
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildStatusHtml(result.status, result.intentId));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred";
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
}
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
}