mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'staging' into dev-to-staging
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
|
||||
@@ -13,6 +13,8 @@ import ebirrConfig from "./config/ebirr.config";
|
||||
import cardConfig from "./config/card.config";
|
||||
import dmoneyConfig from "./config/dmoney.config";
|
||||
import cacConfig from "./config/cac.config";
|
||||
import cbeBillConfig from "./config/cbe-bill.config";
|
||||
import { CbeBillModule } from "./modules/cbe-bill/cbe-bill.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { IntentsModule } from "./modules/intents/intents.module";
|
||||
import { OutboxModule } from "./modules/outbox/outbox.module";
|
||||
@@ -36,6 +38,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module";
|
||||
cardConfig,
|
||||
dmoneyConfig,
|
||||
cacConfig,
|
||||
cbeBillConfig,
|
||||
],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
@@ -47,6 +50,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module";
|
||||
HealthModule,
|
||||
ProvidersModule,
|
||||
IntentsModule,
|
||||
CbeBillModule,
|
||||
WebhooksModule,
|
||||
OutboxModule,
|
||||
ReconciliationModule,
|
||||
|
||||
26
apps/edr-payment-api/src/config/cbe-bill.config.ts
Normal file
26
apps/edr-payment-api/src/config/cbe-bill.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
/**
|
||||
* CBE Unified Bill Payment — the INBOUND biller integration (docs/cbe/). Deliberately separate
|
||||
* from cbe.config.ts, which belongs to the outbound CBE_BIRR wallet gateway: two disjoint
|
||||
* credential/auth domains that rotate independently (plan D7).
|
||||
*/
|
||||
export default registerAs("cbeBill", () => ({
|
||||
/** Kill switch — all /cbe/* endpoints answer 503 while false. */
|
||||
enabled: process.env.CBE_BILL_ENABLED === "true",
|
||||
/** Credentials CBE presents to /cbe/oauth/token. */
|
||||
clientId: process.env.CBE_BILL_CLIENT_ID || "",
|
||||
clientSecret: process.env.CBE_BILL_CLIENT_SECRET || "",
|
||||
/** Signs/verifies the bearer tokens WE issue to CBE — never shared with ServiceAuthGuard. */
|
||||
jwtSecret: process.env.CBE_BILL_JWT_SECRET || "",
|
||||
tokenExpiresIn: Number(process.env.CBE_BILL_TOKEN_EXPIRES_IN || 3600),
|
||||
scope: process.env.CBE_BILL_SCOPE || "Unified_Outgoing",
|
||||
/** bill-query hop to the owning domain app. Short — CBE holds its own timeout over ours. */
|
||||
domainTimeoutMs: Number(process.env.CBE_BILL_DOMAIN_TIMEOUT_MS || 3000),
|
||||
passengerApiBaseUrl: (
|
||||
process.env.PASSENGER_API_BASE_URL || "http://localhost:3002"
|
||||
).replace(/\/$/, ""),
|
||||
freightApiBaseUrl: (
|
||||
process.env.FREIGHT_API_BASE_URL || "http://localhost:3001"
|
||||
).replace(/\/$/, ""),
|
||||
}));
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.1, §5):
|
||||
* - `bill_reference` — the short numeric Bill_Id CBE presents back to us; unique, null for
|
||||
* every non-CBE intent.
|
||||
* - `payer_name` — payer snapshot captured at initiate; fallback for /cbe/query Full_Name.
|
||||
* - `cbe_bill_reference_seq` — backs the 11-digit sequence body of the bill reference.
|
||||
*
|
||||
* DATA SAFETY: purely additive — new nullable columns and a new sequence; no existing rows
|
||||
* or values are touched.
|
||||
*/
|
||||
export class AddCbeBillReference1782300000000 implements MigrationInterface {
|
||||
name = "AddCbeBillReference1782300000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "edr_payment"."payment_intent" ADD COLUMN IF NOT EXISTS "bill_reference" varchar(32)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "edr_payment"."payment_intent" ADD COLUMN IF NOT EXISTS "payer_name" varchar(128)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_payment_intent_bill_reference" ON "edr_payment"."payment_intent" ("bill_reference") WHERE "bill_reference" IS NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS "edr_payment"."cbe_bill_reference_seq" START 10000001`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS "edr_payment"."cbe_bill_reference_seq"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "edr_payment"."uq_payment_intent_bill_reference"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "edr_payment"."payment_intent" DROP COLUMN IF EXISTS "payer_name"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "edr_payment"."payment_intent" DROP COLUMN IF EXISTS "bill_reference"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* CBE-protocol audit/idempotency ledger for the Unified Bill Payment integration
|
||||
* (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.2).
|
||||
*
|
||||
* - UNIQUE (end_to_end_txn_id, operation): DB-level backstop for the application idempotency
|
||||
* checks — a concurrent duplicate of the same CBE attempt cannot create two rows.
|
||||
* - Partial UNIQUE (cbe_txn_ref) on settled PAYMENTs: blocks Cbe_Txn_Ref replay across bills.
|
||||
*
|
||||
* DATA SAFETY: new table only; nothing existing is touched.
|
||||
*/
|
||||
export class CreateCbeBillOperation1782400000000 implements MigrationInterface {
|
||||
name = "CreateCbeBillOperation1782400000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "edr_payment"."cbe_bill_operation" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"operation" varchar(16) NOT NULL,
|
||||
"bill_id" varchar(32) NOT NULL,
|
||||
"end_to_end_txn_id" varchar(128) NOT NULL,
|
||||
"cbe_txn_ref" varchar(128),
|
||||
"destination_api_name" varchar(64),
|
||||
"intent_id" uuid,
|
||||
"trade_status" varchar(16) NOT NULL,
|
||||
"failure_class" varchar(16),
|
||||
"response_code" varchar(8),
|
||||
"response_description" text,
|
||||
"request_payload" jsonb NOT NULL,
|
||||
"response_payload" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_cbe_bill_operation_e2e" ON "edr_payment"."cbe_bill_operation" ("end_to_end_txn_id", "operation")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_cbe_bill_operation_txn_ref" ON "edr_payment"."cbe_bill_operation" ("cbe_txn_ref") WHERE "operation" = 'PAYMENT' AND "trade_status" = 'SUCCESS'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_cbe_bill_operation_bill" ON "edr_payment"."cbe_bill_operation" ("bill_id", "operation")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "edr_payment"."cbe_bill_operation"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { PaymentReferenceType, PaymentService } from "@edr/types";
|
||||
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||
import { CbeBillError } from "./mappers/cbe-error.mapper";
|
||||
|
||||
/**
|
||||
* Why a bill is no longer payable. Shared vocabulary between both domain apps and the local
|
||||
* intent check, so one mapper produces every Response_Description CBE sees.
|
||||
* `NOT_PAYABLE` is the catch-all for domain states with no better name (booking DRAFT/BOARDED,
|
||||
* invoice DRAFT) — it must stay last-resort, never a substitute for a specific reason.
|
||||
*/
|
||||
export type BillNotPayableReason =
|
||||
| "ALREADY_PAID"
|
||||
| "CANCELLED"
|
||||
| "REFUNDED"
|
||||
| "EXPIRED"
|
||||
| "NOT_FOUND"
|
||||
| "NOT_PAYABLE";
|
||||
|
||||
/** Contract of POST /internal/payments/bill-query on the domain apps (plan Phase 4). */
|
||||
export interface BillQueryResult {
|
||||
stillPayable: boolean;
|
||||
payerName?: string | null;
|
||||
currentAmountMinor?: number | null;
|
||||
currency?: string | null;
|
||||
/** When stillPayable=false — see {@link BillNotPayableReason}. */
|
||||
reason?: BillNotPayableReason | string | null;
|
||||
/**
|
||||
* What the payer is paying for, shown on CBE's confirmation screen next to the amount —
|
||||
* the domain's own human reference (booking ref / invoice number), not our internal ids.
|
||||
*/
|
||||
paymentReason?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment_Reason when the domain app sends none (older build, or an order with no human
|
||||
* reference). Generic but never blank: CBE renders this field to the payer, and a bill with
|
||||
* an amount and no stated purpose is what a customer refuses to confirm.
|
||||
*/
|
||||
export function defaultPaymentReason(
|
||||
referenceType: PaymentReferenceType,
|
||||
): string {
|
||||
return referenceType === PaymentReferenceType.BOOKING
|
||||
? "Train ticket booking"
|
||||
: "Freight invoice";
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it
|
||||
* has to name the thing they are actually holding — a passenger booking or a freight invoice —
|
||||
* rather than our internal "bill" abstraction (plan §6.6).
|
||||
*/
|
||||
function subjectOf(referenceType: PaymentReferenceType): string {
|
||||
return referenceType === PaymentReferenceType.BOOKING ? "booking" : "invoice";
|
||||
}
|
||||
|
||||
export function reasonToDescription(
|
||||
reason: string | null | undefined,
|
||||
referenceType: PaymentReferenceType,
|
||||
): string {
|
||||
const subject = subjectOf(referenceType);
|
||||
switch (reason) {
|
||||
case "ALREADY_PAID":
|
||||
return `This ${subject} has already been paid.`;
|
||||
case "CANCELLED":
|
||||
return `This ${subject} has been cancelled.`;
|
||||
case "REFUNDED":
|
||||
return `This ${subject} has been refunded.`;
|
||||
case "EXPIRED":
|
||||
return `This ${subject} has expired and can no longer be paid.`;
|
||||
// A bill reference we issued whose order has since vanished from the domain app. Same
|
||||
// wording as an unknown Bill_Id — from the teller's side it is the same situation.
|
||||
case "NOT_FOUND":
|
||||
return "Bill not found.";
|
||||
default:
|
||||
return `This ${subject} is no longer payable.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live "still payable?" hop to the owning domain app — routing comes from
|
||||
* `intent.service` (plan D3). This hop is the double-payment guard (§6.3) and the source of
|
||||
* the mandatory Full_Name: NOT optional, and on the /cbe/payment path never served from cache.
|
||||
* Short timeout, no retries — CBE holds its own timeout over ours.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BillResolverService {
|
||||
private readonly logger = new Logger(BillResolverService.name);
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async billQuery(intent: PaymentIntent): Promise<BillQueryResult> {
|
||||
const base =
|
||||
intent.service === PaymentService.PASSENGER
|
||||
? this.config.get<string>("cbeBill.passengerApiBaseUrl")
|
||||
: this.config.get<string>("cbeBill.freightApiBaseUrl");
|
||||
const url = `${base}/internal/payments/bill-query`;
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<BillQueryResult>(
|
||||
url,
|
||||
{
|
||||
referenceType: intent.referenceType,
|
||||
referenceId: intent.referenceId,
|
||||
},
|
||||
{
|
||||
timeout: this.config.get<number>("cbeBill.domainTimeoutMs") ?? 3000,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
},
|
||||
),
|
||||
);
|
||||
// The passenger API wraps every response in a { success, data } envelope
|
||||
// (global transform interceptor); freight returns the body bare. Accept both.
|
||||
const body = response.data as unknown as {
|
||||
success?: boolean;
|
||||
data?: BillQueryResult;
|
||||
};
|
||||
return body && typeof body === "object" && "success" in body && body.data
|
||||
? body.data
|
||||
: (response.data as BillQueryResult);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`bill-query ${intent.service}/${intent.referenceId} unreachable: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
// TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5).
|
||||
throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT");
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts
Normal file
39
apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Verifies the bearer tokens WE minted for CBE at /cbe/oauth/token (plan D7). Its secret
|
||||
* (CBE_BILL_JWT_SECRET) is disjoint from ServiceAuthGuard's shared token: a CBE token must
|
||||
* never authenticate a call to /payments/*, and vice versa.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CbeAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth?.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException("Missing bearer token");
|
||||
}
|
||||
|
||||
try {
|
||||
await this.jwtService.verifyAsync(auth.substring(7), {
|
||||
secret: this.config.get<string>("cbeBill.jwtSecret"),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CbeAuthGuard } from "./cbe-auth.guard";
|
||||
import { CbeExceptionFilter } from "./cbe-exception.filter";
|
||||
import { CbeBillService } from "./cbe-bill.service";
|
||||
import { TokenRequestDto } from "./dto/token-request.dto";
|
||||
import { TokenResponseDto } from "./dto/token-response.dto";
|
||||
import { CbeQueryRequestDto } from "./dto/cbe-query-request.dto";
|
||||
import { CbeQueryResponseDto } from "./dto/cbe-query-response.dto";
|
||||
import { CbePaymentRequestDto } from "./dto/cbe-payment-request.dto";
|
||||
import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto";
|
||||
|
||||
/**
|
||||
* CBE Unified Bill Payment — the INBOUND surface CBE core banking calls (docs/cbe/). We are
|
||||
* the biller: CBE authenticates against /cbe/oauth/token with credentials we issued, then
|
||||
* presents the bearer token on /cbe/query and /cbe/payment. Business failures answer HTTP 200
|
||||
* with Response_Code "3"; only authentication answers 401 (plan D6/D7).
|
||||
*/
|
||||
@ApiTags("CBE Unified Bill (inbound)")
|
||||
@Controller("cbe")
|
||||
@UseFilters(CbeExceptionFilter)
|
||||
export class CbeBillController {
|
||||
constructor(private readonly cbeBillService: CbeBillService) {}
|
||||
|
||||
@Post("oauth/token")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "OAuth client_credentials token for CBE (we are the auth server)" })
|
||||
async token(@Body() dto: TokenRequestDto): Promise<TokenResponseDto> {
|
||||
return this.cbeBillService.generateToken(dto);
|
||||
}
|
||||
|
||||
@Post("query")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(CbeAuthGuard)
|
||||
@ApiOperation({ summary: "Bill lookup — amount due + payer name for a Bill_Id" })
|
||||
async query(@Body() dto: CbeQueryRequestDto): Promise<CbeQueryResponseDto> {
|
||||
return this.cbeBillService.query(dto);
|
||||
}
|
||||
|
||||
@Post("payment")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(CbeAuthGuard)
|
||||
@ApiOperation({ summary: "Settle a bill — customer already debited by CBE" })
|
||||
async payment(
|
||||
@Body() dto: CbePaymentRequestDto,
|
||||
): Promise<CbePaymentResponseDto> {
|
||||
return this.cbeBillService.pay(dto);
|
||||
}
|
||||
}
|
||||
28
apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts
Normal file
28
apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { IntentsModule } from "../intents/intents.module";
|
||||
import { CbeBillOperation } from "./entities/cbe-bill-operation.entity";
|
||||
import { BillResolverService } from "./bill-resolver.service";
|
||||
import { CbeAuthGuard } from "./cbe-auth.guard";
|
||||
import { CbeBillController } from "./cbe-bill.controller";
|
||||
import { CbeBillRepository } from "./cbe-bill.repository";
|
||||
import { CbeBillService } from "./cbe-bill.service";
|
||||
|
||||
/**
|
||||
* Inbound CBE Unified Bill Payment module (docs/cbe/). Its auth domain is disjoint from the
|
||||
* rest of the app: tokens are minted and verified with CBE_BILL_JWT_SECRET only (plan D7) —
|
||||
* JwtModule is registered bare and the secret passed explicitly at sign/verify time.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([CbeBillOperation]),
|
||||
HttpModule,
|
||||
JwtModule.register({}),
|
||||
IntentsModule,
|
||||
],
|
||||
controllers: [CbeBillController],
|
||||
providers: [CbeBillService, CbeBillRepository, BillResolverService, CbeAuthGuard],
|
||||
})
|
||||
export class CbeBillModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
CbeBillOperation,
|
||||
CbeOperation,
|
||||
} from "./entities/cbe-bill-operation.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CbeBillRepository extends BaseRepository<CbeBillOperation> {
|
||||
constructor(
|
||||
@InjectRepository(CbeBillOperation)
|
||||
repository: Repository<CbeBillOperation>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** The prior attempt for CBE's per-attempt id — the §6.5 idempotency lookup. */
|
||||
async findByEndToEndTxnId(
|
||||
endToEndTxnId: string,
|
||||
operation: CbeOperation,
|
||||
): Promise<CbeBillOperation | null> {
|
||||
return this.repository.findOne({ where: { endToEndTxnId, operation } });
|
||||
}
|
||||
|
||||
/** A SUCCESSful settlement already carrying this Cbe_Txn_Ref — blocks replay across bills. */
|
||||
async findSettledByCbeTxnRef(
|
||||
cbeTxnRef: string,
|
||||
): Promise<CbeBillOperation | null> {
|
||||
return this.repository.findOne({
|
||||
where: { cbeTxnRef, operation: "PAYMENT", tradeStatus: "SUCCESS" },
|
||||
});
|
||||
}
|
||||
}
|
||||
387
apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts
Normal file
387
apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { ProviderMethod, ProviderPaymentStatus } from "@edr/types";
|
||||
import { BillReferenceService } from "../intents/bill-reference.service";
|
||||
import { IntentsRepository } from "../intents/intents.repository";
|
||||
import { IntentsService } from "../intents/intents.service";
|
||||
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||
import {
|
||||
BillNotPayableReason,
|
||||
BillResolverService,
|
||||
defaultPaymentReason,
|
||||
reasonToDescription,
|
||||
} from "./bill-resolver.service";
|
||||
import { CbeBillRepository } from "./cbe-bill.repository";
|
||||
import { CbeBillOperation } from "./entities/cbe-bill-operation.entity";
|
||||
import { TokenRequestDto } from "./dto/token-request.dto";
|
||||
import { TokenResponseDto } from "./dto/token-response.dto";
|
||||
import { CbeQueryRequestDto } from "./dto/cbe-query-request.dto";
|
||||
import { CbeQueryResponseDto } from "./dto/cbe-query-response.dto";
|
||||
import { CbePaymentRequestDto } from "./dto/cbe-payment-request.dto";
|
||||
import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto";
|
||||
import { CbeBillError, toCbeFailure } from "./mappers/cbe-error.mapper";
|
||||
import {
|
||||
mapQueryFailure,
|
||||
mapQuerySuccess,
|
||||
} from "./mappers/cbe-query.mapper";
|
||||
import {
|
||||
mapPaymentFailure,
|
||||
mapPaymentSuccess,
|
||||
} from "./mappers/cbe-payment.mapper";
|
||||
|
||||
/** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */
|
||||
const AMOUNT_TOLERANCE = 0.01;
|
||||
|
||||
/**
|
||||
* Translate an intent's own terminal state into the same reason vocabulary the domain apps
|
||||
* speak, so both paths flow through one description mapper.
|
||||
*/
|
||||
function localReason(intent: PaymentIntent): BillNotPayableReason {
|
||||
switch (intent.status) {
|
||||
case ProviderPaymentStatus.SUCCEEDED:
|
||||
return "ALREADY_PAID";
|
||||
case ProviderPaymentStatus.CANCELLED:
|
||||
// expireIntent() cancels with failureCode EXPIRED — an abandoned bill, not a cancellation.
|
||||
return intent.failureCode === "EXPIRED" ? "EXPIRED" : "CANCELLED";
|
||||
default:
|
||||
return "NOT_PAYABLE";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3).
|
||||
* Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the
|
||||
* controller); the exception filter only catches auth, validation, and the unexpected.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CbeBillService {
|
||||
private readonly logger = new Logger(CbeBillService.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly cbeBillRepository: CbeBillRepository,
|
||||
private readonly intentsRepository: IntentsRepository,
|
||||
private readonly intentsService: IntentsService,
|
||||
private readonly billReferenceService: BillReferenceService,
|
||||
private readonly billResolver: BillResolverService,
|
||||
) {}
|
||||
|
||||
/* ------------------------------------------------------------------ token */
|
||||
|
||||
async generateToken(dto: TokenRequestDto): Promise<TokenResponseDto> {
|
||||
this.assertEnabled();
|
||||
|
||||
const clientId = this.config.get<string>("cbeBill.clientId");
|
||||
const clientSecret = this.config.get<string>("cbeBill.clientSecret");
|
||||
// Unset credentials must fail closed — never let "" === "" mint a token.
|
||||
const valid =
|
||||
!!clientId &&
|
||||
!!clientSecret &&
|
||||
dto.client_id === clientId &&
|
||||
dto.client_secret === clientSecret &&
|
||||
dto.grant_type === "client_credentials" &&
|
||||
dto.scope === this.config.get<string>("cbeBill.scope");
|
||||
if (!valid) throw new UnauthorizedException("Invalid credentials");
|
||||
|
||||
const expiresIn = this.config.get<number>("cbeBill.tokenExpiresIn") ?? 3600;
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ iss: "edr-payment-api", clientId: dto.client_id, scope: dto.scope },
|
||||
{ secret: this.config.get<string>("cbeBill.jwtSecret"), expiresIn },
|
||||
);
|
||||
|
||||
return {
|
||||
token_type: "Bearer",
|
||||
access_token: accessToken,
|
||||
expires_in: expiresIn,
|
||||
scope: dto.scope,
|
||||
consented_on: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ query */
|
||||
|
||||
async query(dto: CbeQueryRequestDto): Promise<CbeQueryResponseDto> {
|
||||
this.assertEnabled();
|
||||
|
||||
// Audit first — unlike the reference (which left this commented out), every query attempt
|
||||
// is persisted; counter disputes are exactly what this row is for (plan Phase 3.2).
|
||||
const audit = await this.upsertAudit("QUERY", dto.End_To_End_Txn_Id, {
|
||||
billId: dto.Bill_Id,
|
||||
destinationApiName: dto.Destination_Api_Name,
|
||||
requestPayload: dto as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
try {
|
||||
const intent = await this.resolveIntent(dto.Bill_Id);
|
||||
this.assertIntentPayable(intent);
|
||||
|
||||
// The live domain hop — the double-payment guard (§6.3). Not optional.
|
||||
const billQuery = await this.billResolver.billQuery(intent);
|
||||
if (!billQuery.stillPayable) {
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
|
||||
const response = mapQuerySuccess(dto, {
|
||||
amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor,
|
||||
fullName: billQuery.payerName || intent.payerName || "",
|
||||
paymentReason:
|
||||
billQuery.paymentReason || defaultPaymentReason(intent.referenceType),
|
||||
});
|
||||
await this.finishAudit(audit, {
|
||||
intentId: intent.id,
|
||||
tradeStatus: "SUCCESS",
|
||||
response,
|
||||
});
|
||||
return response;
|
||||
} catch (err) {
|
||||
const failure = toCbeFailure(err);
|
||||
if (!(err instanceof CbeBillError)) {
|
||||
this.logger.error(
|
||||
`/cbe/query ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`,
|
||||
);
|
||||
}
|
||||
const response = mapQueryFailure(dto, failure.description);
|
||||
await this.finishAudit(audit, {
|
||||
tradeStatus: "FAILED",
|
||||
failureClass: failure.failureClass,
|
||||
response,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ payment */
|
||||
|
||||
async pay(dto: CbePaymentRequestDto): Promise<CbePaymentResponseDto> {
|
||||
this.assertEnabled();
|
||||
|
||||
// §6.5 idempotency on CBE's per-attempt id, in order.
|
||||
const prior = await this.cbeBillRepository.findByEndToEndTxnId(
|
||||
dto.End_To_End_Txn_Id,
|
||||
"PAYMENT",
|
||||
);
|
||||
if (prior) {
|
||||
if (prior.tradeStatus === "SUCCESS") {
|
||||
// Replay the stored body verbatim. Never re-settle.
|
||||
return prior.responsePayload as unknown as CbePaymentResponseDto;
|
||||
}
|
||||
if (prior.tradeStatus === "PENDING") {
|
||||
return mapPaymentFailure(dto, "Payment is being processed.");
|
||||
}
|
||||
if (prior.failureClass === "BUSINESS") {
|
||||
// Final — retrying cannot change the answer. Replay what we told CBE last time.
|
||||
return (
|
||||
(prior.responsePayload as unknown as CbePaymentResponseDto) ??
|
||||
mapPaymentFailure(
|
||||
dto,
|
||||
prior.responseDescription ?? "Payment already failed.",
|
||||
)
|
||||
);
|
||||
}
|
||||
// FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement.
|
||||
}
|
||||
|
||||
// Cbe_Txn_Ref replay across different bills/attempts (partial-unique backstop in the DB).
|
||||
const settled = await this.cbeBillRepository.findSettledByCbeTxnRef(
|
||||
dto.Cbe_Txn_Ref,
|
||||
);
|
||||
if (settled) {
|
||||
return mapPaymentFailure(
|
||||
dto,
|
||||
`Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let audit: CbeBillOperation;
|
||||
if (prior) {
|
||||
// Transient retry reuses the row — UNIQUE (end_to_end_txn_id, operation) forbids a second.
|
||||
audit =
|
||||
(await this.cbeBillRepository.update(prior.id, {
|
||||
tradeStatus: "PENDING",
|
||||
failureClass: null,
|
||||
cbeTxnRef: dto.Cbe_Txn_Ref,
|
||||
requestPayload: dto as unknown as Record<string, unknown>,
|
||||
})) ?? prior;
|
||||
} else {
|
||||
try {
|
||||
audit = await this.cbeBillRepository.create({
|
||||
operation: "PAYMENT",
|
||||
billId: dto.Bill_Id,
|
||||
endToEndTxnId: dto.End_To_End_Txn_Id,
|
||||
cbeTxnRef: dto.Cbe_Txn_Ref,
|
||||
destinationApiName: dto.Destination_Api_Name,
|
||||
tradeStatus: "PENDING",
|
||||
requestPayload: dto as unknown as Record<string, unknown>,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
|
||||
// Concurrent duplicate of the same attempt lost the insert race.
|
||||
return mapPaymentFailure(dto, "Payment is being processed.");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let intent: PaymentIntent | undefined;
|
||||
try {
|
||||
intent = await this.resolveIntent(dto.Bill_Id);
|
||||
|
||||
if (dto.Currency && dto.Currency !== intent.currency) {
|
||||
throw new CbeBillError("Payment currency does not match.", "BUSINESS");
|
||||
}
|
||||
|
||||
// Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous
|
||||
// failure (§6.1): after this we settle and reconcile downstream.
|
||||
const billQuery = await this.billResolver.billQuery(intent);
|
||||
if (!billQuery.stillPayable) {
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
|
||||
const amount = Number(dto.Amount);
|
||||
if (
|
||||
!Number.isFinite(amount) ||
|
||||
Math.abs(amount - intent.amountMinor) >
|
||||
intent.amountMinor * AMOUNT_TOLERANCE
|
||||
) {
|
||||
throw new CbeBillError("Payment amount does not match.", "BUSINESS");
|
||||
}
|
||||
|
||||
const paidAt = new Date(dto.Timestamp);
|
||||
// Existing state machine, unmodified — intent + outbox commit in one transaction.
|
||||
await this.intentsService.applyProviderResult(intent.id, {
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
providerTxnId: dto.Cbe_Txn_Ref,
|
||||
paidAt: Number.isNaN(paidAt.getTime()) ? new Date() : paidAt,
|
||||
confirmedAmountMinor: amount,
|
||||
});
|
||||
|
||||
const response = mapPaymentSuccess(dto, intent.merchantOrderId);
|
||||
await this.finishAudit(audit, {
|
||||
intentId: intent.id,
|
||||
tradeStatus: "SUCCESS",
|
||||
response,
|
||||
});
|
||||
this.logger.log(
|
||||
`bill ${dto.Bill_Id} settled by CBE txn ${dto.Cbe_Txn_Ref} (intent ${intent.id})`,
|
||||
);
|
||||
return response;
|
||||
} catch (err) {
|
||||
const failure = toCbeFailure(err);
|
||||
if (!(err instanceof CbeBillError)) {
|
||||
this.logger.error(
|
||||
`/cbe/payment ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`,
|
||||
);
|
||||
}
|
||||
const response = mapPaymentFailure(dto, failure.description);
|
||||
await this.finishAudit(audit, {
|
||||
intentId: intent?.id,
|
||||
tradeStatus: "FAILED",
|
||||
failureClass: failure.failureClass,
|
||||
response,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ helpers */
|
||||
|
||||
/** Kill switch (CBE_BILL_ENABLED) — 503 so CBE classes it as transport error and retries. */
|
||||
assertEnabled(): void {
|
||||
if (!this.config.get<boolean>("cbeBill.enabled")) {
|
||||
throw new ServiceUnavailableException("CBE bill payment is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheap local gate before the domain hop: what OUR record of this attempt says. Only
|
||||
* REQUIRES_ACTION is payable; every other status gets a description naming the actual reason,
|
||||
* because CBE reads it back to the payer standing at the counter. All BUSINESS — none of these
|
||||
* states can change back, so a same-End_To_End_Txn_Id retry cannot produce a different answer.
|
||||
*/
|
||||
private assertIntentPayable(intent: PaymentIntent): void {
|
||||
if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return;
|
||||
if (intent.status === ProviderPaymentStatus.PROCESSING) {
|
||||
throw new CbeBillError("Payment is being processed.", "BUSINESS");
|
||||
}
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(localReason(intent), intent.referenceType),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
|
||||
/** Check digit first (cheap reject), then the unique bill_reference lookup. */
|
||||
private async resolveIntent(billId: string): Promise<PaymentIntent> {
|
||||
if (!this.billReferenceService.isValid(billId)) {
|
||||
throw new CbeBillError("Bill not found.", "BUSINESS");
|
||||
}
|
||||
const intent = await this.intentsRepository.findByBillReference(billId);
|
||||
if (!intent || intent.provider !== ProviderMethod.CBE_BILL) {
|
||||
throw new CbeBillError("Bill not found.", "BUSINESS");
|
||||
}
|
||||
return intent;
|
||||
}
|
||||
|
||||
/** Insert the audit row; a CBE retry of the same QUERY attempt reuses (and refreshes) its row. */
|
||||
private async upsertAudit(
|
||||
operation: "QUERY",
|
||||
endToEndTxnId: string,
|
||||
data: {
|
||||
billId: string;
|
||||
destinationApiName: string;
|
||||
requestPayload: Record<string, unknown>;
|
||||
},
|
||||
): Promise<CbeBillOperation> {
|
||||
try {
|
||||
return await this.cbeBillRepository.create({
|
||||
operation,
|
||||
endToEndTxnId,
|
||||
tradeStatus: "PENDING",
|
||||
...data,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
|
||||
const existing = await this.cbeBillRepository.findByEndToEndTxnId(
|
||||
endToEndTxnId,
|
||||
operation,
|
||||
);
|
||||
if (existing) return existing;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async finishAudit(
|
||||
audit: CbeBillOperation,
|
||||
outcome: {
|
||||
intentId?: string;
|
||||
tradeStatus: "SUCCESS" | "FAILED";
|
||||
failureClass?: "BUSINESS" | "TRANSIENT";
|
||||
response: { Response_Code: string; Response_Description: string };
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.cbeBillRepository.update(audit.id, {
|
||||
intentId: outcome.intentId ?? audit.intentId,
|
||||
tradeStatus: outcome.tradeStatus,
|
||||
failureClass: outcome.failureClass ?? null,
|
||||
responseCode: outcome.response.Response_Code,
|
||||
responseDescription: outcome.response.Response_Description,
|
||||
responsePayload: outcome.response as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
|
||||
/**
|
||||
* Controller-scoped safety net for anything that escapes CbeBillService's own error handling
|
||||
* (auth failures, DTO validation, unhandled throws). CBE's contract (plan D6): HTTP 200 for
|
||||
* every business outcome, 401 only for authentication.
|
||||
*
|
||||
* Exception: the CBE_BILL_ENABLED kill switch throws ServiceUnavailableException and stays
|
||||
* HTTP 503 with a non-0/1/3 code — the spec classes "any other code" as a transport error
|
||||
* ("retry, contact admin"), which is exactly what a kill switch should signal; a 200/code-3
|
||||
* would tell CBE the failure is final.
|
||||
*/
|
||||
@Catch()
|
||||
export class CbeExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(CbeExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
|
||||
if (exception instanceof UnauthorizedException) {
|
||||
response.status(HttpStatus.UNAUTHORIZED).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "1",
|
||||
Response_Description: exception.message || "Unauthorized",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception instanceof ServiceUnavailableException) {
|
||||
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "9",
|
||||
Response_Description: "Service temporarily unavailable.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
// class-validator errors arrive as BadRequestException with message: string[].
|
||||
const body = exception.getResponse();
|
||||
const message =
|
||||
typeof body === "object" && body !== null && "message" in body
|
||||
? ([] as string[]).concat((body as { message: string }).message).join("; ")
|
||||
: exception.message;
|
||||
response.status(HttpStatus.OK).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Description: message || "Invalid request",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`unhandled /cbe/* error: ${exception instanceof Error ? exception.stack : String(exception)}`,
|
||||
);
|
||||
response.status(HttpStatus.OK).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Description: "Internal server error.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { AdditionalFieldDto } from "./cbe-query-request.dto";
|
||||
|
||||
/**
|
||||
* CBE → us, POST /cbe/payment (AAFDA spec §3.5). Mandatory fields per spec; the optional tail
|
||||
* (payer identity, channel) mirrors the reference implementation pending the Q1 sample files.
|
||||
*/
|
||||
export class CbePaymentRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Destination_Api_Name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
End_To_End_Txn_Id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Cbe_Txn_Ref!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Timestamp!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Bill_Id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Amount!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
Currency!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Phone_No?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Credit_Acct_Number?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
First_Name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Last_Name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Full_Name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Tin_Number?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Cheque_No?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Bank_Code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
Payment_Method?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [AdditionalFieldDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdditionalFieldDto)
|
||||
Additional_Fields?: AdditionalFieldDto[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Us → CBE, POST /cbe/payment response (AAFDA spec §3.8). */
|
||||
export class CbePaymentResponseDto {
|
||||
Destination_Api_Name!: string;
|
||||
End_To_End_Txn_Id!: string;
|
||||
Cbe_Txn_Ref!: string;
|
||||
Destination_Txn_Ref!: string;
|
||||
Status!: string;
|
||||
Response_Code!: string;
|
||||
Response_Description!: string;
|
||||
Additional_Fields!: { Key: string; Value: string }[];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
export class AdditionalFieldDto {
|
||||
@IsString()
|
||||
Key!: string;
|
||||
|
||||
@IsString()
|
||||
Value!: string;
|
||||
}
|
||||
|
||||
/** CBE → us, POST /cbe/query (AAFDA spec §2.5). Field names are CBE's, Pascal_Snake. */
|
||||
export class CbeQueryRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Destination_Api_Name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
End_To_End_Txn_Id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Bill_Id!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [AdditionalFieldDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AdditionalFieldDto)
|
||||
Additional_Fields?: AdditionalFieldDto[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Us → CBE, POST /cbe/query response (AAFDA spec §2.8, shape mirrored from the reference
|
||||
* implementation pending the Q1 sample files). Empty-string fields are deliberate — the
|
||||
* reference sends the full envelope with blanks rather than omitting keys.
|
||||
*/
|
||||
export class CbeQueryResponseDto {
|
||||
Destination_Api_Name!: string;
|
||||
End_To_End_Txn_Id!: string;
|
||||
Bill_Id!: string;
|
||||
Total_Amount!: string;
|
||||
Penalty_Amount!: string;
|
||||
Bill_Amount!: string;
|
||||
First_Name!: string;
|
||||
Last_Name!: string;
|
||||
Full_Name!: string;
|
||||
Payment_Reason!: string;
|
||||
Tin_Number!: string;
|
||||
Credit_Acct_Number!: string;
|
||||
Transaction_Type!: string;
|
||||
Timestamp!: string;
|
||||
Status!: string;
|
||||
Response_Code!: string;
|
||||
Response_Description!: string;
|
||||
Additional_Fields!: { Key: string; Value: string }[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
/** CBE → us, POST /cbe/oauth/token (AAFDA spec §1.5). Field names are CBE's, snake_case. */
|
||||
export class TokenRequestDto {
|
||||
@ApiProperty({ example: "client_credentials" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
grant_type!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
client_id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
client_secret!: string;
|
||||
|
||||
@ApiProperty({ example: "Unified_Outgoing" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
scope!: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Us → CBE, POST /cbe/oauth/token response (AAFDA spec §1.7). */
|
||||
export class TokenResponseDto {
|
||||
token_type!: string;
|
||||
access_token!: string;
|
||||
expires_in!: number;
|
||||
scope!: string;
|
||||
/** Unix seconds at issue time. */
|
||||
consented_on!: number;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
|
||||
export type CbeOperation = "QUERY" | "PAYMENT";
|
||||
export type CbeTradeStatus = "PENDING" | "SUCCESS" | "FAILED";
|
||||
/** Drives the same-End_To_End_Txn_Id retry policy (plan §6.5): BUSINESS is final, TRANSIENT retryable. */
|
||||
export type CbeFailureClass = "BUSINESS" | "TRANSIENT";
|
||||
|
||||
/**
|
||||
* CBE-protocol-level audit and idempotency ledger (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.2).
|
||||
* Separate from payment_intent because it tracks CBE's transaction identity — not ours — and
|
||||
* must retain the exact response body we returned so a retry replays it byte-for-byte.
|
||||
*
|
||||
* The UNIQUE (end_to_end_txn_id, operation) and partial-unique cbe_txn_ref indexes live in the
|
||||
* CreateCbeBillOperation migration.
|
||||
*/
|
||||
@Entity({ name: "cbe_bill_operation" })
|
||||
@Index("idx_cbe_bill_operation_bill", ["billId", "operation"])
|
||||
export class CbeBillOperation extends BaseEntity {
|
||||
@Column({ name: "operation", type: "varchar", length: 16 })
|
||||
operation!: CbeOperation;
|
||||
|
||||
/** Bill_Id exactly as received from CBE. */
|
||||
@Column({ name: "bill_id", type: "varchar", length: 32 })
|
||||
billId!: string;
|
||||
|
||||
/** CBE's per-attempt id — the idempotency key of the protocol. */
|
||||
@Column({ name: "end_to_end_txn_id", type: "varchar", length: 128 })
|
||||
endToEndTxnId!: string;
|
||||
|
||||
/** CBE core-banking reference; set on PAYMENT. */
|
||||
@Column({ name: "cbe_txn_ref", type: "varchar", length: 128, nullable: true })
|
||||
cbeTxnRef?: string | null;
|
||||
|
||||
/** Echoed back in every response. */
|
||||
@Column({
|
||||
name: "destination_api_name",
|
||||
type: "varchar",
|
||||
length: 64,
|
||||
nullable: true,
|
||||
})
|
||||
destinationApiName?: string | null;
|
||||
|
||||
/** Our payment_intent.id once the bill resolved to an intent. Soft reference, no FK. */
|
||||
@Column({ name: "intent_id", type: "uuid", nullable: true })
|
||||
intentId?: string | null;
|
||||
|
||||
@Column({ name: "trade_status", type: "varchar", length: 16 })
|
||||
tradeStatus!: CbeTradeStatus;
|
||||
|
||||
@Column({
|
||||
name: "failure_class",
|
||||
type: "varchar",
|
||||
length: 16,
|
||||
nullable: true,
|
||||
})
|
||||
failureClass?: CbeFailureClass | null;
|
||||
|
||||
@Column({ name: "response_code", type: "varchar", length: 8, nullable: true })
|
||||
responseCode?: string | null;
|
||||
|
||||
@Column({ name: "response_description", type: "text", nullable: true })
|
||||
responseDescription?: string | null;
|
||||
|
||||
/** Raw inbound body, verbatim. */
|
||||
@Column({ name: "request_payload", type: "jsonb" })
|
||||
requestPayload!: Record<string, unknown>;
|
||||
|
||||
/** EXACT body we returned — replayed verbatim when CBE retries a settled End_To_End_Txn_Id. */
|
||||
@Column({ name: "response_payload", type: "jsonb", nullable: true })
|
||||
responsePayload?: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CbeFailureClass } from "../entities/cbe-bill-operation.entity";
|
||||
|
||||
/**
|
||||
* A CBE business/transport outcome we detected ourselves. `failureClass` drives the §6.5
|
||||
* same-End_To_End_Txn_Id retry policy: BUSINESS outcomes are final (retrying cannot change
|
||||
* the answer), TRANSIENT ones (our 5xx, domain app unreachable) may be retried by CBE.
|
||||
*/
|
||||
export class CbeBillError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly failureClass: CbeFailureClass,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CbeBillError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every failure maps to Response_Code "3" — the AAFDA spec (§2.10, §3.10) defines only
|
||||
* 0 (success), 1 (auth), 3 (business); only the description is specific (plan §6.6).
|
||||
*/
|
||||
export function toCbeFailure(err: unknown): {
|
||||
description: string;
|
||||
failureClass: CbeFailureClass;
|
||||
} {
|
||||
if (err instanceof CbeBillError) {
|
||||
return { description: err.message, failureClass: err.failureClass };
|
||||
}
|
||||
return { description: "Internal server error.", failureClass: "TRANSIENT" };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CbePaymentRequestDto } from "../dto/cbe-payment-request.dto";
|
||||
import { CbePaymentResponseDto } from "../dto/cbe-payment-response.dto";
|
||||
|
||||
export function mapPaymentSuccess(
|
||||
request: CbePaymentRequestDto,
|
||||
destinationTxnRef: string,
|
||||
): CbePaymentResponseDto {
|
||||
return {
|
||||
Destination_Api_Name: request.Destination_Api_Name,
|
||||
End_To_End_Txn_Id: request.End_To_End_Txn_Id,
|
||||
Cbe_Txn_Ref: request.Cbe_Txn_Ref,
|
||||
Destination_Txn_Ref: destinationTxnRef,
|
||||
Status: "SUCCESS",
|
||||
Response_Code: "0",
|
||||
Response_Description: "Success",
|
||||
Additional_Fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPaymentFailure(
|
||||
request: CbePaymentRequestDto,
|
||||
description: string,
|
||||
): CbePaymentResponseDto {
|
||||
return {
|
||||
Destination_Api_Name: request.Destination_Api_Name,
|
||||
End_To_End_Txn_Id: request.End_To_End_Txn_Id,
|
||||
Cbe_Txn_Ref: request.Cbe_Txn_Ref,
|
||||
Destination_Txn_Ref: "",
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Description: description,
|
||||
Additional_Fields: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { CbeQueryRequestDto } from "../dto/cbe-query-request.dto";
|
||||
import { CbeQueryResponseDto } from "../dto/cbe-query-response.dto";
|
||||
|
||||
export function mapQuerySuccess(
|
||||
request: CbeQueryRequestDto,
|
||||
input: { amountMajor: number; fullName: string; paymentReason: string },
|
||||
): CbeQueryResponseDto {
|
||||
const amount = input.amountMajor.toFixed(2);
|
||||
return {
|
||||
Destination_Api_Name: request.Destination_Api_Name,
|
||||
End_To_End_Txn_Id: request.End_To_End_Txn_Id,
|
||||
Bill_Id: request.Bill_Id,
|
||||
Total_Amount: amount,
|
||||
Penalty_Amount: "0.00",
|
||||
Bill_Amount: amount,
|
||||
First_Name: "",
|
||||
Last_Name: "",
|
||||
Full_Name: input.fullName,
|
||||
Payment_Reason: input.paymentReason,
|
||||
Tin_Number: "",
|
||||
Credit_Acct_Number: "",
|
||||
Transaction_Type: "",
|
||||
Timestamp: new Date().toISOString(),
|
||||
Status: "SUCCESS",
|
||||
Response_Code: "0",
|
||||
Response_Description: "Success",
|
||||
Additional_Fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapQueryFailure(
|
||||
request: CbeQueryRequestDto,
|
||||
description: string,
|
||||
): CbeQueryResponseDto {
|
||||
return {
|
||||
Destination_Api_Name: request.Destination_Api_Name,
|
||||
End_To_End_Txn_Id: request.End_To_End_Txn_Id,
|
||||
Bill_Id: request.Bill_Id,
|
||||
Total_Amount: "",
|
||||
Penalty_Amount: "",
|
||||
Bill_Amount: "",
|
||||
First_Name: "",
|
||||
Last_Name: "",
|
||||
Full_Name: "",
|
||||
Payment_Reason: "",
|
||||
Tin_Number: "",
|
||||
Credit_Acct_Number: "",
|
||||
Transaction_Type: "",
|
||||
Timestamp: new Date().toISOString(),
|
||||
Status: "FAILED",
|
||||
Response_Code: "3",
|
||||
Response_Description: description,
|
||||
Additional_Fields: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DataSource } from "typeorm";
|
||||
import { BillReferenceService } from "./bill-reference.service";
|
||||
|
||||
describe("BillReferenceService", () => {
|
||||
const dataSource = {
|
||||
query: jest.fn().mockResolvedValue([{ nextval: "10000001" }]),
|
||||
} as unknown as DataSource;
|
||||
const service = new BillReferenceService(dataSource);
|
||||
|
||||
it("generates a 12-digit numeric reference that validates", async () => {
|
||||
const ref = await service.generate();
|
||||
expect(ref).toMatch(/^\d{12}$/);
|
||||
expect(ref.startsWith("00010000001")).toBe(true);
|
||||
expect(service.isValid(ref)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a single mistyped digit", async () => {
|
||||
const ref = await service.generate();
|
||||
const flipped =
|
||||
ref.slice(0, 5) + ((Number(ref[5]) + 1) % 10) + ref.slice(6);
|
||||
expect(service.isValid(flipped)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects adjacent transpositions", async () => {
|
||||
const ref = await service.generate();
|
||||
// Transpose the last two differing adjacent body digits.
|
||||
const digits = ref.split("");
|
||||
const i = digits.findIndex((d, idx) => idx < 11 && d !== digits[idx + 1]);
|
||||
[digits[i], digits[i + 1]] = [digits[i + 1], digits[i]];
|
||||
expect(service.isValid(digits.join(""))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects wrong length and non-numeric input", () => {
|
||||
expect(service.isValid("12345")).toBe(false);
|
||||
expect(service.isValid("00045123389A")).toBe(false);
|
||||
expect(service.isValid("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
/**
|
||||
* CBE_BILL bill reference numbers (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §5).
|
||||
*
|
||||
* 12 numeric digits: an 11-digit Postgres-sequence value, zero-padded, plus a trailing Luhn
|
||||
* check digit. Numeric-only so it is typeable on any USSD keypad; the check digit rejects
|
||||
* most single-digit typos and adjacent transpositions before any DB lookup; sequence-backed
|
||||
* so uniqueness is guaranteed without a collision-retry loop.
|
||||
*
|
||||
* Subject to Phase 0-Q2 — if CBE imposes their own length/charset constraint, theirs wins.
|
||||
*/
|
||||
const SEQUENCE = "edr_payment.cbe_bill_reference_seq";
|
||||
const TOTAL_LENGTH = 12;
|
||||
|
||||
@Injectable()
|
||||
export class BillReferenceService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async generate(): Promise<string> {
|
||||
const rows: [{ nextval: string }] = await this.dataSource.query(
|
||||
`SELECT nextval('${SEQUENCE}')`,
|
||||
);
|
||||
const body = rows[0].nextval.padStart(TOTAL_LENGTH - 1, "0");
|
||||
return body + luhnCheckDigit(body);
|
||||
}
|
||||
|
||||
/** Format + check-digit validation — the cheap reject before any DB hit. */
|
||||
isValid(billReference: string): boolean {
|
||||
if (!/^\d+$/.test(billReference) || billReference.length !== TOTAL_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
const body = billReference.slice(0, -1);
|
||||
return luhnCheckDigit(body) === billReference.slice(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Standard Luhn check digit over a numeric string. */
|
||||
function luhnCheckDigit(digits: string): string {
|
||||
let sum = 0;
|
||||
// Rightmost body digit is doubled (it sits next to the check digit position).
|
||||
for (let i = 0; i < digits.length; i++) {
|
||||
let d = Number(digits[digits.length - 1 - i]);
|
||||
if (i % 2 === 0) {
|
||||
d *= 2;
|
||||
if (d > 9) d -= 9;
|
||||
}
|
||||
sum += d;
|
||||
}
|
||||
return String((10 - (sum % 10)) % 10);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
@@ -101,6 +102,25 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
idempotencyKey?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Payer full name snapshot (CBE_BILL: fallback Full_Name for /cbe/query when the " +
|
||||
"domain app is unreachable)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
payerName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Intent expiry, ISO-8601 (CBE_BILL: the booking's own payment deadline — never a " +
|
||||
"provider session TTL)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class IntentReferenceQueryDto {
|
||||
|
||||
@@ -109,6 +109,23 @@ export class PaymentIntent extends BaseEntity {
|
||||
})
|
||||
idempotencyKey?: string | null;
|
||||
|
||||
/**
|
||||
* CBE_BILL only: the short numeric Bill_Id the customer types at a CBE channel
|
||||
* (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §5). Null for every other provider.
|
||||
*/
|
||||
@Column({
|
||||
name: "bill_reference",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
nullable: true,
|
||||
unique: true,
|
||||
})
|
||||
billReference?: string | null;
|
||||
|
||||
/** Payer full name snapshot — fallback for CBE /cbe/query Full_Name when bill-query is down. */
|
||||
@Column({ name: "payer_name", type: "varchar", length: 128, nullable: true })
|
||||
payerName?: string | null;
|
||||
|
||||
@Column({ name: "expires_at", type: "timestamptz", nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ProvidersModule } from "../providers/providers.module";
|
||||
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
import { BillReferenceService } from "./bill-reference.service";
|
||||
import { IntentsController } from "./intents.controller";
|
||||
import { IntentsRepository } from "./intents.repository";
|
||||
import { IntentsService } from "./intents.service";
|
||||
@@ -15,7 +16,7 @@ import { IntentsService } from "./intents.service";
|
||||
ProvidersModule,
|
||||
],
|
||||
controllers: [IntentsController],
|
||||
providers: [IntentsService, IntentsRepository],
|
||||
exports: [IntentsService, IntentsRepository],
|
||||
providers: [IntentsService, IntentsRepository, BillReferenceService],
|
||||
exports: [IntentsService, IntentsRepository, BillReferenceService],
|
||||
})
|
||||
export class IntentsModule {}
|
||||
|
||||
@@ -70,6 +70,13 @@ export class IntentsRepository extends BaseRepository<PaymentIntent> {
|
||||
});
|
||||
}
|
||||
|
||||
/** CBE_BILL: resolve the intent behind a Bill_Id presented by CBE. */
|
||||
async findByBillReference(
|
||||
billReference: string,
|
||||
): Promise<PaymentIntent | null> {
|
||||
return this.repository.findOne({ where: { billReference } });
|
||||
}
|
||||
|
||||
async findByMerchantOrderId(
|
||||
merchantOrderId: string,
|
||||
): Promise<PaymentIntent | null> {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
ProviderPaymentStatus,
|
||||
} from "@edr/types";
|
||||
import { CacBankProvider } from "@edr/payment-providers";
|
||||
import { IntentsService } from "./intents.service";
|
||||
import { IntentsRepository } from "./intents.repository";
|
||||
import { BillReferenceService } from "./bill-reference.service";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
|
||||
/**
|
||||
* CBE_BILL regression tests for plan D5 (docs/cbe/CBE_IMPLEMENTATION_PLAN.md): the provider is
|
||||
* deliberately absent from PAYMENT_PROVIDER_MAP, so the pull-side refresh must return the
|
||||
* cached intent untouched instead of calling a provider. This is load-bearing — a stub
|
||||
* provider entry would make the reconciliation sweep expire live CBE bills.
|
||||
*/
|
||||
describe("IntentsService CBE_BILL", () => {
|
||||
const providers = new Map();
|
||||
let repository: jest.Mocked<
|
||||
Pick<
|
||||
IntentsRepository,
|
||||
"create" | "findById" | "findByIdempotencyKey" | "update"
|
||||
>
|
||||
>;
|
||||
let billReferenceService: { generate: jest.Mock };
|
||||
let service: IntentsService;
|
||||
|
||||
const request: InitiatePaymentRequest = {
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
referenceId: "booking-1",
|
||||
amountMinor: 1500,
|
||||
currency: "ETB",
|
||||
provider: ProviderMethod.CBE_BILL,
|
||||
payerName: "Abebe Kebede",
|
||||
expiresAt: "2026-08-01T12:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
create: jest.fn(async (data) => ({ id: "intent-1", ...data })),
|
||||
findById: jest.fn(),
|
||||
findByIdempotencyKey: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
} as never;
|
||||
billReferenceService = {
|
||||
generate: jest.fn().mockResolvedValue("000100000015"),
|
||||
};
|
||||
service = new IntentsService(
|
||||
repository as unknown as IntentsRepository,
|
||||
{} as DataSource,
|
||||
providers as never,
|
||||
{} as CacBankProvider,
|
||||
billReferenceService as unknown as BillReferenceService,
|
||||
);
|
||||
});
|
||||
|
||||
it("initiates without a provider session: REQUIRES_ACTION + SHOW_BILL_REFERENCE", async () => {
|
||||
const snapshot = await service.initiate(request);
|
||||
|
||||
expect(snapshot.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
|
||||
expect(snapshot.billReference).toBe("000100000015");
|
||||
expect(snapshot.clientAction).toMatchObject({
|
||||
type: "SHOW_BILL_REFERENCE",
|
||||
billReference: "000100000015",
|
||||
});
|
||||
// The booking's own deadline, not a provider-session TTL (plan §6.4).
|
||||
expect(snapshot.expiresAt).toBe("2026-08-01T12:00:00.000Z");
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
billReference: "000100000015",
|
||||
payerName: "Abebe Kebede",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-ETB currency (plan D8)", async () => {
|
||||
await expect(
|
||||
service.initiate({ ...request, currency: "DJF" }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("getIntent leaves a stale CBE_BILL intent untouched (no provider in map — plan D5)", async () => {
|
||||
const intent = {
|
||||
id: "intent-1",
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
referenceId: "booking-1",
|
||||
merchantOrderId: "PSG-x",
|
||||
provider: ProviderMethod.CBE_BILL,
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
amountMinor: 1500,
|
||||
currency: "ETB",
|
||||
billReference: "000100000015",
|
||||
// Stale enough that a mapped provider WOULD be queried.
|
||||
updatedAt: new Date(Date.now() - 60_000),
|
||||
} as unknown as PaymentIntent;
|
||||
repository.findById.mockResolvedValue(intent);
|
||||
const applySpy = jest.spyOn(service, "applyProviderResult");
|
||||
|
||||
const snapshot = await service.getIntent("intent-1");
|
||||
|
||||
expect(snapshot.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
|
||||
expect(applySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TERMINAL_INTENT_STATUSES,
|
||||
} from "./entities/payment-intent.entity";
|
||||
import { IntentsRepository } from "./intents.repository";
|
||||
import { BillReferenceService } from "./bill-reference.service";
|
||||
|
||||
/** Don't hit the provider again if the intent was refreshed this recently. */
|
||||
const REFRESH_MIN_AGE_MS = 5_000;
|
||||
@@ -69,6 +70,7 @@ export class IntentsService {
|
||||
@Inject(PAYMENT_PROVIDER_MAP)
|
||||
private readonly providers: PaymentProviderMap,
|
||||
private readonly cacBankProvider: CacBankProvider,
|
||||
private readonly billReferenceService: BillReferenceService,
|
||||
) {}
|
||||
|
||||
/* ------------------------------------------------------------------ initiate */
|
||||
@@ -85,6 +87,12 @@ export class IntentsService {
|
||||
if (byKey) return this.toSnapshot(byKey);
|
||||
}
|
||||
|
||||
// CBE_BILL is inbound-only: there is no provider session to open and deliberately no entry
|
||||
// in PAYMENT_PROVIDER_MAP (plan D5 — the sweep and refreshIfStale must no-op on it).
|
||||
if (request.provider === ProviderMethod.CBE_BILL) {
|
||||
return this.initiateCbeBill(request);
|
||||
}
|
||||
|
||||
// Free method changes: no reuse/supersede. Every initiate opens a fresh intent, so a booking
|
||||
// may accumulate many intents (each method attempt is its own row). The `idempotencyKey` check
|
||||
// above still collapses exact duplicate submissions (e.g. a double-click). Confirm-once is
|
||||
@@ -140,6 +148,54 @@ export class IntentsService {
|
||||
return this.toSnapshot(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md). Intent-first: the bill
|
||||
* reference is created here, before CBE ever sees the bill; settlement arrives later through
|
||||
* the inbound /cbe/payment endpoint and the unchanged applyProviderResult() state machine.
|
||||
*/
|
||||
private async initiateCbeBill(
|
||||
request: InitiatePaymentRequest,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
// D8: CBE settles ETB only. The domain app must price/charge the order in ETB.
|
||||
if (request.currency !== "ETB") {
|
||||
throw new BadRequestException(
|
||||
`CBE_BILL supports ETB only (got ${request.currency})`,
|
||||
);
|
||||
}
|
||||
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const billReference = await this.billReferenceService.generate();
|
||||
// expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider
|
||||
// session TTL (plan §6.4: a short TTL would make the sweep cancel the booking within the hour).
|
||||
const expiresAt = request.expiresAt ? new Date(request.expiresAt) : null;
|
||||
|
||||
const intent = await this.intentsRepository.create({
|
||||
service: request.service,
|
||||
referenceType: request.referenceType,
|
||||
referenceId: request.referenceId,
|
||||
merchantOrderId,
|
||||
provider: request.provider,
|
||||
amountMinor: request.amountMinor,
|
||||
currency: request.currency,
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
clientAction: {
|
||||
type: "SHOW_BILL_REFERENCE",
|
||||
billReference,
|
||||
instructions:
|
||||
"Pay this bill at any CBE branch, CBE Birr app, mobile banking or USSD.",
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
},
|
||||
idempotencyKey: request.idempotencyKey ?? null,
|
||||
expiresAt,
|
||||
billReference,
|
||||
payerName: request.payerName ?? null,
|
||||
});
|
||||
this.logger.log(
|
||||
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${billReference})`,
|
||||
);
|
||||
return this.toSnapshot(intent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ confirm (OTP providers) */
|
||||
|
||||
async confirm(
|
||||
@@ -626,6 +682,7 @@ export class IntentsService {
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
expiresAt: intent.expiresAt?.toISOString(),
|
||||
billReference: intent.billReference ?? undefined,
|
||||
providerResponse: intent.rawInitiation ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user