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

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

View File

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

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { 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

@@ -16,9 +16,8 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { Freight } from "@edr/types";
import { BillingService } from "../billing/billing.service";
import { BillingService } from "./billing.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
@@ -27,25 +26,24 @@ import {
} from "../payment/payments.dto";
/**
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
* booking it maps the request to {@link Freight.InvoiceSource.Booking} and hands
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
* 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 BookingPaymentController {
export class PaymentController {
constructor(private readonly billing: BillingService) { }
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for a freight booking",
description: "Charges the booking's open invoice through the payment gateway.",
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(Freight.InvoiceSource.Booking, dto.bookingId, {
return this.billing.payInvoice(dto.invoiceId, {
method: dto.method,
platform: dto.platform,
payerAccount: dto.payerAccount,
@@ -59,23 +57,23 @@ export class BookingPaymentController {
@ApiOperation({
summary: "Browser checkout redirect",
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: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query("bookingId") bookingId: string,
@Query("invoiceId") invoiceId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
if (!invoiceId) {
return res
.status(HttpStatus.BAD_REQUEST)
.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)) {
return res
@@ -86,8 +84,7 @@ export class BookingPaymentController {
try {
const result = await this.billing.payInvoice(
Freight.InvoiceSource.Booking,
bookingId,
invoiceId,
{ method, platform },
);
const url =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ export class FirstMileInvoiceService {
}
// Fetch the booking to get the companyId and companyProfileId
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.booking?.companyId) {
this.logger.warn(

View File

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

View File

@@ -1069,6 +1069,7 @@ export class BookingBatchService implements OnModuleInit {
Freight.InvoiceSource.Booking,
booking.id,
deadline,
"PREPAID",
);
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
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// 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);
}
@@ -1167,6 +1168,7 @@ export class BookingBatchService implements OnModuleInit {
await this.billing.expirePayable(
Freight.InvoiceSource.Booking,
victim.id,
"PREPAID",
manager,
);
});

View File

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

View File

@@ -1,5 +1,5 @@
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 { useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -8,7 +8,9 @@ import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -51,17 +53,41 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
: "/contracts/new";
const onRebook = () => navigate(rebookTo);
// POST /payments/initiate 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.
// Billing is invoice-centric — resolve the booking's currently payable
// invoice (same query/key BookingPaymentPanel uses, so this shares its
// 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({
mutationFn: (method: PaymentMethod) =>
api.payments.initiate.call({ bookingId: booking.id, method }),
mutationFn: (method: PaymentMethod) => {
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) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId: booking.id, method });
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = redirectUrl;
},
});

View File

@@ -17,7 +17,7 @@ export type PaymentMethod =
export type PaymentPlatform = "web" | "mobile";
export interface InitiatePaymentPayload {
bookingId: string;
invoiceId: string;
method: PaymentMethod;
platform?: PaymentPlatform;
payerAccount?: string;
@@ -67,6 +67,25 @@ function buildCheckoutUrl(payload: {
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 = {
initiate: async (
payload: InitiatePaymentPayload,
@@ -84,4 +103,5 @@ export const paymentsService = {
},
checkoutUrl: buildCheckoutUrl,
checkoutUrlForInvoice: buildCheckoutUrlForInvoice,
};

View File

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