feat: (payment) add eBirr as synchronous API_PURCHASE wallet debit

This commit is contained in:
Abubeker Yasin
2026-08-10 11:36:05 +03:00
parent 78d69eb02b
commit 6471c3511a
19 changed files with 1591 additions and 380 deletions

View File

@@ -7,7 +7,7 @@ import {
ProviderMethod,
ProviderPaymentStatus,
} from "@edr/types";
import { CacBankProvider } from "@edr/payment-providers";
import { CacBankProvider, EBirrProvider } from "@edr/payment-providers";
import { IntentsService } from "./intents.service";
import { IntentsRepository } from "./intents.repository";
import { BillReferenceService } from "./bill-reference.service";
@@ -61,6 +61,7 @@ describe("IntentsService CBE_BILL", () => {
{} as DataSource,
providers as never,
{} as CacBankProvider,
{} as EBirrProvider,
billReferenceService as unknown as BillReferenceService,
);
});

View File

@@ -6,7 +6,11 @@ import {
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers";
import {
createMerchantOrderId,
CacBankProvider,
EBirrProvider,
} from "@edr/payment-providers";
import {
ConfirmPaymentRequest,
InitiatePaymentRequest,
@@ -70,6 +74,9 @@ export class IntentsService {
@Inject(PAYMENT_PROVIDER_MAP)
private readonly providers: PaymentProviderMap,
private readonly cacBankProvider: CacBankProvider,
// eBirr's debit is awaited on the request path (see settleEBirrPurchase), so we need the
// concrete class for its non-interface `purchase()` — same pattern as CacBankProvider.
private readonly eBirrProvider: EBirrProvider,
private readonly billReferenceService: BillReferenceService,
) {}
@@ -78,7 +85,6 @@ export class IntentsService {
async initiate(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
if (request.idempotencyKey) {
const byKey = await this.intentsRepository.findByIdempotencyKey(
request.service,
@@ -105,17 +111,20 @@ export class IntentsService {
);
}
// Push-debit providers charge an account we must be told up front — there is no hosted page
// that could collect it later.
if (
request.provider === ProviderMethod.CAC_BANK &&
(request.provider === ProviderMethod.CAC_BANK ||
request.provider === ProviderMethod.EBIRR) &&
!request.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (customer mobile number) is required for CAC_BANK",
`payerAccount (customer mobile number) is required for ${request.provider}`,
);
}
const merchantOrderId = createMerchantOrderId();
const result = await provider.initiate({
const providerInput = {
merchantOrderId,
orderRef: request.orderRef ?? request.referenceId,
amountMinor: request.amountMinor,
@@ -125,7 +134,8 @@ export class IntentsService {
returnUrl: request.returnUrl,
redirectUrl: request.returnUrl,
failureUrl: request.failureUrl,
});
};
const result = await provider.initiate(providerInput);
const intent = await this.intentsRepository.create({
service: request.service,
@@ -145,9 +155,47 @@ export class IntentsService {
this.logger.log(
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`,
);
if (request.provider === ProviderMethod.EBIRR) {
return this.settleEBirrPurchase(intent.id, providerInput);
}
return this.toSnapshot(intent);
}
/**
* Issue the eBirr debit and hand back the settled intent.
*
* eBirr has no webhook: API_PURCHASE's response IS the settlement notification. So it is awaited
* here, on the request path, and the caller gets a terminal snapshot — the portal shows success
* or the real failure straight from the initiate response, with nothing to poll.
*
* The wait is bounded by EBIRR_PURCHASE_TIMEOUT_MS (45s), which must stay under the passenger
* API's 60s PAYMENT_API_HTTP_TIMEOUT_MS. A payer slower than that comes back PROCESSING and the
* intent keeps its AWAIT_PUSH client action, so the existing poll and the reconciliation sweep
* settle it as before. That fallback is rare but must not be removed: an unanswered purchase may
* still have moved money (vendor doc §10).
*
* purchase() does not throw — transport failures are already mapped to FAILED (never dispatched)
* or PROCESSING (sent, unanswered). The catch is for anything unforeseen: leaving the intent
* REQUIRES_ACTION also lands on the poll/sweep fallback, which is the safe direction.
*/
private async settleEBirrPurchase(
intentId: string,
providerInput: Parameters<EBirrProvider["purchase"]>[0],
): Promise<PaymentIntentSnapshot> {
try {
const status = await this.eBirrProvider.purchase(providerInput);
await this.applyProviderResult(intentId, status);
} catch (err) {
this.logger.error(
`eBirr purchase for intent ${intentId} (${providerInput.merchantOrderId}) could not be ` +
`settled: ${err instanceof Error ? err.message : err} — leaving it to the sweep`,
);
}
return this.snapshotOf(intentId);
}
/**
* 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

View File

@@ -0,0 +1,374 @@
import { of, throwError } from "rxjs";
import { AxiosError } from "axios";
import {
EBirrProvider,
normalizeEthiopianMsisdn,
ProviderPaymentStatus,
} from "@edr/payment-providers";
/**
* eBirr is a direct wallet debit over the ASM envelope (docs/ebirr/INTEGRATION.md), not the
* Alipay-style redirect gateway the pre-rewrite provider was written against. These pin the
* things that were wrong before and the things that are easy to "fix" back by mistake:
*
* - the wire shape must match the request verified by hand against testpayments.ebirr.com,
* including `payerInfo.accountNo` (NOT the doc's `subscriptionId`) and no signature field;
* - the amount reaches eBirr unscaled — the old code divided by 100 and would have charged
* 1/100th of every booking (same class of bug as cac-bank-amount.spec.ts);
* - `initiate()` must not touch the network: the blocking debit is `purchase()`;
* - a timeout must NOT be reported as FAILED — the money may have moved (vendor doc §10).
*/
describe("EBirrProvider", () => {
const config = {
get: (key: string) =>
({
"ebirr.baseUrl": "https://testpayments.ebirr.com",
"ebirr.merchantUid": "M1000003",
"ebirr.apiKey": "API-1234560",
"ebirr.apiUserId": "10000008",
"ebirr.paymentMethod": "MWALLET_ACCOUNT",
"ebirr.channelName": "WEB",
"ebirr.purchaseTimeoutMs": 45_000,
"ebirr.pushTtlMs": 180_000,
})[key],
};
const input = {
merchantOrderId: "EDR-ORDER-1",
orderRef: "EDR-20240001",
amountMinor: 1500.5,
currency: "ETB",
payerAccount: "+251923582676",
};
function build(response?: unknown) {
const post = jest.fn().mockReturnValue(of({ data: response, status: 200 }));
const provider = new EBirrProvider(config as never, { post } as never);
return { provider, post };
}
/**
* Captured verbatim from the live sandbox (08/08/2026) across four scenarios. Note that the
* three failure envelopes are NOT 2001 yet still carry the authoritative `params.state` — the
* reason the provider reads `state` regardless of `responseCode`.
*/
const approved = {
schemaVersion: "1.0",
timestamp: "2026-08-08T06:40:42Z",
responseId: "REQ-001-20260506114500",
responseCode: "2001",
errorCode: "0",
responseMsg: "RCS_SUCCESS",
params: {
referenceId: "holyffuot",
transactionId: "619",
orderId: "521",
issuerTransactionId: "10000991513",
txAmount: "1.00",
state: "APPROVED",
},
};
const declined = {
schemaVersion: "1.0",
timestamp: "2026-08-08T06:41:58Z",
responseId: "REQ-001-20260506114500",
responseCode: "5206",
errorCode: "E10205",
responseMsg: "Payment Failed (Invalid Credentials)",
params: {
referenceId: "hoflyffuot",
transactionId: "621",
orderId: "522",
txAmount: "1.00",
state: "DECLINED",
description: "Invalid Credentials",
},
};
/** The payer aborted the USSD prompt, or let it lapse — eBirr reports both identically. */
const userAborted = {
schemaVersion: "1.0",
timestamp: "2026-08-08T06:42:51Z",
responseId: "REQ-001-20260506114500",
responseCode: "5001",
errorCode: "4004",
responseMsg: "User Aborted",
params: {
referenceId: "hoflyffuofft",
transactionId: "622",
orderId: "523",
txAmount: "1.00",
state: "TIMEOUT",
description: "User Aborted",
},
};
describe("initiate", () => {
it("issues no HTTP call and returns AWAIT_PUSH", async () => {
const { provider, post } = build();
const result = await provider.initiate(input);
expect(post).not.toHaveBeenCalled();
expect(result.clientAction).toEqual({
type: "AWAIT_PUSH",
message: expect.stringContaining("PIN"),
payerAccountMasked: "2519****2676",
});
expect(result.providerOrderId).toBe("EDR-ORDER-1");
});
it("rejects a missing payer account rather than charging nobody", async () => {
const { provider } = build();
await expect(
provider.initiate({ ...input, payerAccount: undefined }),
).rejects.toThrow(/payerAccount/);
});
it("never leaks the api key or the full MSISDN into the audit payload", async () => {
const { provider } = build();
const result = await provider.initiate(input);
const serialized = JSON.stringify(result.rawInitiation);
expect(serialized).not.toContain("API-1234560");
expect(serialized).not.toContain("251923582676");
});
});
describe("purchase", () => {
it("sends the ASM envelope verified against the live sandbox", async () => {
const { provider, post } = build(approved);
await provider.purchase(input);
const [url, body] = post.mock.calls[0];
expect(url).toBe("https://testpayments.ebirr.com/asm");
expect(body).toMatchObject({
schemaVersion: "1.0",
channelName: "WEB",
serviceName: "API_PURCHASE",
serviceParams: {
merchantUid: "M1000003",
apiKey: "API-1234560",
apiUserId: "10000008",
paymentMethod: "MWALLET_ACCOUNT",
// `accountNo`, not the vendor doc's `subscriptionId`
payerInfo: { accountNo: "251923582676" },
transactionInfo: {
referenceId: "EDR-ORDER-1",
invoiceId: "EDR-20240001",
currency: "ETB",
},
},
});
// eBirr wants `YYYY-MM-DD HH:mm:ss`, not the epoch seconds Waafi's /asm takes.
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(body.requestId).toHaveLength(36);
// Nothing is signed — there is no shared secret in this integration.
expect(JSON.stringify(body)).not.toContain("sign");
});
it("sends the amount unscaled — 1500.50 ETB, not 15.005", async () => {
const { provider, post } = build(approved);
await provider.purchase(input);
expect(post.mock.calls[0][1].serviceParams.transactionInfo.amount).toBe(
1500.5,
);
});
it("maps an APPROVED verdict to SUCCEEDED with the provider txn id", async () => {
const { provider } = build(approved);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
expect(result.providerTxnId).toBe("619");
expect(result.failureCode).toBeUndefined();
});
it("maps the live DECLINED response to FAILED with the specific cause", async () => {
const { provider } = build(declined);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureCode).toBe("E10205");
// The specific cause, not the generic "Payment Failed (…)" wrapper.
expect(result.failureMessage).toBe("Invalid Credentials");
// eBirr issues a transaction id for failed attempts too — keep it for reconciliation.
expect(result.providerTxnId).toBe("621");
});
it("maps the live User-Aborted/TIMEOUT response to FAILED", async () => {
const { provider } = build(userAborted);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureCode).toBe("4004");
expect(result.failureMessage).toBe("User Aborted");
expect(result.providerTxnId).toBe("622");
});
it("never promotes a rejected envelope to SUCCEEDED, even if state says APPROVED", async () => {
const { provider } = build({
...declined,
params: { state: "APPROVED" },
});
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
});
it("falls back to the envelope when a rejection carries no params at all", async () => {
const { provider } = build({
schemaVersion: "1.0",
responseCode: "5001",
errorCode: "E10206",
responseMsg: "Failed to process request",
});
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureCode).toBe("E10206");
});
it("maps a timeout to PROCESSING, never FAILED — the money may have moved", async () => {
const post = jest
.fn()
.mockReturnValue(
throwError(() => new AxiosError("timeout of 45000ms exceeded")),
);
const provider = new EBirrProvider(config as never, { post } as never);
const result = await provider.purchase(input);
expect(result.status).toBe(ProviderPaymentStatus.PROCESSING);
});
/**
* The dispatch/no-dispatch split. A request that never left the process cannot have moved
* money and leaves NO transaction for API_GETTRANINFO to find — reporting it as PROCESSING
* stranded the payer on "check your phone" for a push that was never sent, until expiry.
* A request that did go out stays PROCESSING no matter how it broke (vendor doc §10).
*/
function purchaseWithTransportError(
message: string,
code?: string,
): Promise<{ status: ProviderPaymentStatus; failureMessage?: string }> {
const err = new AxiosError(message);
if (code) err.code = code;
const post = jest.fn().mockReturnValue(throwError(() => err));
return new EBirrProvider(
config as never,
{
post,
} as never,
).purchase(input);
}
it.each([
// Node's TCP connect timeout — the SYN was never answered (blocked port / no whitelist).
["connect ETIMEDOUT 197.156.83.125:443", "ETIMEDOUT"],
["connect ECONNREFUSED 10.0.0.1:443", "ECONNREFUSED"],
["getaddrinfo ENOTFOUND testpayments.ebirr.com", "ENOTFOUND"],
["Invalid URL", "ERR_INVALID_URL"],
["certificate has expired", "CERT_HAS_EXPIRED"],
])("fails fast on %s — it never reached eBirr", async (message, code) => {
const result = await purchaseWithTransportError(message, code);
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
// A cause the payer can act on, not the generic "please try again".
expect(result.failureMessage).toMatch(/could not reach ebirr/i);
});
it.each([
// Axios's own read timeout reported with the ETIMEDOUT code (clarifyTimeoutError) — the
// request WAS sent, so this must not be confused with a connect timeout.
["timeout of 45000ms exceeded", "ETIMEDOUT"],
// Fired after the body went out; the debit may well have been processed.
["socket hang up", "ECONNRESET"],
["aborted", "ECONNABORTED"],
["something nobody anticipated", undefined],
])(
"keeps %s as PROCESSING — it may have been dispatched",
async (message, code) => {
const result = await purchaseWithTransportError(message, code);
expect(result.status).toBe(ProviderPaymentStatus.PROCESSING);
},
);
});
describe("queryStatus", () => {
it("looks the transaction up by referenceId via API_GETTRANINFO", async () => {
const { provider, post } = build({
schemaVersion: "1.0",
responseCode: "2001",
errorCode: "0",
responseMsg: "RCS_SUCCESS",
params: { status: "Approved", transactionId: "126895" },
});
const result = await provider.queryStatus("EDR-ORDER-1");
expect(post.mock.calls[0][1]).toMatchObject({
serviceName: "API_GETTRANINFO",
serviceParams: { referenceId: "EDR-ORDER-1" },
});
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
expect(result.providerTxnId).toBe("126895");
});
it("treats an unknown transaction as REQUIRES_ACTION, not PROCESSING", async () => {
const { provider } = build({
schemaVersion: "1.0",
responseCode: "5001",
errorCode: "E10206",
responseMsg: "Failed to get transaction info",
});
const result = await provider.queryStatus("EDR-ORDER-1");
// The payer simply hasn't answered the prompt yet. Persisting a PROCESSING guess would
// let the sweep strand them on a push they never touched.
expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
});
it("honours a terminal state on a rejected envelope instead of hanging the payer", async () => {
// The purchase endpoint returns rejected envelopes that still carry a verdict
// (5206/DECLINED, 5001/TIMEOUT). If the query endpoint does the same, reading only the
// envelope would report REQUIRES_ACTION and leave the payer waiting until expiry.
const { provider } = build(userAborted);
const result = await provider.queryStatus("EDR-ORDER-1");
expect(result.status).toBe(ProviderPaymentStatus.FAILED);
expect(result.failureMessage).toBe("User Aborted");
});
});
describe("normalizeEthiopianMsisdn", () => {
it.each([
["+251923582676", "251923582676"],
["251923582676", "251923582676"],
["0923582676", "251923582676"],
["923582676", "251923582676"],
["+251 92 358 2676", "251923582676"],
["0712345678", "251712345678"],
])("normalises %s to %s", (raw, expected) => {
expect(normalizeEthiopianMsisdn(raw)).toBe(expected);
});
it.each(["", "not-a-number", "0812345678", "09123", "0912345678901"])(
"rejects %s rather than prompting a stranger's handset",
(raw) => {
expect(() => normalizeEthiopianMsisdn(raw)).toThrow();
},
);
});
});

View File

@@ -1,33 +0,0 @@
import { Injectable } from "@nestjs/common";
import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers";
import { WebhookProcessorService } from "../webhook-processor.service";
@Injectable()
export class EBirrWebhookService {
constructor(
private readonly provider: EBirrProvider,
private readonly processor: WebhookProcessorService,
) {}
async handle(payload: EBirrWebhookPayload): Promise<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
await this.processor.process({
provider: this.provider.method,
externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`,
merchantOrderId: payload.orderNo,
providerTxnId: payload.tradeNo,
signatureValid,
rawStatus: payload.tradeStatus,
payload: payload as unknown as Record<string, unknown>,
result: {
status: mapped,
providerTxnId: payload.tradeNo,
failureCode: payload.tradeStatus,
},
});
}
}

View File

@@ -14,14 +14,12 @@ import {
CardWebhookPayload,
CbeBirrWebhookPayload,
DMoneyWebhookPayload,
EBirrWebhookPayload,
TelebirrWebhookPayload,
WaafiWebhookHeaders,
WaafiWebhookPayload,
} from "@edr/payment-providers";
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
import { CardWebhookService } from "./handlers/card-webhook.service";
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
@@ -40,7 +38,6 @@ export class WebhooksController {
constructor(
private readonly telebirr: TelebirrWebhookService,
private readonly cbeBirr: CbeBirrWebhookService,
private readonly eBirr: EBirrWebhookService,
private readonly card: CardWebhookService,
private readonly waafi: WaafiWebhookService,
private readonly dMoney: DMoneyWebhookService,
@@ -89,17 +86,9 @@ export class WebhooksController {
return { success: true };
}
@Post("ebirr")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" })
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
try {
await this.eBirr.handle(payload);
} catch (err) {
this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`);
}
return { code: "0000", message: "success" };
}
// No eBirr route by design: EbirrPay's API-payment flow has no callback. The API_PURCHASE
// response is the settlement notification, and API_GETTRANINFO is the authority for a missing
// or ambiguous one — see docs/ebirr/INTEGRATION.md.
@Post("card")
@HttpCode(HttpStatus.OK)

View File

@@ -8,7 +8,6 @@ import { WebhookProcessorService } from "./webhook-processor.service";
import { WebhooksController } from "./webhooks.controller";
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
import { CardWebhookService } from "./handlers/card-webhook.service";
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
@@ -25,7 +24,6 @@ import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
WebhookProcessorService,
TelebirrWebhookService,
CbeBirrWebhookService,
EBirrWebhookService,
CardWebhookService,
WaafiWebhookService,
DMoneyWebhookService,