From 4be4286fbf193b9b58e3d3e0717a92588040d9fe Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 09:25:46 +0000 Subject: [PATCH] feat: rewired up the billing and payment with the booking --- .../src/modules/billing/billing.controller.ts | 2 +- .../src/modules/billing/billing.module.ts | 8 +- .../modules/billing/billing.service.spec.ts | 5 + .../src/modules/billing/billing.service.ts | 102 ++++- .../bookings/booking-invoice.service.ts | 104 ++++- .../bookings/booking-payment.controller.ts | 180 +++++++++ .../bookings/booking-payment.service.ts | 34 +- .../src/modules/bookings/bookings.module.ts | 9 +- .../src/modules/payment/payment.controller.ts | 143 +------ .../src/modules/payment/payment.module.ts | 10 +- .../src/modules/payment/payment.service.ts | 358 +++++++++--------- packages/types/src/freight/index.ts | 9 +- 12 files changed, 584 insertions(+), 380 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index e7ac41704..e954b7e1b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -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"; diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 1dd745088..1ed135bbb 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 72ddb0b02..907aeb76d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index b1e3d25bf..448ad6685 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 { + 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 { + 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); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index daa3327dd..4eacedc13 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -1,6 +1,7 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; +import { DataSource } from 'typeorm'; import { BillingService, @@ -9,7 +10,11 @@ import { InvoiceLineInput, } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { FirstMileService } from '../first-mile/first-mile.service'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { PriceLineItemDto } from './dto/generate-price-response.dto'; +import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ @@ -22,18 +27,32 @@ interface StoredPricingBreakdown { /** Round to 2 decimals, avoiding binary float drift. */ const round2 = (n: number): number => Math.round(n * 100) / 100; +/** Setting code holding the general-contract ordering window (months). */ +const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period'; +const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; + /** * Owns the booking ⇄ invoice mapping — the one place that knows how a booking - * turns into invoices and which type to use. Bookings are the billable business - * entity, so they generate their own invoices directly via {@link BillingService} - * (billing stays source-agnostic). All booking-specific type branching lives here, - * at the two points it belongs: invoice creation and settlement (the paid handler). + * turns into invoices, which type to use, and how it advances when paid. Bookings + * are the billable business entity, so they generate their own invoices directly + * via {@link BillingService} (billing stays source-agnostic). All booking-specific + * type branching lives here, at the two points it belongs: invoice creation and + * settlement (the paid handler). */ @Injectable() export class BookingInvoiceService { private readonly logger = new Logger(BookingInvoiceService.name); - constructor(private readonly billing: BillingService) {} + constructor( + private readonly billing: BillingService, + private readonly bookingsRepository: BookingsRepository, + private readonly dataSource: DataSource, + private readonly dropdownSettings: DropdownSettingsService, + @Inject(forwardRef(() => FirstMileService)) + private readonly firstMile: FirstMileService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatch: BookingBatchService, + ) {} /** * Ensure the booking has its invoice, generating one from the snapshotted @@ -71,15 +90,14 @@ export class BookingInvoiceService { /** * React to a booking invoice being paid — the settlement branch point. Per-type - * reactions live here (not in the payment process): e.g. a paid up-front invoice - * may later generate a final invoice. Only PREPAID exists today. + * reactions live here (not in the payment process): each invoice type advances + * the booking its own way. Only PREPAID exists today. */ @OnEvent('booking.invoice.paid') - onBookingInvoicePaid(payload: InvoiceEventPayload): void { + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { switch (payload.type) { case Freight.InvoiceType.Prepaid: - // Full prepaid settlement — booking advancement is handled by the - // payment flow today. Final-invoice issuance would hook in here. + await this.advanceBookingOnPayment(payload.sourceId); break; default: this.logger.warn( @@ -88,6 +106,70 @@ export class BookingInvoiceService { } } + /** + * Advance a booking once its prepaid invoice settles. This is the domain + * side-effect of payment, relocated out of the payment service: a general + * contract becomes ACTIVE and opens its ordering window (it does not enter the + * train queue — nothing has been ordered yet); a normal booking becomes PAID + * and is allocated into its batch. Idempotent — no-op when already PAID. + */ + private async advanceBookingOnPayment(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) { + this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`); + return; + } + if (booking.paymentStatus === 'PAID') return; + + const paidAt = new Date(); + const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT'; + + let contractExpiresAt: Date | null = null; + if (isGeneralContract) { + const months = await this.contractPeriodMonths(); + contractExpiresAt = new Date(paidAt); + contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months); + } + + await this.dataSource.transaction(async (mg) => { + await mg.update( + Booking, + { id: bookingId }, + isGeneralContract + ? { paymentStatus: 'PAID', status: 'CONTRACT_ACTIVE', expiresAt: contractExpiresAt } + : { paymentStatus: 'PAID', status: 'PAID' }, + ); + await this.firstMile.acceptBooking(bookingId); + }); + + if (isGeneralContract) { + this.logger.log( + `General contract ${booking.reference} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, + ); + return; + } + + try { + await this.bookingBatch.ensurePaidBookingAllocated(bookingId); + } catch (err) { + this.logger.error( + `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** Configured general-contract ordering window in months (defaults to 3). */ + private async contractPeriodMonths(): Promise { + try { + const setting = await this.dropdownSettings.getByCode(CONTRACT_PERIOD_SETTING_CODE); + const months = Number(setting.children?.[0]?.value); + if (Number.isFinite(months) && months > 0) return months; + } catch { + // Setting not seeded — fall back to the default. + } + return DEFAULT_CONTRACT_PERIOD_MONTHS; + } + /** Map a booking's pricing snapshot into a generic invoice request. */ private buildInput(booking: Booking): GenerateInvoiceInput | null { const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts new file mode 100644 index 000000000..ae01ebc36 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts @@ -0,0 +1,180 @@ +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 { Freight } from "@edr/types"; + +import { BillingService } from "../billing/billing.service"; +import { + InitiatePaymentDto, + InitiateResponseDto, + PaymentMethodTypeEnum, + PaymentPlatformDto, +} 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. + * Routes are unchanged (`/payments/*`) so the portal is unaffected. + */ +@ApiTags("Payment") +@Controller("payments") +export class BookingPaymentController { + 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.", + }) + @ApiOkResponse({ type: InitiateResponseDto }) + initiate(@Body() dto: InitiatePaymentDto): Promise { + return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { + 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 booking's 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: "method", enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiProduces("text/html") + async checkout( + @Query("bookingId") bookingId: string, + @Query("method") method: PaymentMethodTypeEnum, + @Query("platform") platform: PaymentPlatformDto = "web", + @Res() res: Response, + ) { + if (!bookingId) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing required query parameter: bookingId")); + } + 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( + Freight.InvoiceSource.Booking, + bookingId, + { 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, """); + return ` + + + + + Redirecting to payment… + + + +
+
+

Redirecting to payment provider…

+

Click here if you are not redirected

+
+ + +`; + } + + private buildStatusHtml(status: string, intentId: string): string { + return ` + + + + Payment status + + + +
+
${status}
+ Intent: ${intentId} +
+ +`; + } + + private buildErrorHtml(message: string): string { + return ` + + + + Payment error + + + +
+
Payment could not be initiated
+

${message}

+
+ +`; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts index 21473eeb8..1fbe34e1f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -1,49 +1,37 @@ 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 { PaymentService } from '../payment/payment.service'; -import { PaymentStatus } from '../payment/entities/payment.entity'; +import { BillingService } from '../billing/billing.service'; import { PaymentMethodTypeEnum } from '../payment/payments.dto'; export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } -const NON_TERMINAL_STATUSES: PaymentStatus[] = [ - "action-required", - "processing", - "success", -]; - @Injectable() export class BookingPaymentService { constructor( private readonly bookingsRepository: BookingsRepository, - private readonly paymentService: PaymentService, + 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 existing = await this.paymentService.findBookingById(bookingId); - if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { - if (existing.clientAction) { - const action = existing.clientAction as { type?: string; url?: string }; - if (action.type === "REDIRECT" && action.url) { - return { redirectUrl: action.url }; - } - } - } - - const resp = await this.paymentService.initiatePayment({ - bookingId, + const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, { method: PaymentMethodTypeEnum.TELEBIRR, - platform: "web", + platform: 'web', }); const action = resp.clientAction as { type?: string; url?: string } | undefined; return { - redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "", + redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '', }; } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 8318a4e7d..2e968309f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -11,8 +11,11 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { BillingModule } from '../billing/billing.module'; +import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.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'; @@ -35,7 +38,6 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing import { ContractRendererService } from '../../contracts/contract-renderer.service'; import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; -import { PaymentModule } from '../payment/payment.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; @Module({ @@ -50,8 +52,9 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingReviewNote, BookingContractSignature, ]), - PaymentModule, BillingModule, + DropdownSettingsModule, + forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), FilesModule, MinioModule, @@ -66,7 +69,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu config.get('app.cbeExchange') ?? {}, }), ], - controllers: [BookingsController, PayController], + controllers: [BookingsController, PayController, BookingPaymentController], providers: [ BookingsService, BookingsRepository, diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index f1f34c3b1..b1b269665 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -1,13 +1,13 @@ import { - Body, Controller, Get, HttpStatus, Param, ParseUUIDPipe, - Post, Query, Res, + Body, + Post, } from "@nestjs/common"; import { ApiTags, @@ -20,14 +20,7 @@ import { Response } from "express"; import { Public } from "@edr/api-common"; import { BookingView, FreightAdmin } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; -import { - InitiatePaymentDto, - InitiateResponseDto, - IntentStatusDto, - PaymentMethodTypeEnum, - PaymentPlatformDto, - RefundDto, -} from "./payments.dto"; +import { IntentStatusDto, RefundDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") @@ -73,16 +66,6 @@ export class PaymentController { }); } - @Post("initiate") - @ApiOperation({ - summary: "Initiate payment for a freight booking", - description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`, - }) - @ApiOkResponse({ type: InitiateResponseDto }) - initiatePayment(@Body() dto: InitiatePaymentDto) { - return this.paymentService.initiatePayment(dto); - } - @Get("intents/:bookingId") @ApiOperation({ summary: "Get payment intent status for a booking" }) @ApiOkResponse({ type: IntentStatusDto }) @@ -97,54 +80,6 @@ export class PaymentController { return this.paymentService.refund(dto); } - @Get("checkout") - @Public() - @ApiOperation({ - summary: "Browser checkout redirect", - description: - "Initiates payment 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: "method", enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) - @ApiProduces("text/html") - async checkout( - @Query("bookingId") bookingId: string, - @Query("method") method: PaymentMethodTypeEnum, - @Query("platform") platform: PaymentPlatformDto = "web", - @Res() res: Response, - ) { - if (!bookingId) { - return res - .status(HttpStatus.BAD_REQUEST) - .type("html") - .send(this.buildErrorHtml("Missing required query parameter: bookingId")); - } - 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.paymentService.initiatePayment({ bookingId, 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)); - } - } - @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) @@ -153,76 +88,4 @@ export class PaymentController { const html = await this.paymentService.genReceiptHtml(orderId); return res.status(HttpStatus.OK).type("html").send(html); } - - private buildRedirectHtml(url: string): string { - const escaped = url.replace(/\"/g, """); - return ` - - - - - Redirecting to payment… - - - -
-
-

Redirecting to payment provider…

-

Click here if you are not redirected

-
- - -`; - } - - private buildStatusHtml(status: string, intentId: string): string { - return ` - - - - Payment status - - - -
-
${status}
- Intent: ${intentId} -
- -`; - } - - private buildErrorHtml(message: string): string { - return ` - - - - Payment error - - - -
-
Payment could not be initiated
-

${message}

-
- -`; - } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 18e32330d..330521218 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -1,4 +1,4 @@ -import { DynamicModule, Module, forwardRef } from "@nestjs/common"; +import { DynamicModule, forwardRef, Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq"; @@ -13,9 +13,6 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; -import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; -import { FirstMileModule } from "../first-mile/first-mile.module"; -import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentEntity } from "./entities/payment.entity"; @@ -59,10 +56,7 @@ function rabbitMQImport(): DynamicModule[] { imports: [ HttpModule.register({ timeout: 10_000 }), ConfigModule, - DropdownSettingsModule, - BillingModule, - forwardRef(() => FirstMileModule), - forwardRef(() => TrainSchedulingModule), + forwardRef(() => BillingModule), TypeOrmModule.forFeature([ PaymentEntity, PaymentWebhookEventEntity, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 5fb77f404..7beb6e8c6 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -11,6 +11,7 @@ import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; +import { BillingService } from "../billing/billing.service"; import * as fs from "fs"; import * as path from "path"; @@ -22,26 +23,46 @@ import { ProviderPaymentStatus, } from "@edr/payment-providers"; import { - Freight, PaymentService as PaymentServiceEnum, PaymentReferenceType, PaymentIntentSnapshot, ProviderMethod, } from "@edr/types"; -import { BillingService } from "../billing/billing.service"; import { - InitiatePaymentDto, InitiateResponseDto, IntentStatusDto, + PaymentPlatformDto, RefundDto, } from "./payments.dto"; -import { BookingBatchService } from "../train-scheduling/booking-batch.service"; -import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service"; -import { FirstMileService } from "../first-mile/first-mile.service"; -/** Setting code holding the global ordering window (months) for general contracts. */ -const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period"; -const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; +/** Everything the gateway needs to open an intent. Amount/currency are supplied by + * the caller (billing) — this service never derives them from a domain record. */ +export interface InitiateIntentInput { + /** Opaque domain reference (booking id, …). */ + referenceId: string; + /** Human-readable order ref shown on provider pages. */ + orderRef: string; + /** Authoritative amount in minor units, computed by the caller. */ + amountMinor: number; + currency: string; + /** Stored on the intent projection for receipts/dashboards. */ + reason?: string; + /** Provider/method selector. */ + method: ProviderMethod | string; + platform?: PaymentPlatformDto; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; +} + +export interface InitiateIntentResult { + intentId: string; + response: InitiateResponseDto; + /** True when the provider settled the charge synchronously during initiate. */ + immediateSuccess: boolean; + providerTxnId?: string; + paidAt?: Date; +} const STATUS_MAP: Record = { "action-required": ProviderPaymentStatus.REQUIRES_ACTION, @@ -52,6 +73,23 @@ const STATUS_MAP: Record = { "refunded": ProviderPaymentStatus.CANCELLED, }; +const PROVIDER_TO_METHOD: Record = { + TELEBIRR: "telebirr", + CBE_BIRR: "cbe-birr", + EBIRR: "ebirr", + WAAFI: "waafi", + CARD: "card", + DMONEY: "dmoney", + CAC_BANK: "cac-bank", +}; + +/** + * Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and + * NOTHING domain-specific: it never loads a booking, computes an amount, or + * advances a domain record. On settlement it notifies billing directly + * ({@link BillingService.settleByPaymentId}); billing (and through it, the domain) + * reacts. The billing↔payment pair is a deliberate forwardRef cycle. + */ @Injectable() export class PaymentService { private readonly logger = new Logger(PaymentService.name); @@ -60,27 +98,10 @@ export class PaymentService { private readonly datasource: DataSource, private readonly paymentRepo: PaymentRepository, private readonly paymentClient: PaymentClientService, - @Inject(forwardRef(() => BookingBatchService)) - private readonly bookingBatchService: BookingBatchService, - private readonly dropdownSettings: DropdownSettingsService, - private readonly firstMileService: FirstMileService, + @Inject(forwardRef(() => BillingService)) private readonly billing: BillingService, ) { } - /** Configured general-contract ordering window in months (defaults to 3). */ - private async contractPeriodMonths(): Promise { - try { - const setting = await this.dropdownSettings.getByCode( - CONTRACT_PERIOD_SETTING_CODE, - ); - const months = Number(setting.children?.[0]?.value); - if (Number.isFinite(months) && months > 0) return months; - } catch { - // Setting not seeded — fall back to the default. - } - return DEFAULT_CONTRACT_PERIOD_MONTHS; - } - async getAll(filters: { search?: string; status?: string; @@ -146,7 +167,6 @@ export class PaymentService { total += row.count; } - // Sum of successfully collected amounts. const paidAgg = await this.paymentRepo .createQueryBuilder("payment") .select("COALESCE(SUM(payment.amount), 0)", "sum") @@ -164,74 +184,75 @@ export class PaymentService { }; } - async initiatePayment(dto: InitiatePaymentDto): Promise { - const booking = await this.datasource - .getRepository(Booking) - .findOneBy({ id: dto.bookingId }); - if (!booking) throw new NotFoundException("Booking not found"); - - // Charge the invoice (the billing document of record) so discounts, - // penalties and staff adjustments carried on it are honored. Fall back to - // the booking total only when no invoice has been generated yet. - const invoice = await this.billing.findPayable( - Freight.InvoiceSource.Booking, - booking.id, - ); - const amountMinor = Math.round( - Number(invoice?.totalAmount ?? booking.totalAmount), - ); - + /** + * Open a gateway intent for a caller-supplied amount/reference and project it + * locally. Returns the intent id (so billing can correlate the invoice) plus + * the client action. When the provider settles synchronously, the intent is + * marked paid WITHOUT emitting — the caller (billing) settles inline after it + * has stored the intent id, avoiding a settle-before-correlation race. + */ + async initiate(input: InitiateIntentInput): Promise { const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, referenceType: PaymentReferenceType.SHIPMENT, - referenceId: booking.id, - orderRef: booking.reference, - amountMinor, - currency: booking.paymentCurrency, - provider: dto.method as unknown as ProviderMethod, - platform: dto.platform, - payerAccount: dto.payerAccount, - returnUrl:'https://edrfreight.triaplc.com/payment/success', - failureUrl: 'https://edrfreight.triaplc.com/payment/failure', + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", }); - const intent = await this.syncIntentProjection(booking.id, booking, snapshot); + const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; - if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { - await this.finalizePaymentSuccess({ - intentId: intent.id, - bookingId: booking.id, + const intent = await this.upsertIntent(input, snapshot); + + if (immediateSuccess) { + // Settle the projection but DO NOT notify billing — billing settles + // inline once it has stored intentId on the invoice (see payInvoice), + // avoiding a settle-before-correlation race. + await this.markIntentSucceeded(intent.id, { providerTxnId: snapshot.providerTxnId, - paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + paidAt, + notify: false, }); } - return this.formatIntentResponse(intent); + return { + intentId: intent.id, + // `intent` still reflects the projection status ("processing" on immediate + // success — settlement is applied by the caller, not shown synchronously). + response: this.formatIntentResponse(intent), + immediateSuccess, + providerTxnId: snapshot.providerTxnId, + paidAt, + }; } - private async syncIntentProjection( - bookingId: string, - booking: Booking, + /** Create or update the local intent projection from a provider snapshot. */ + private async upsertIntent( + input: InitiateIntentInput, snapshot: PaymentIntentSnapshot, ): Promise { - const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" }); + const existing = await this.paymentRepo.findOneBy({ + refId: input.referenceId, + type: "booking", + }); - const PROVIDER_TO_METHOD: Record = { - TELEBIRR: "telebirr", - CBE_BIRR: "cbe-birr", - EBIRR: "ebirr", - WAAFI: "waafi", - CARD: "card", - DMONEY: "dmoney", - CAC_BANK: "cac-bank", - }; const method: PaymentEntity["method"] = PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; - const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED - ? "processing" - : this.toLocalStatus(snapshot.status); + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? "processing" + : this.toLocalStatus(snapshot.status); - const clientAction = (snapshot.clientAction ?? undefined) as Record | undefined; + const clientAction = (snapshot.clientAction ?? undefined) as + | Record + | undefined; const data = { status, method, @@ -248,30 +269,36 @@ export class PaymentService { } return this.paymentRepo.create({ - refId: bookingId, + refId: input.referenceId, type: "booking", - amount: booking.totalAmount, - currency: booking.paymentCurrency, - reason: `Payment for booking ${booking.reference}`, + amount: input.amountMinor, + currency: input.currency as PaymentEntity["currency"], + reason: input.reason ?? `Payment for ${input.orderRef}`, rawInitiation: snapshot as unknown as Record, clientAction: clientAction ?? {}, ...data, } as any); } - async getIntentByBookingId(bookingId: string): Promise { - const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" }); + /** + * Reconcile an intent's status with the gateway by reference. Read-only on the + * domain side: it syncs the local projection and, when the provider reports a + * newly-observed success, notifies billing to settle. `referenceId` is opaque + * (the booking id, but this service does not load it). + */ + async getIntentByBookingId(referenceId: string): Promise { + const local = await this.paymentRepo.findOneBy({ refId: referenceId, type: "booking" }); let snapshot: PaymentIntentSnapshot | null = null; try { snapshot = await this.paymentClient.getIntentByReference( PaymentReferenceType.SHIPMENT, - bookingId, + referenceId, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.warn( - `payment service lookup failed for booking ${bookingId}: ${message}; using local intent`, + `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, ); } @@ -279,111 +306,54 @@ export class PaymentService { if (!local) throw new NotFoundException("PaymentIntent not found"); return this.formatIntentStatus(local); } + if (!local) throw new NotFoundException("PaymentIntent not found"); - const booking = await this.datasource - .getRepository(Booking) - .findOneBy({ id: bookingId }); + // Sync local projection with provider-reported status. + const becameSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success"; - if (!booking) throw new NotFoundException("Booking not found"); - - const intent = await this.syncIntentProjection(bookingId, booking, snapshot); - - if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { - await this.finalizePaymentSuccess({ - intentId: intent.id, - bookingId: booking.id, + if (becameSuccess) { + await this.markIntentSucceeded(local.id, { providerTxnId: snapshot.providerTxnId, paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + notify: true, }); + } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(snapshot.status), + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }, + ); } - const refreshed = await this.paymentRepo.findOneBy({ id: intent.id }); - return this.formatIntentStatus(refreshed ?? intent); + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); } - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - await this.datasource.transaction(async (mg) => { - await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); - await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - - async finalizePaymentSuccess(input: { - intentId: string; - bookingId: string; - providerTxnId?: string; - paidAt?: Date; - }): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + /** + * Mark a gateway intent paid and (by default) notify billing to settle the + * linked invoice. Idempotent — no-op when already success. Pass `notify: false` + * when the caller settles inline and will trigger settlement itself. + */ + async markIntentSucceeded( + intentId: string, + opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, + ): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.paymentRepo.findOneBy({ id: intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); if (intent.status === "success") return { alreadyFinalized: true }; - const paidAt = input.paidAt ?? new Date(); + const paidAt = opts.paidAt ?? new Date(); + await this.paymentRepo.update( + { id: intent.id }, + { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId }, + ); - // A general contract is paid once, up front; it does NOT enter the train - // queue (nothing has been ordered yet). Instead it becomes ACTIVE and - // opens its ordering window. Orders placed later spawn their own paid - // child bookings that go through the normal pipeline. - const booking = await this.datasource - .getRepository(Booking) - .findOne({ where: { id: input.bookingId } }); - const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT"; - - let contractExpiresAt: Date | null = null; - if (isGeneralContract) { - const months = await this.contractPeriodMonths(); - contractExpiresAt = new Date(paidAt); - contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months); - } - - await this.datasource.transaction(async (mg) => { - await mg.update( - PaymentEntity, - { id: intent.id }, - { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, - ); - await mg.update( - Booking, - { id: input.bookingId }, - isGeneralContract - ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } - : { paymentStatus: "PAID", status: "PAID" }, - ); - - // Settle the booking's open invoice in the same transaction and link - // this payment. The invoice emits `booking.invoice.paid` for the source - // to react to. No-op if the booking has no open invoice. - await this.billing.settlePayable( - Freight.InvoiceSource.Booking, - input.bookingId, - intent.id, - mg, - ); - - await this.firstMileService.acceptBooking(input.bookingId); - - }); - - if (isGeneralContract) { - this.logger.log( - `General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, - ); - return { alreadyFinalized: false }; - } - - try { - await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); - } catch (err) { - this.logger.error( - `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, - ); + if (opts.notify !== false) { + await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); } return { alreadyFinalized: false }; @@ -402,6 +372,29 @@ export class PaymentService { { id: intent.id }, { status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage }, ); + + // Invoice stays open for retry — nothing to settle. Logged only. + this.logger.warn( + `Payment ${intent.id} failed for ${intent.refId}` + + (input.failureMessage ? `: ${input.failureMessage}` : ""), + ); + } + + async refund(dto: RefundDto) { + const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); + if (!intent || intent.status !== "success") { + throw new BadRequestException("No successful payment to refund"); + } + + // NOTE: refunding still mutates the booking directly — left intact pending + // the refund redesign. TODO: route refunds through billing.refundPayable + + // a `${source}.invoice.refunded` reaction, like settlement. + await this.datasource.transaction(async (mg) => { + await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); + await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); + }); + + return { refunded: true, bookingId: dto.bookingId }; } async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { @@ -468,13 +461,12 @@ export class PaymentService { if (event.eventType === "payment.succeeded") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } - const { alreadyFinalized } = await this.finalizePaymentSuccess({ - intentId: intent.id, - bookingId: event.referenceId, + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + notify: true, }); return { processed: true, alreadyFinalized }; } @@ -482,7 +474,7 @@ export class PaymentService { if (event.eventType === "payment.failed") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } await this.markPaymentFailed({ intentId: intent.id, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 374eee28a..6375a0369 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -140,11 +140,18 @@ export enum InvoiceStatus { /** Originating subsystem an invoice bills for; namespaces invoice events. */ export enum InvoiceSource { Booking = "booking", - Contract = "contract", Warehouse = "warehouse", Demurrage = "demurrage", } +/** + * What an invoice bills for within its source — the discriminator when one + * entity carries several invoices (e.g. a booking's up-front vs final charge). + */ +export enum InvoiceType { + Prepaid = "PREPAID", +} + export enum SchedulingStatus { NotScheduled = "NOT_SCHEDULED", Holding = "HOLDING",