mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
feat: rewired up the billing and payment with the booking
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
@@ -7,6 +7,8 @@ import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
|
||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||
const DEFAULT_DUE_DAYS = 14;
|
||||
@@ -80,6 +82,8 @@ export class BillingService {
|
||||
private readonly invoices: InvoiceRepository,
|
||||
private readonly invoiceLines: InvoiceLineRepository,
|
||||
private readonly events: EventEmitter2,
|
||||
@Inject(forwardRef(() => PaymentService))
|
||||
private readonly payment: PaymentService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -304,9 +308,9 @@ export class BillingService {
|
||||
/**
|
||||
* 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. `payment.service.initiatePayment`) should charge `invoice.totalAmount`
|
||||
* against it rather than recomputing from the source's own total, so
|
||||
* discounts/penalties/adjustments carried on the invoice are honored.
|
||||
* (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.
|
||||
*
|
||||
* 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
|
||||
@@ -335,10 +339,13 @@ export class BillingService {
|
||||
* 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: the payment process 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` (e.g. from
|
||||
* `payment.service.finalizePaymentSuccess`) to enlist in its DB transaction.
|
||||
* 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(
|
||||
source: Freight.InvoiceSource,
|
||||
@@ -378,4 +385,83 @@ export class BillingService {
|
||||
|
||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
async payInvoice(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
} = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findPayable(source, sourceId);
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: sourceId,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
payerAccount: opts.payerAccount,
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
|
||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||
await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
if (result.immediateSuccess) {
|
||||
await this.settleByPaymentId(
|
||||
result.intentId,
|
||||
result.providerTxnId,
|
||||
result.paidAt,
|
||||
);
|
||||
}
|
||||
|
||||
return result.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the open invoice linked to a gateway intent id, if any. Called by the
|
||||
* payment service when an intent succeeds: finds the invoice linked by
|
||||
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
|
||||
* to advance on. Idempotent — no-op when no open invoice is linked (already
|
||||
* settled, or settled inline by {@link payInvoice}).
|
||||
*/
|
||||
async settleByPaymentId(
|
||||
paymentId: string,
|
||||
_providerTxnId?: string,
|
||||
_paidAt?: Date,
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { paymentId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user