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 e52dfafa1..61597264b 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 @@ -89,7 +89,7 @@ describe("BillingService.generateInvoice", () => { expect(invoice.sourceId).toBe("booking-1"); expect(invoice.totalAmount).toBe(1500); expect(invoice.issuedAt).toBeInstanceOf(Date); - expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/); + expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/); expect(savedLines).toHaveLength(2); }); 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 e4389e7cf..f1b58ad5e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -96,11 +96,6 @@ export interface GenerateInvoiceInput { * (default PENDING) stamps `issuedAt`. */ status?: Freight.InvoiceStatus; - /** - * Document number prefix for this source (e.g. `WHF` for warehouse fees); - * defaults to `FRT`. The daily sequence is allocated per prefix. - */ - numberCode?: string; } /** Payload broadcast on `${source}.invoice.`. */ @@ -665,11 +660,12 @@ export class BillingService { const result = await this.payment.initiate({ referenceId: sourceId, source: invoice.source, - // Gateway reference type derives from the invoice source by convention - // (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and - // the domain never supplies it. New sources add their uppercased value to - // the PaymentReferenceType enum. - referenceType: invoice.source.toUpperCase() as PaymentReferenceType, + // Freight payments settle under the generic SHIPMENT reference — how the + // payment service attributes them to the freight API. The payment ↔ invoice + // link is the intent id (`paymentId`); per-source post-payment reactions live + // in the domain via `${source}.invoice.paid`. Neither billing nor the payment + // service branches on a domain-specific reference type. + referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber, amountMinor: Math.round(Number(invoice.totalAmount)), currency: invoice.currency, 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 b1b269665..50856c3d7 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -6,8 +6,6 @@ import { ParseUUIDPipe, Query, Res, - Body, - Post, } from "@nestjs/common"; import { ApiTags, @@ -18,9 +16,9 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView, FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; -import { IntentStatusDto, RefundDto } from "./payments.dto"; +import { IntentStatusDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") @@ -73,13 +71,6 @@ export class PaymentController { return this.paymentService.getIntentByBookingId(bookingId); } - @Post("refund") - @FreightAdmin() - @ApiOperation({ summary: "Refund a paid booking (staff/admin only)" }) - refund(@Body() dto: RefundDto) { - return this.paymentService.refund(dto); - } - @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) 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 738a6d118..d92af7a3e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -7,7 +7,6 @@ import { Logger, NotFoundException, } from "@nestjs/common"; -import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -16,7 +15,6 @@ import { BillingService } from "../billing/billing.service"; import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; -import { Booking } from "../bookings/entities/booking.entity"; import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { @@ -29,7 +27,6 @@ import { InitiateResponseDto, IntentStatusDto, PaymentPlatformDto, - RefundDto, } from "./payments.dto"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by @@ -96,7 +93,6 @@ export class PaymentService { private readonly logger = new Logger(PaymentService.name); constructor( - private readonly datasource: DataSource, private readonly paymentRepo: PaymentRepository, private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BillingService)) @@ -404,34 +400,6 @@ export class PaymentService { ); } - 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"], @@ -527,16 +495,10 @@ export class PaymentService { `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, ); - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { - await this.datasource.manager.update( - Booking, - { id: intent.refId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + // The payment service stays domain-agnostic: it settles the intent and + // lets billing settle the invoice (markIntentSucceeded → settleByPaymentId), + // which emits `${source}.invoice.paid`. Per-source advances (booking → PAID, + // warehouse → release, …) live in the domain services that listen for it. return { processed: true, alreadyFinalized }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 9b349181d..6f7219781 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; -import { BillingService, InvoiceLineInput } from '../billing/billing.service'; +import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { @@ -35,8 +36,6 @@ export interface PayInvoiceDto { /** Warehouse fee invoices live in the global billing system under this source. */ const SOURCE = Freight.InvoiceSource.Warehouse; -/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */ -const NUMBER_CODE = 'WHF'; /** Global statuses that still owe money and therefore block terminal release. */ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ @@ -216,7 +215,6 @@ export class WarehouseInvoiceService { currency: billingCurrency, lines, status: Freight.InvoiceStatus.Issued, - numberCode: NUMBER_CODE, }); const detail = await this.findById(invoice.id); @@ -307,6 +305,21 @@ export class WarehouseInvoiceService { return detail; } + /** + * Notify on online (gateway) settlement — the domain side-effect of a warehouse + * fee being paid through billing's payment flow. The counter {@link pay} path + * notifies inline (and carries driver details from the request), so this only + * handles gateway payments: those stamp the invoice `paymentId`, whereas a + * counter settlement leaves it null. Skipping null-`paymentId` events avoids + * double-notifying a counter payment that already sent its SMS. + */ + @OnEvent('warehouse.invoice.paid') + async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise { + if (!payload.paymentId) return; + const detail = await this.findById(payload.invoiceId); + await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) }); + } + // ── Release blocking ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ async findBlockingInvoice(inventoryId: string): Promise {