mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
feat: rewired up the billing and payment with the booking
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BillingController } from "./billing.controller";
|
||||
@@ -7,9 +7,13 @@ 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 { PaymentModule } from "../payment/payment.module";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Invoice, InvoiceLine])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
forwardRef(() => PaymentModule),
|
||||
],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
exports: [BillingService],
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
);
|
||||
});
|
||||
|
||||
@@ -130,6 +131,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -165,6 +167,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -192,6 +195,7 @@ describe("BillingService.settlePayable", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
@@ -224,6 +228,7 @@ describe("BillingService.settlePayable", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
|
||||
@@ -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