From 2551f76f8a9f7b6a339b4e5f99f6f5dfeaef792b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 09:50:29 +0000 Subject: [PATCH] fix: reference type in payment and billing --- .../1821000000004-MakePaymentsTypeGeneric.ts | 47 +++++++++++++++++++ .../src/modules/billing/billing.service.ts | 8 +++- .../payment/entities/payment.entity.ts | 8 +++- .../src/modules/payment/payment.service.ts | 20 ++++---- 4 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts diff --git a/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts new file mode 100644 index 000000000..9f0e8e7bd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make the payment projection source-agnostic so any domain (not just bookings) + * can own a payment intent. + * + * - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the + * invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a + * new domain no longer needs an enum migration to write its intents. + * - adds `payments.reference_type varchar(40)` — the gateway reference type + * (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll + * path can query the provider without hardcoding it. + * + * Matches payment/entities/payment.entity.ts. + */ +export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface { + name = "MakePaymentsTypeGeneric1821000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`, + ); + await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`); + + await queryRunner.query( + `ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`, + ); + + // Restore the single-value enum. Any non-'booking' rows would block the cast; + // collapse them first so the down migration is safe. + await queryRunner.query( + `UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`, + ); + await queryRunner.query( + `CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`, + ); + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`, + ); + } +} 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 448ad6685..1d0473c7e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,6 +1,6 @@ import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; -import { Freight } from "@edr/types"; +import { Freight, PaymentReferenceType } from "@edr/types"; import { DataSource, EntityManager, In } from "typeorm"; import { Invoice } from "./entities/invoice.entity"; @@ -417,6 +417,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, orderRef: invoice.invoiceNumber, amountMinor: Math.round(Number(invoice.totalAmount)), currency: invoice.currency, diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 2bb81a331..5c4a4f7f7 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat import { PaymentRefundEntity } from "./payment-refund.entity"; -type PaymentType = "booking" +/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ +type PaymentType = string type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" type Currency = "ETB" | "USD" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity { @Column({ type: 'varchar', length: 255, name: "ref_id" }) refId!: string - @Column({ type: "enum", enum: ["booking"] }) + @Column({ type: "varchar", length: 50 }) type!: PaymentType; + @Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" }) + referenceType?: string; + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] }) method!: PaymentMethod 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 7beb6e8c6..a712e4ad8 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -40,6 +40,10 @@ import { export interface InitiateIntentInput { /** Opaque domain reference (booking id, …). */ referenceId: string; + /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ + source: string; + /** Gateway reference type the intent is opened with (caller's domain decides it). */ + referenceType: PaymentReferenceType; /** Human-readable order ref shown on provider pages. */ orderRef: string; /** Authoritative amount in minor units, computed by the caller. */ @@ -194,7 +198,7 @@ export class PaymentService { async initiate(input: InitiateIntentInput): Promise { const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, - referenceType: PaymentReferenceType.SHIPMENT, + referenceType: input.referenceType, referenceId: input.referenceId, orderRef: input.orderRef, amountMinor: input.amountMinor, @@ -240,7 +244,6 @@ export class PaymentService { ): Promise { const existing = await this.paymentRepo.findOneBy({ refId: input.referenceId, - type: "booking", }); const method: PaymentEntity["method"] = @@ -270,7 +273,8 @@ export class PaymentService { return this.paymentRepo.create({ refId: input.referenceId, - type: "booking", + type: input.source, + referenceType: input.referenceType, amount: input.amountMinor, currency: input.currency as PaymentEntity["currency"], reason: input.reason ?? `Payment for ${input.orderRef}`, @@ -287,12 +291,12 @@ export class PaymentService { * (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" }); + const local = await this.paymentRepo.findOneBy({ refId: referenceId }); let snapshot: PaymentIntentSnapshot | null = null; try { snapshot = await this.paymentClient.getIntentByReference( - PaymentReferenceType.SHIPMENT, + (local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT, referenceId, ); } catch (err) { @@ -423,7 +427,7 @@ export class PaymentService { } findBookingById(id: string) { - return this.paymentRepo.findOneBy({ refId: id, type: "booking" }); + return this.paymentRepo.findOneBy({ refId: id }); } formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { @@ -459,7 +463,7 @@ export class PaymentService { failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } @@ -472,7 +476,7 @@ export class PaymentService { } if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; }