mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: (payment) add eBirr as synchronous API_PURCHASE wallet debit
This commit is contained in:
@@ -60,6 +60,15 @@ export type {
|
||||
WaafiGetTranInfoResponse,
|
||||
} from './providers/waafi/waafi.types';
|
||||
|
||||
// EbirrPay API-payment request/response types (same ASM envelope as Waafi — see ebirr.types.ts)
|
||||
export type {
|
||||
EbirrState,
|
||||
EbirrPurchaseRequest,
|
||||
EbirrPurchaseResponse,
|
||||
EbirrGetTranInfoRequest,
|
||||
EbirrGetTranInfoResponse,
|
||||
} from './providers/ebirr/ebirr.types';
|
||||
|
||||
// CAC Bank request/response types
|
||||
export type {
|
||||
CacSigninRequest,
|
||||
@@ -76,7 +85,7 @@ export type {
|
||||
// Webhook payload types
|
||||
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
|
||||
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
|
||||
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
|
||||
// (no eBirr webhook type: EbirrPay has no callback — the API_PURCHASE response is the result)
|
||||
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
|
||||
export type {
|
||||
WaafiWebhookPayload,
|
||||
@@ -88,5 +97,8 @@ export type {
|
||||
} from './webhooks/waafi-webhook.types';
|
||||
export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types';
|
||||
|
||||
// Phone-number helpers
|
||||
export { normalizeEthiopianMsisdn, maskMsisdn } from './utils/msisdn';
|
||||
|
||||
// DI token for injecting all providers as an array (future multi-provider wiring)
|
||||
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
@@ -12,225 +12,568 @@ import {
|
||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import * as crypto from "node:crypto";
|
||||
import * as https from "node:https";
|
||||
import { maskMsisdn, normalizeEthiopianMsisdn } from "../../utils/msisdn";
|
||||
import {
|
||||
EbirrGetTranInfoRequest,
|
||||
EbirrGetTranInfoResponse,
|
||||
EbirrPurchaseRequest,
|
||||
EbirrPurchaseResponse,
|
||||
} from "./ebirr.types";
|
||||
|
||||
interface EBirrInitiateRequest {
|
||||
merchantCode: string;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
notifyUrl: string;
|
||||
returnUrl: string;
|
||||
timestamp: number;
|
||||
sign: string;
|
||||
}
|
||||
/** EbirrPay's "request processed" envelope code. Says nothing about the payment outcome. */
|
||||
const EBIRR_SUCCESS_CODE = "2001";
|
||||
/** Status queries are quick lookups — they must not inherit the long purchase timeout. */
|
||||
const EBIRR_QUERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface EBirrInitiateResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data?: {
|
||||
orderNo: string;
|
||||
payUrl: string;
|
||||
expireTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface EBirrQueryResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data?: {
|
||||
orderNo: string;
|
||||
tradeStatus: string;
|
||||
tradeNo?: string;
|
||||
totalAmount?: number;
|
||||
payTime?: number;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Transport errors that prove the request **never reached eBirr**: a bad URL, DNS, the TCP
|
||||
* connect and the TLS handshake all fail before a single byte of the body is written.
|
||||
*
|
||||
* This distinction is the whole reason the set exists. No debit can exist for a request that was
|
||||
* never sent, so there is nothing for `API_GETTRANINFO` to find — reporting these as PROCESSING
|
||||
* leaves the payer staring at "check your phone" for a prompt that was never pushed, until the
|
||||
* intent expires. They are a payment failure, and the payer should be told immediately.
|
||||
*
|
||||
* Deliberately an allowlist. Anything not named here — notably `ECONNRESET` and `ECONNABORTED`,
|
||||
* which can both fire *after* the body went out — falls through to PROCESSING, because vendor doc
|
||||
* §10 is explicit that an ambiguous send must be resolved by querying, never by assuming failure.
|
||||
*/
|
||||
const EBIRR_UNDISPATCHED_CODES = new Set([
|
||||
"ERR_INVALID_URL", // empty/misconfigured EBIRR_BASE_URL — no socket is ever opened
|
||||
"ENOTFOUND", // DNS: host does not resolve
|
||||
"EAI_AGAIN", // DNS: resolver timed out
|
||||
"ECONNREFUSED", // TCP: port closed
|
||||
"EHOSTUNREACH",
|
||||
"ENETUNREACH",
|
||||
"CERT_HAS_EXPIRED", // TLS: handshake fails before the request is written
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"ERR_TLS_CERT_ALTNAME_INVALID",
|
||||
]);
|
||||
|
||||
/**
|
||||
* EbirrPay — direct mobile-wallet debit (docs/ebirr/INTEGRATION.md).
|
||||
*
|
||||
* Unlike every other gateway in this package, eBirr has **no hosted page, no redirect and no
|
||||
* webhook**. `API_PURCHASE` pushes a PIN prompt to the payer's handset over USSD and holds the
|
||||
* HTTP connection open until they approve or decline it — the synchronous response *is* the
|
||||
* settlement notification.
|
||||
*
|
||||
* The charge is therefore split in two:
|
||||
*
|
||||
* - `initiate()` performs **no I/O**. It validates the payer account and builds the
|
||||
* `AWAIT_PUSH` client action used as the fallback when the debit outlives its timeout.
|
||||
* - `purchase()` issues the actual blocking debit. payment-api's IntentsService awaits it on
|
||||
* the request path (`settleEBirrPurchase`) so the payer gets the verdict in the initiate
|
||||
* response — there is no webhook, so this response *is* the settlement notification.
|
||||
*
|
||||
* The wait is bounded by `EBIRR_PURCHASE_TIMEOUT_MS` (45s), which has to stay under the chain's
|
||||
* own limits — the passenger API's 60s `PAYMENT_API_HTTP_TIMEOUT_MS` and nginx's 60s default
|
||||
* `proxy_read_timeout`. A payer slower than that yields PROCESSING and the intent falls back to
|
||||
* `AWAIT_PUSH` for the client poll; the reconciliation sweep settles it via `queryStatus`
|
||||
* (`API_GETTRANINFO`), which is the authority vendor doc §10 points at for missing or ambiguous
|
||||
* responses. That path is rare but must not be removed: the money may already have moved.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EBirrProvider implements PaymentProvider {
|
||||
export class EBirrProvider implements PaymentProvider, OnModuleInit {
|
||||
readonly method = ProviderMethod.EBIRR;
|
||||
private readonly logger = new Logger(EBirrProvider.name);
|
||||
private readonly httpsAgent: https.Agent;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
) {
|
||||
const insecure = this.config.get<boolean>("ebirr.insecureTls");
|
||||
if (insecure) {
|
||||
this.logger.warn(
|
||||
"EBIRR_INSECURE_TLS=true — TLS verification disabled for eBirr calls. DEV ONLY.",
|
||||
);
|
||||
}
|
||||
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
|
||||
}
|
||||
|
||||
/** Log the effective eBirr config once at startup (secrets masked) so misconfig is visible. */
|
||||
onModuleInit(): void {
|
||||
this.logger.log(
|
||||
`eBirr config resolved: ${JSON.stringify(this.effectiveConfig())}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Snapshot of every resolved eBirr env value; secret fields are masked, not printed raw. */
|
||||
private effectiveConfig(): Record<string, unknown> {
|
||||
return {
|
||||
EBIRR_BASE_URL: this.baseUrl || "(empty)",
|
||||
EBIRR_MERCHANT_UID: this.merchantUid || "(empty)",
|
||||
EBIRR_API_USER_ID: this.apiUserId || "(empty)",
|
||||
EBIRR_API_KEY: this.mask(this.apiKey),
|
||||
EBIRR_PAYMENT_METHOD: this.paymentMethod,
|
||||
EBIRR_CHANNEL_NAME: this.channelName,
|
||||
EBIRR_PURCHASE_TIMEOUT_MS: this.purchaseTimeoutMs,
|
||||
EBIRR_PUSH_TTL_MS: this.pushTtlMs,
|
||||
EBIRR_INSECURE_TLS:
|
||||
this.config.get<boolean>("ebirr.insecureTls") ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mask a secret to `set(len=N,…abcd)` / `(empty)` so presence & length are visible but not the value. */
|
||||
private mask(value: string): string {
|
||||
if (!value) return "(empty)";
|
||||
const tail = value.length > 4 ? value.slice(-4) : "";
|
||||
return `set(len=${value.length},…${tail})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the push debit. Deliberately does no network I/O — the charge itself is `purchase()`.
|
||||
*
|
||||
* `providerOrderId` is our own `merchantOrderId`: this flow mints no separate order id (the
|
||||
* `orderId` the vendor doc §5.4 mentions belongs to the HPP family we don't use), and
|
||||
* `queryStatus` looks the transaction up by `referenceId` anyway.
|
||||
*/
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
const amount = input.amountMinor / 100;
|
||||
const timestamp = Date.now();
|
||||
if (!input.payerAccount?.trim()) {
|
||||
throw new Error(
|
||||
"eBirr requires payerAccount (the customer's mobile-wallet number)",
|
||||
);
|
||||
}
|
||||
// Throws on a malformed number rather than pushing a PIN prompt to the wrong handset.
|
||||
const accountNo = normalizeEthiopianMsisdn(input.payerAccount);
|
||||
|
||||
const requestBody: EBirrInitiateRequest = {
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: input.merchantOrderId,
|
||||
amount,
|
||||
currency: input.currency,
|
||||
subject: `EDR Ticket`,
|
||||
body: `Order ${input.orderRef}`,
|
||||
notifyUrl: this.notifyUrl,
|
||||
// Per-transaction browser return target (each calling app has its own UI); config is fallback.
|
||||
returnUrl: input.returnUrl ?? this.returnUrl,
|
||||
timestamp,
|
||||
sign: this.signRequest({
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: input.merchantOrderId,
|
||||
amount,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<EBirrInitiateResponse>(
|
||||
`${this.baseUrl}/gateway/api/pay/create`,
|
||||
requestBody,
|
||||
this.logger.log(
|
||||
`eBirr initiate ref=${input.merchantOrderId} account=${maskMsisdn(accountNo)} ` +
|
||||
`currency=${input.currency} amount=${this.toAmount(input.amountMinor)} ` +
|
||||
`(amountMinorIn=${input.amountMinor})`,
|
||||
);
|
||||
|
||||
if (response.code !== "0000" || !response.data?.orderNo) {
|
||||
throw new Error(`eBirr initiate failed: ${response.message}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(response.data.expireTime);
|
||||
|
||||
return {
|
||||
providerOrderId: response.data.orderNo,
|
||||
clientAction: { type: "REDIRECT", url: response.data.payUrl },
|
||||
expiresAt,
|
||||
providerOrderId: input.merchantOrderId,
|
||||
clientAction: {
|
||||
type: "AWAIT_PUSH",
|
||||
message:
|
||||
"Check your phone and enter your eBirr PIN to approve the payment.",
|
||||
payerAccountMasked: maskMsisdn(accountNo),
|
||||
},
|
||||
expiresAt: new Date(Date.now() + this.pushTtlMs),
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
request: this.sanitizeRequest(
|
||||
this.buildPurchaseRequest(input, accountNo),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const timestamp = Date.now();
|
||||
const requestBody = {
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: merchantOrderId,
|
||||
timestamp,
|
||||
sign: this.signRequest({
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: merchantOrderId,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
/**
|
||||
* Issue the debit. Blocks for as long as the payer takes to enter their PIN, so it must be
|
||||
* called off the request path.
|
||||
*
|
||||
* Never throws: a transport failure or timeout is reported as PROCESSING, not FAILED. The money
|
||||
* may well have moved, and the vendor doc §10 is explicit that a timeout must be resolved by
|
||||
* querying, not by assuming failure and re-charging.
|
||||
*/
|
||||
async purchase(input: ProviderInitiationInput): Promise<ProviderStatus> {
|
||||
const accountNo = normalizeEthiopianMsisdn(input.payerAccount ?? "");
|
||||
const requestBody = this.buildPurchaseRequest(input, accountNo);
|
||||
const url = `${this.baseUrl}/asm`;
|
||||
|
||||
const response = await this.postJson<EBirrQueryResponse>(
|
||||
`${this.baseUrl}/gateway/api/pay/query`,
|
||||
requestBody,
|
||||
this.logger.log(
|
||||
`eBirr API_PURCHASE → ${url} ref=${input.merchantOrderId} ` +
|
||||
`account=${maskMsisdn(accountNo)} amount=${requestBody.serviceParams.transactionInfo.amount} ` +
|
||||
`${input.currency} (awaiting payer PIN, up to ${this.purchaseTimeoutMs}ms)`,
|
||||
);
|
||||
|
||||
if (response.code !== "0000" || !response.data) {
|
||||
throw new Error(`eBirr query failed: ${response.message}`);
|
||||
let response: EbirrPurchaseResponse;
|
||||
try {
|
||||
response = await this.postJson<EbirrPurchaseResponse>(
|
||||
url,
|
||||
requestBody,
|
||||
this.purchaseTimeoutMs,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const code = (err as { code?: string })?.code;
|
||||
|
||||
// Never sent: no debit exists, so there is nothing to reconcile and no reason to make the
|
||||
// payer wait. Fail it now with a cause they can act on.
|
||||
if (this.isUndispatched(err)) {
|
||||
this.logger.error(
|
||||
`eBirr API_PURCHASE ${input.merchantOrderId} was never dispatched (${code ?? "n/a"}: ` +
|
||||
`${message}) — no debit exists, failing the intent instead of awaiting reconciliation`,
|
||||
);
|
||||
return {
|
||||
status: ProviderPaymentStatus.FAILED,
|
||||
failureCode: code ?? "EBIRR_UNREACHABLE",
|
||||
failureMessage:
|
||||
"Could not reach eBirr — no payment was taken. Please try again.",
|
||||
rawResponse: { error: message, code, dispatched: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Sent but unanswered: unresolved, NOT failed. The sweep settles it via API_GETTRANINFO.
|
||||
this.logger.warn(
|
||||
`eBirr API_PURCHASE ${input.merchantOrderId} did not return a verdict ` +
|
||||
`(${message}) — leaving PROCESSING for reconciliation`,
|
||||
);
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
rawResponse: { error: message, code, dispatched: true },
|
||||
};
|
||||
}
|
||||
|
||||
const mapped = this.mapStatus(response.data.tradeStatus);
|
||||
const rawResponse = response as unknown as Record<string, unknown>;
|
||||
const state = response.params?.state;
|
||||
const mapped = this.resolveVerdict(response.responseCode, state);
|
||||
|
||||
if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
this.logger.error(
|
||||
`eBirr API_PURCHASE ${input.merchantOrderId} rejected: ` +
|
||||
`${response.responseCode}/${response.errorCode} ${response.responseMsg} ` +
|
||||
`state=${state ?? "n/a"} (txn=${response.params?.transactionId ?? "n/a"})`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`eBirr API_PURCHASE ${input.merchantOrderId} state=${state ?? "n/a"} → ${mapped} ` +
|
||||
`(txn=${response.params?.transactionId ?? "n/a"}, order=${response.params?.orderId ?? "n/a"})`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.data.tradeNo,
|
||||
// Present on rejected responses too (eBirr records the failed attempt), so this is
|
||||
// captured regardless of verdict — it is what reconciliation quotes back to eBirr.
|
||||
providerTxnId: response.params?.transactionId,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED
|
||||
? response.data.tradeStatus
|
||||
? response.errorCode || response.responseCode
|
||||
: undefined,
|
||||
failureMessage:
|
||||
mapped === ProviderPaymentStatus.FAILED
|
||||
? // `params.description` is the specific cause ("Invalid Credentials"); responseMsg is
|
||||
// the generic wrapper ("Payment Failed (Invalid Credentials)").
|
||||
(response.params?.description ?? response.responseMsg)
|
||||
: undefined,
|
||||
rawResponse,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Did this error happen *before* the request was written to the wire?
|
||||
*
|
||||
* Only errors we can prove were never dispatched may be reported as FAILED — everything else
|
||||
* has to stay PROCESSING, because a request eBirr received may have moved money.
|
||||
*/
|
||||
private isUndispatched(err: unknown): boolean {
|
||||
// An HTTP response came back — whatever its status, eBirr received the request.
|
||||
if (err instanceof AxiosError && err.response) return false;
|
||||
|
||||
const code = (err as { code?: string })?.code;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// ETIMEDOUT is ambiguous and has to be read from the message. Node reports a TCP connect
|
||||
// timeout as `connect ETIMEDOUT <ip>:<port>` — nothing was sent. Axios reports its own *read*
|
||||
// timeout with the same code when `clarifyTimeoutError` is set, but phrases it
|
||||
// "timeout of Nms exceeded" — that one was sent and is still in flight.
|
||||
if (code === "ETIMEDOUT") return message.startsWith("connect ");
|
||||
|
||||
return !!code && EBIRR_UNDISPATCHED_CODES.has(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the outcome from an eBirr response.
|
||||
*
|
||||
* Observed against the live sandbox: a *rejected* envelope still carries the authoritative
|
||||
* verdict in `params.state` — e.g. `5206/E10205` with `state: DECLINED`, and `5001/4004`
|
||||
* ("User Aborted") with `state: TIMEOUT`. So `state` wins whenever it is present; the envelope
|
||||
* code is only the fallback for responses that carry no `params` at all.
|
||||
*
|
||||
* The one thing `state` may never do is promote a rejected envelope to SUCCEEDED — success
|
||||
* requires both a 2001 envelope and an approving state.
|
||||
*/
|
||||
private resolveVerdict(
|
||||
responseCode: string,
|
||||
state: string | undefined,
|
||||
): ProviderPaymentStatus {
|
||||
const envelopeOk = responseCode === EBIRR_SUCCESS_CODE;
|
||||
if (!state) {
|
||||
return envelopeOk
|
||||
? ProviderPaymentStatus.PROCESSING
|
||||
: ProviderPaymentStatus.FAILED;
|
||||
}
|
||||
const mapped = this.mapStatus(state);
|
||||
if (mapped === ProviderPaymentStatus.SUCCEEDED && !envelopeOk) {
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const response = await this.postJson<EbirrGetTranInfoResponse>(
|
||||
`${this.baseUrl}/asm`,
|
||||
this.buildGetTranInfoRequest(merchantOrderId),
|
||||
EBIRR_QUERY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const rawState =
|
||||
response.params?.status ??
|
||||
response.params?.state ??
|
||||
response.params?.tranStatusDesc;
|
||||
|
||||
// A rejected envelope that still carries a state is a real verdict — the purchase endpoint
|
||||
// demonstrably does this (5206/DECLINED, 5001/TIMEOUT), so don't assume the query endpoint
|
||||
// won't. Trust the state; only fall back to the envelope when there is none.
|
||||
if (rawState) {
|
||||
const mapped = this.resolveVerdict(response.responseCode, rawState);
|
||||
this.logger.log(
|
||||
`eBirr API_GETTRANINFO ${merchantOrderId} state=${rawState} → ${mapped} ` +
|
||||
`(txn=${response.params?.transactionId ?? "n/a"}, order=${response.params?.orderId ?? "n/a"})`,
|
||||
);
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.params?.transactionId,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED
|
||||
? response.errorCode || rawState
|
||||
: undefined,
|
||||
failureMessage:
|
||||
mapped === ProviderPaymentStatus.FAILED
|
||||
? (response.params?.description ?? response.responseMsg)
|
||||
: undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
// No state at all. Same convention as waafi/cac-bank: eBirr returns a bare error envelope
|
||||
// for a transaction it has no record of, which means the payer hasn't answered the prompt
|
||||
// yet — that is REQUIRES_ACTION (still waiting on the handset), NOT PROCESSING. Persisting a
|
||||
// PROCESSING guess would let the sweep strand a payer on a push they never touched. The
|
||||
// intent still resolves: via purchase()'s verdict, or via expiresAt.
|
||||
if (response.responseCode !== EBIRR_SUCCESS_CODE) {
|
||||
this.logger.warn(
|
||||
`eBirr API_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ` +
|
||||
`${response.responseMsg} — no state returned, treating as still awaiting the payer`,
|
||||
);
|
||||
return {
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
providerTxnId: response.params?.transactionId,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { sign, ...data } = payload;
|
||||
if (!sign || typeof sign !== "string") return false;
|
||||
|
||||
const expectedSign = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign));
|
||||
}
|
||||
|
||||
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||
return this.mapStatus(tradeStatus);
|
||||
}
|
||||
|
||||
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||
switch (tradeStatus?.toUpperCase()) {
|
||||
case "TRADE_SUCCESS":
|
||||
private mapStatus(raw: string | undefined): ProviderPaymentStatus {
|
||||
switch (raw?.toUpperCase()) {
|
||||
case "APPROVED":
|
||||
case "SUCCESS":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case "TRADE_CLOSED":
|
||||
case "TRADE_FAILED":
|
||||
case "CANCELED":
|
||||
case "CANCELLED":
|
||||
return ProviderPaymentStatus.CANCELLED;
|
||||
case "DECLINED":
|
||||
case "REJECTED":
|
||||
case "FAILED":
|
||||
case "EXPIRED":
|
||||
case "TIMEOUT":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case "WAIT_BUYER_PAY":
|
||||
case "PENDING":
|
||||
case "INITIATED":
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case "PROCESSING":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString =
|
||||
sortedKeys.map((key) => `${key}=${data[key]}`).join("&") +
|
||||
`&key=${this.secretKey}`;
|
||||
|
||||
return crypto
|
||||
.createHash("md5")
|
||||
.update(signString)
|
||||
.digest("hex")
|
||||
.toUpperCase();
|
||||
private buildPurchaseRequest(
|
||||
input: ProviderInitiationInput,
|
||||
accountNo: string,
|
||||
): EbirrPurchaseRequest {
|
||||
return {
|
||||
schemaVersion: "1.0",
|
||||
requestId: crypto.randomUUID(),
|
||||
timestamp: this.timestamp(),
|
||||
channelName: this.channelName,
|
||||
serviceName: "API_PURCHASE",
|
||||
serviceParams: {
|
||||
merchantUid: this.merchantUid,
|
||||
apiKey: this.apiKey,
|
||||
apiUserId: this.apiUserId,
|
||||
paymentMethod: this.paymentMethod,
|
||||
payerInfo: { accountNo },
|
||||
transactionInfo: {
|
||||
referenceId: input.merchantOrderId,
|
||||
invoiceId: input.orderRef,
|
||||
amount: this.toAmount(input.amountMinor),
|
||||
// Charge exactly the currency the caller already converted to. The provider never
|
||||
// relabels the currency.
|
||||
currency: input.currency,
|
||||
description: `${input.orderRef}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
private buildGetTranInfoRequest(
|
||||
merchantOrderId: string,
|
||||
): EbirrGetTranInfoRequest {
|
||||
return {
|
||||
schemaVersion: "1.0",
|
||||
requestId: crypto.randomUUID(),
|
||||
timestamp: this.timestamp(),
|
||||
channelName: this.channelName,
|
||||
serviceName: "API_GETTRANINFO",
|
||||
serviceParams: {
|
||||
merchantUid: this.merchantUid,
|
||||
apiKey: this.apiKey,
|
||||
apiUserId: this.apiUserId,
|
||||
referenceId: merchantOrderId,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `amountMinor` is a misnomer inherited from the shared contract — it carries the *major*
|
||||
* amount (see PaymentIntent.amountMinor: "real/major price; may be fractional"). Pass it
|
||||
* through at 2dp. Never divide by 100 (the pre-rewrite code did, charging 1/100th), and don't
|
||||
* borrow Waafi's `Math.trunc` — that is only correct for 0-decimal DJF and would drop ETB cents.
|
||||
*/
|
||||
private toAmount(amountMinor: number): number {
|
||||
return Number(amountMinor.toFixed(2));
|
||||
}
|
||||
|
||||
/** eBirr expects `YYYY-MM-DD HH:mm:ss` — not the epoch seconds Waafi's `/asm` accepts. */
|
||||
private timestamp(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single funnel for every `/asm` call, so the full wire request and the full wire response are
|
||||
* both logged for `API_PURCHASE` and `API_GETTRANINFO` alike — at `log`, not `debug`, because
|
||||
* eBirr has no webhook and no dashboard we can see: when a payer disputes a debit these lines
|
||||
* are the only record of what we sent and what came back. Bodies pass through `sanitize*` so the
|
||||
* api key and the payer's full MSISDN never reach the log sink.
|
||||
*/
|
||||
private async postJson<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
timeout: number,
|
||||
): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout,
|
||||
httpsAgent: this.httpsAgent,
|
||||
};
|
||||
|
||||
const envelope = body as { serviceName?: string; requestId?: string };
|
||||
const tag = `${envelope.serviceName ?? "UNKNOWN"} requestId=${envelope.requestId ?? "n/a"}`;
|
||||
|
||||
this.logger.log(
|
||||
`eBirr → POST ${url} ${tag} request=${JSON.stringify(this.sanitizeRequest(body))}`,
|
||||
);
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(
|
||||
`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
this.logger.log(
|
||||
`eBirr ← ${tag} status=${res.status} latency=${Date.now() - started}ms ` +
|
||||
`response=${JSON.stringify(this.sanitizeResponse(res.data))}`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
const latency = Date.now() - started;
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
`eBirr ← ${tag} failed after ${latency}ms: status=${err.response?.status} ` +
|
||||
`response=${JSON.stringify(this.sanitizeResponse(err.response?.data))} ` +
|
||||
`code=${err.code} message=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
`eBirr ← ${tag} threw after ${latency}ms: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: EBirrInitiateRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
/**
|
||||
* Redact the api key and mask the payer MSISDN in an outbound envelope. Covers both service
|
||||
* shapes: `API_PURCHASE` carries `payerInfo.accountNo`, `API_GETTRANINFO` carries neither.
|
||||
*/
|
||||
private sanitizeRequest(body: unknown): Record<string, unknown> {
|
||||
const envelope = body as EbirrPurchaseRequest;
|
||||
const params = envelope?.serviceParams as
|
||||
| (EbirrPurchaseRequest["serviceParams"] & Record<string, unknown>)
|
||||
| undefined;
|
||||
if (!params) return body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
...envelope,
|
||||
serviceParams: {
|
||||
...params,
|
||||
apiKey: "***REDACTED***",
|
||||
...(params.payerInfo
|
||||
? { payerInfo: { accountNo: maskMsisdn(params.payerInfo.accountNo) } }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Responses carry no secret, but `params.accountNo` / `params.payerId` echo the payer's wallet
|
||||
* number back — mask those the same way the request side does.
|
||||
*/
|
||||
private sanitizeResponse(data: unknown): unknown {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
|
||||
const response = data as { params?: Record<string, unknown> };
|
||||
if (!response.params) return data;
|
||||
|
||||
const params = { ...response.params };
|
||||
for (const key of ["accountNo", "payerId"]) {
|
||||
const value = params[key];
|
||||
if (typeof value === "string" && value) params[key] = maskMsisdn(value);
|
||||
}
|
||||
return { ...response, params };
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>("ebirr.baseUrl") ?? "";
|
||||
}
|
||||
private get merchantCode(): string {
|
||||
return this.config.get<string>("ebirr.merchantCode") ?? "";
|
||||
private get merchantUid(): string {
|
||||
return this.config.get<string>("ebirr.merchantUid") ?? "";
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>("ebirr.secretKey") ?? "";
|
||||
private get apiKey(): string {
|
||||
return this.config.get<string>("ebirr.apiKey") ?? "";
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>("ebirr.notifyUrl") ?? "";
|
||||
private get apiUserId(): string {
|
||||
return this.config.get<string>("ebirr.apiUserId") ?? "";
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>("ebirr.returnUrl") ?? "";
|
||||
private get paymentMethod(): string {
|
||||
return this.config.get<string>("ebirr.paymentMethod") ?? "MWALLET_ACCOUNT";
|
||||
}
|
||||
private get channelName(): string {
|
||||
return this.config.get<string>("ebirr.channelName") ?? "WEB";
|
||||
}
|
||||
/**
|
||||
* How long `purchase()` waits for the payer's PIN. This is awaited on the request path (see
|
||||
* IntentsService.settleEBirrPurchase), so it MUST stay below the passenger API's
|
||||
* PAYMENT_API_HTTP_TIMEOUT_MS (60s) and any proxy read timeout in front of it.
|
||||
*/
|
||||
private get purchaseTimeoutMs(): number {
|
||||
return this.config.get<number>("ebirr.purchaseTimeoutMs") ?? 45_000;
|
||||
}
|
||||
private get pushTtlMs(): number {
|
||||
return this.config.get<number>("ebirr.pushTtlMs") ?? 180_000;
|
||||
}
|
||||
}
|
||||
|
||||
143
packages/payment-providers/src/providers/ebirr/ebirr.types.ts
Normal file
143
packages/payment-providers/src/providers/ebirr/ebirr.types.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* EbirrPay (API Payment) request/response types — docs/ebirr/EbirrPay for API PAYMENT.md.
|
||||
*
|
||||
* EbirrPay is the same ASM platform as WaafiPay: every operation is multiplexed through a single
|
||||
* `POST /asm`, discriminated by `serviceName`, with the identical envelope and the identical
|
||||
* `responseCode === '2001'` convention. See `../waafi/waafi.types.ts`.
|
||||
*
|
||||
* We use the API family (`API_PURCHASE`, `API_GETTRANINFO`), which is a *direct wallet debit*:
|
||||
* the purchase call pushes a PIN prompt to the payer's handset over USSD and blocks until they
|
||||
* approve it. There is no hosted page, no redirect and no webhook — the vendor doc's §5 "Redirects
|
||||
* the customer to a secure Hosted Payment Page" and §8.3 "EbirrPay returns APIUrl and orderId" are
|
||||
* stale copy-paste from the HPP family, contradicted by §5.3's own response body. See
|
||||
* docs/ebirr/INTEGRATION.md.
|
||||
*/
|
||||
|
||||
/** Terminal/intermediate states reported by EbirrPay (`params.state` / `params.status`). */
|
||||
export type EbirrState =
|
||||
| 'APPROVED'
|
||||
| 'DECLINED'
|
||||
| 'FAILED'
|
||||
| 'CANCELLED'
|
||||
| 'EXPIRED'
|
||||
| 'TIMEOUT'
|
||||
| string;
|
||||
|
||||
/** Common request envelope shared by every `/asm` call (§3). */
|
||||
export interface EbirrRequestEnvelope<TServiceParams> {
|
||||
schemaVersion: '1.0';
|
||||
requestId: string;
|
||||
timestamp: string;
|
||||
channelName: string;
|
||||
serviceName: string;
|
||||
serviceParams: TServiceParams;
|
||||
}
|
||||
|
||||
/** Common response envelope. `responseCode === '2001'` means the request was processed. */
|
||||
export interface EbirrResponseEnvelope<TParams> {
|
||||
schemaVersion: string;
|
||||
timestamp: string;
|
||||
responseId: string;
|
||||
responseCode: string;
|
||||
errorCode: string;
|
||||
responseMsg: string;
|
||||
params?: TParams;
|
||||
}
|
||||
|
||||
// --- API_PURCHASE -----------------------------------------------------------------------------
|
||||
|
||||
export interface EbirrPurchaseServiceParams {
|
||||
merchantUid: string;
|
||||
/** Merchant API key (§4). The vendor doc also calls this `APIKey` in §6.2/§7.2 — same field. */
|
||||
apiKey: string;
|
||||
/** Doc §5.2 types this as a Number, but the live API accepts the string form. */
|
||||
apiUserId: string;
|
||||
paymentMethod: string;
|
||||
/**
|
||||
* The payer's wallet. Doc §5.2 names this `subscriptionId`, but the field the live sandbox
|
||||
* actually accepts is `accountNo` — verified by hand against testpayments.ebirr.com.
|
||||
*/
|
||||
payerInfo: {
|
||||
accountNo: string;
|
||||
};
|
||||
transactionInfo: {
|
||||
referenceId: string;
|
||||
/** Not listed in §5.2 but accepted by the live API and echoed back by API_GETTRANINFO (§7.3). */
|
||||
invoiceId?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type EbirrPurchaseRequest = EbirrRequestEnvelope<EbirrPurchaseServiceParams>;
|
||||
|
||||
/**
|
||||
* §5.3 plus the fields the live sandbox actually returns.
|
||||
*
|
||||
* `state` is the authoritative outcome. Critically, it is present on **rejected** envelopes too,
|
||||
* so it must be read regardless of `responseCode` — observed live:
|
||||
*
|
||||
* 2001 / errorCode 0 → state APPROVED
|
||||
* 5206 / errorCode E10205 → state DECLINED, description "Invalid Credentials"
|
||||
* 5001 / errorCode 4004 → state TIMEOUT, description "User Aborted"
|
||||
*
|
||||
* `transactionId` and `orderId` are issued even for failed attempts.
|
||||
*/
|
||||
export interface EbirrPurchaseParams {
|
||||
accountNo?: string;
|
||||
accountType?: string;
|
||||
state?: EbirrState;
|
||||
merchantCharges?: string;
|
||||
referenceId?: string;
|
||||
transactionId?: string;
|
||||
/** EbirrPay's own order id (doc §11 "EbirrPay order tracking"). Not in §5.3's example. */
|
||||
orderId?: string;
|
||||
issuerTransactionId?: string;
|
||||
txAmount?: string;
|
||||
/** Specific failure cause, e.g. "Invalid Credentials" / "User Aborted". Not in §5.3. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type EbirrPurchaseResponse = EbirrResponseEnvelope<EbirrPurchaseParams>;
|
||||
|
||||
// --- API_GETTRANINFO --------------------------------------------------------------------------
|
||||
|
||||
export interface EbirrGetTranInfoServiceParams {
|
||||
merchantUid: string;
|
||||
apiKey: string;
|
||||
apiUserId: string;
|
||||
/** Either the merchant referenceId or the EbirrPay transactionId may be supplied. */
|
||||
referenceId?: string;
|
||||
transactionId?: string;
|
||||
}
|
||||
|
||||
export type EbirrGetTranInfoRequest = EbirrRequestEnvelope<EbirrGetTranInfoServiceParams>;
|
||||
|
||||
/**
|
||||
* §7.3 — field-for-field the same set as `WaafiGetTranInfoParams`.
|
||||
*
|
||||
* The doc renders the first key as `tranStatETBesc`, which is `tranStat` + `usD` + `esc`: the
|
||||
* vendor ran a global USD→ETB replace over the Waafi doc and corrupted `tranStatusDesc`. The real
|
||||
* field is `tranStatusDesc`.
|
||||
*/
|
||||
export interface EbirrGetTranInfoParams {
|
||||
tranStatusDesc?: string;
|
||||
amount?: string;
|
||||
payerId?: string;
|
||||
paymentMethod?: string;
|
||||
description?: string;
|
||||
tranDate?: string;
|
||||
currency?: string;
|
||||
invoiceId?: string;
|
||||
referenceId?: string;
|
||||
tranAmount?: string;
|
||||
transactionId?: string;
|
||||
orderId?: string;
|
||||
tranStatusId?: string;
|
||||
status?: EbirrState;
|
||||
/** Purchase responses use `state`; assume the query endpoint may too rather than betting on it. */
|
||||
state?: EbirrState;
|
||||
}
|
||||
|
||||
export type EbirrGetTranInfoResponse = EbirrResponseEnvelope<EbirrGetTranInfoParams>;
|
||||
53
packages/payment-providers/src/utils/msisdn.ts
Normal file
53
packages/payment-providers/src/utils/msisdn.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Ethiopian MSISDN normalisation.
|
||||
*
|
||||
* Passenger phone numbers are stored inconsistently — `+2519…`, `2519…` and local `09…` all
|
||||
* appear (see the variant-building comment in the passenger API's bookings.service). Ethiopian
|
||||
* mobile wallets want the bare international form with no `+` and no leading zero, e.g.
|
||||
* `251923582676`.
|
||||
*
|
||||
* This is deliberately separate from `normalizeCacMobile` (cac-bank.json.ts), which does the
|
||||
* opposite for Djibouti — it *strips* the 253 country code to a national number.
|
||||
*/
|
||||
|
||||
/** Ethiopian mobile subscriber numbers are 9 digits and always start with 9 (or 7 for Safaricom). */
|
||||
const ET_NATIONAL_LENGTH = 9;
|
||||
const ET_COUNTRY_CODE = '251';
|
||||
|
||||
/**
|
||||
* Convert any accepted Ethiopian phone format to the bare `251XXXXXXXXX` form.
|
||||
*
|
||||
* Accepts `+251923582676`, `251923582676`, `0923582676` and `923582676`, plus spaces, dashes and
|
||||
* parentheses anywhere. Throws on anything that isn't a plausible Ethiopian mobile number rather
|
||||
* than silently sending a wrong account — a mistyped number would push a PIN prompt to a
|
||||
* stranger's handset.
|
||||
*/
|
||||
export function normalizeEthiopianMsisdn(input: string): string {
|
||||
const digits = (input ?? '').replace(/[\s()+-]/g, '');
|
||||
if (!/^\d+$/.test(digits)) {
|
||||
throw new Error(`Invalid Ethiopian mobile number: ${input}`);
|
||||
}
|
||||
|
||||
let national: string;
|
||||
if (digits.startsWith('00' + ET_COUNTRY_CODE)) {
|
||||
national = digits.slice(2 + ET_COUNTRY_CODE.length);
|
||||
} else if (digits.startsWith(ET_COUNTRY_CODE)) {
|
||||
national = digits.slice(ET_COUNTRY_CODE.length);
|
||||
} else if (digits.startsWith('0')) {
|
||||
national = digits.slice(1);
|
||||
} else {
|
||||
national = digits;
|
||||
}
|
||||
|
||||
if (national.length !== ET_NATIONAL_LENGTH || !/^[79]/.test(national)) {
|
||||
throw new Error(`Invalid Ethiopian mobile number: ${input}`);
|
||||
}
|
||||
|
||||
return `${ET_COUNTRY_CODE}${national}`;
|
||||
}
|
||||
|
||||
/** Mask an MSISDN for display/logging: `251923582676` → `2519****2676`. */
|
||||
export function maskMsisdn(msisdn: string): string {
|
||||
if (msisdn.length <= 8) return '****';
|
||||
return `${msisdn.slice(0, 4)}****${msisdn.slice(-4)}`;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export interface EBirrWebhookPayload {
|
||||
merchantCode: string;
|
||||
orderNo: string;
|
||||
tradeStatus: string;
|
||||
tradeNo?: string;
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
payTime?: number;
|
||||
timestamp: number;
|
||||
sign: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -58,6 +58,17 @@ export type ClientAction =
|
||||
providerOrderId: string;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Wallet push-debit (eBirr): the provider has already prompted the payer on their own
|
||||
* handset (USSD/app PIN). There is nothing to navigate to and nothing to collect —
|
||||
* the client shows `message` and polls the intent until it turns terminal.
|
||||
*/
|
||||
type: "AWAIT_PUSH";
|
||||
message: string;
|
||||
/** Masked MSISDN the prompt was pushed to, so the payer can confirm it's their phone. */
|
||||
payerAccountMasked?: string;
|
||||
}
|
||||
| {
|
||||
/** CBE_BILL: show the bill reference the customer pays at any CBE channel. */
|
||||
type: "SHOW_BILL_REFERENCE";
|
||||
@@ -77,6 +88,8 @@ export interface ProviderInitiationInput {
|
||||
* Payer account identifier (e.g. mobile-wallet MSISDN in full international format).
|
||||
* Optional and provider-specific: some wallet providers (e.g. Waafi HPP with
|
||||
* MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page.
|
||||
* For push-debit providers (eBirr, CAC Bank) it is mandatory — it is the account the PIN
|
||||
* prompt is pushed to, so there is no hosted page that could collect it later.
|
||||
*/
|
||||
payerAccount?: string;
|
||||
/** Optional caller-supplied redirect targets for redirect/HPP-style providers. */
|
||||
|
||||
Reference in New Issue
Block a user