mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
feat: (payment) add eBirr as synchronous API_PURCHASE wallet debit
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user