mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
fix: reference type in payment and billing
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||||
import { Freight } from "@edr/types";
|
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
@@ -417,6 +417,12 @@ export class BillingService {
|
|||||||
|
|
||||||
const result = await this.payment.initiate({
|
const result = await this.payment.initiate({
|
||||||
referenceId: sourceId,
|
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,
|
orderRef: invoice.invoiceNumber,
|
||||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat
|
|||||||
import { PaymentRefundEntity } from "./payment-refund.entity";
|
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 PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||||
type Currency = "ETB" | "USD"
|
type Currency = "ETB" | "USD"
|
||||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
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" })
|
@Column({ type: 'varchar', length: 255, name: "ref_id" })
|
||||||
refId!: string
|
refId!: string
|
||||||
|
|
||||||
@Column({ type: "enum", enum: ["booking"] })
|
@Column({ type: "varchar", length: 50 })
|
||||||
type!: PaymentType;
|
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"] })
|
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||||
method!: PaymentMethod
|
method!: PaymentMethod
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ import {
|
|||||||
export interface InitiateIntentInput {
|
export interface InitiateIntentInput {
|
||||||
/** Opaque domain reference (booking id, …). */
|
/** Opaque domain reference (booking id, …). */
|
||||||
referenceId: string;
|
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. */
|
/** Human-readable order ref shown on provider pages. */
|
||||||
orderRef: string;
|
orderRef: string;
|
||||||
/** Authoritative amount in minor units, computed by the caller. */
|
/** Authoritative amount in minor units, computed by the caller. */
|
||||||
@@ -194,7 +198,7 @@ export class PaymentService {
|
|||||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.FREIGHT,
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
referenceType: input.referenceType,
|
||||||
referenceId: input.referenceId,
|
referenceId: input.referenceId,
|
||||||
orderRef: input.orderRef,
|
orderRef: input.orderRef,
|
||||||
amountMinor: input.amountMinor,
|
amountMinor: input.amountMinor,
|
||||||
@@ -240,7 +244,6 @@ export class PaymentService {
|
|||||||
): Promise<PaymentEntity> {
|
): Promise<PaymentEntity> {
|
||||||
const existing = await this.paymentRepo.findOneBy({
|
const existing = await this.paymentRepo.findOneBy({
|
||||||
refId: input.referenceId,
|
refId: input.referenceId,
|
||||||
type: "booking",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const method: PaymentEntity["method"] =
|
const method: PaymentEntity["method"] =
|
||||||
@@ -270,7 +273,8 @@ export class PaymentService {
|
|||||||
|
|
||||||
return this.paymentRepo.create({
|
return this.paymentRepo.create({
|
||||||
refId: input.referenceId,
|
refId: input.referenceId,
|
||||||
type: "booking",
|
type: input.source,
|
||||||
|
referenceType: input.referenceType,
|
||||||
amount: input.amountMinor,
|
amount: input.amountMinor,
|
||||||
currency: input.currency as PaymentEntity["currency"],
|
currency: input.currency as PaymentEntity["currency"],
|
||||||
reason: input.reason ?? `Payment for ${input.orderRef}`,
|
reason: input.reason ?? `Payment for ${input.orderRef}`,
|
||||||
@@ -287,12 +291,12 @@ export class PaymentService {
|
|||||||
* (the booking id, but this service does not load it).
|
* (the booking id, but this service does not load it).
|
||||||
*/
|
*/
|
||||||
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
|
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
|
||||||
const local = await this.paymentRepo.findOneBy({ refId: referenceId, type: "booking" });
|
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
|
||||||
|
|
||||||
let snapshot: PaymentIntentSnapshot | null = null;
|
let snapshot: PaymentIntentSnapshot | null = null;
|
||||||
try {
|
try {
|
||||||
snapshot = await this.paymentClient.getIntentByReference(
|
snapshot = await this.paymentClient.getIntentByReference(
|
||||||
PaymentReferenceType.SHIPMENT,
|
(local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT,
|
||||||
referenceId,
|
referenceId,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -423,7 +427,7 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findBookingById(id: string) {
|
findBookingById(id: string) {
|
||||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
|
return this.paymentRepo.findOneBy({ refId: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||||
@@ -459,7 +463,7 @@ export class PaymentService {
|
|||||||
failureMessage?: string;
|
failureMessage?: string;
|
||||||
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
||||||
if (event.eventType === "payment.succeeded") {
|
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) {
|
if (!intent) {
|
||||||
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||||
}
|
}
|
||||||
@@ -472,7 +476,7 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.eventType === "payment.failed") {
|
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) {
|
if (!intent) {
|
||||||
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user