Merge branch 'staging' into dev-to-staging

This commit is contained in:
Nathnael Wondisha
2026-08-01 10:27:47 +03:00
committed by GitHub
56 changed files with 2350 additions and 318 deletions

View File

@@ -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",

View File

@@ -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,

View 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(/\/$/, ""),
}));

View File

@@ -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"`,
);
}
}

View File

@@ -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"`,
);
}
}

View File

@@ -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");
}
}
}

View 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");
}
}
}

View File

@@ -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);
}
}

View 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 {}

View File

@@ -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" },
});
}
}

View 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>,
});
}
}

View File

@@ -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.",
});
}
}

View File

@@ -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[];
}

View File

@@ -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 }[];
}

View File

@@ -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[];
}

View File

@@ -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 }[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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" };
}

View File

@@ -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: [],
};
}

View File

@@ -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: [],
};
}

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 {}

View File

@@ -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> {

View File

@@ -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();
});
});

View File

@@ -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,
};
}