feat: (payment) add telebirr mini-app in-app payment flow

This commit is contained in:
Abubeker Yasin
2026-08-07 13:48:58 +03:00
parent 1c1fcb28a9
commit f51ee7cf59
9 changed files with 393 additions and 29 deletions

View File

@@ -2,6 +2,8 @@ import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import {
ClientAction,
PaymentPlatform,
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
@@ -67,15 +69,7 @@ export class TelebirrProvider implements PaymentProvider {
requestBody.biz_content.timeout_express,
);
const platform = input.platform ?? "web";
const clientAction =
platform === "mobile"
? {
type: "LAUNCH_APP" as const,
appId: this.merchantAppId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) };
const clientAction = this.buildClientAction(platform, prepayId, response);
return {
providerOrderId: prepayId,
@@ -199,6 +193,11 @@ export class TelebirrProvider implements PaymentProvider {
input: ProviderInitiationInput,
): CreateOrderRequest {
const totalAmount = String(input.amountMinor);
// In-app pays inside the SuperApp overlay and never navigates, so there is no browser
// to send back — telebirr's own in-app integration omits redirect_url entirely. Keep it
// absent rather than undefined: a signed-but-unsent field is what produced the earlier
// "verify sign failed" (see docs/payment-service + telebirr.crypto skip-undefined).
const wantsRedirect = input.platform !== "inapp" && !!input.redirectUrl;
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
@@ -214,7 +213,7 @@ export class TelebirrProvider implements PaymentProvider {
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}),
...(wantsRedirect ? { redirect_url: input.redirectUrl! } : {}),
},
};
const sign = signRequestObject(
@@ -245,6 +244,68 @@ export class TelebirrProvider implements PaymentProvider {
return { ...req, sign, sign_type: "SHA256WithRSA" };
}
/**
* Telebirr exposes the same pre-order three ways; only the launch payload differs.
*
* - `mobile` — native app hands off to the wallet app with a receiveCode.
* - `inapp` — the portal is running inside the telebirr SuperApp mini-app WebView. The
* H5 checkout page is unusable there: it deep-links to `kcbconsumer://…`,
* which the WebView cannot resolve (`net::ERR_UNKNOWN_URL_SCHEME`). The
* signed rawRequest goes to the host JS bridge instead — no navigation.
* - `web` — ordinary browser; redirect to the H5 checkout page.
*/
private buildClientAction(
platform: PaymentPlatform,
prepayId: string,
response: CreateOrderResponse,
): ClientAction {
switch (platform) {
case "mobile":
return {
type: "LAUNCH_APP",
appId: this.merchantAppId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
};
case "inapp":
return {
type: "INVOKE_BRIDGE",
bridge: "TELEBIRR",
rawRequest: this.buildInAppRawRequest(prepayId),
};
default:
return { type: "REDIRECT", url: this.buildCheckoutUrl(prepayId) };
}
}
/**
* Signed request handed verbatim to the SuperApp bridge (`js_fun_start_pay`).
*
* Emits `appid, merch_code, nonce_str, prepay_id, timestamp, sign_type, sign` in that
* order — no `webBaseUrl` prefix and no `version`/`trade_type` tail, because the bridge
* takes the bare query string rather than a URL.
*
* `sign_type` sits in the map purely so it lands in the output in the right position;
* `buildCanonicalString` excludes it (as does telebirr's own reference implementation),
* so the signature covers the same five fields as the web checkout URL.
*
* Kept separate from `buildCheckoutUrl` rather than sharing a builder: the two payloads
* are consumed by different validators, and the web flow is live.
*/
private buildInAppRawRequest(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,
merch_code: this.merchantCode,
nonce_str: createNonceStr(),
prepay_id: prepayId,
timestamp: createTimestamp(),
sign_type: "SHA256WithRSA",
};
const sign = signRequestObject(map, this.privateKey);
const fields = Object.entries(map).map(([k, v]) => `${k}=${v}`);
return [...fields, `sign=${sign}`].join("&");
}
private buildCheckoutUrl(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,