mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
feat: (payment) add telebirr mini-app in-app payment flow
This commit is contained in:
@@ -60,14 +60,38 @@ export class RefundDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
// INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so
|
||||
// must be assignable here, but freight never requests platform=inapp and therefore never
|
||||
// receives one. Passenger owns that flow — see docs/telebirr-miniapp/.
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
enum: [
|
||||
"REDIRECT",
|
||||
"LAUNCH_APP",
|
||||
"INVOKE_BRIDGE",
|
||||
"COLLECT_OTP",
|
||||
"SHOW_BILL_REFERENCE",
|
||||
],
|
||||
})
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
type!:
|
||||
| "REDIRECT"
|
||||
| "LAUNCH_APP"
|
||||
| "INVOKE_BRIDGE"
|
||||
| "COLLECT_OTP"
|
||||
| "SHOW_BILL_REFERENCE";
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
|
||||
})
|
||||
bridge?: "TELEBIRR";
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
|
||||
})
|
||||
rawRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
appId?: string;
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class WaiveSupplementaryChargeDto {
|
||||
|
||||
class PaySupplementaryChargeDto {
|
||||
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
||||
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile';
|
||||
@ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto;
|
||||
}
|
||||
|
||||
@ApiTags("Payment")
|
||||
@@ -307,7 +307,14 @@ export class PaymentsController {
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiQuery({
|
||||
name: "platform",
|
||||
enum: ["web", "mobile"],
|
||||
required: false,
|
||||
description:
|
||||
"Browser-only endpoint — `inapp` is not offered here. A mini-app payer has no browser " +
|
||||
"to redirect and must go through POST /payments/initiate for the bridge payload.",
|
||||
})
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
|
||||
@@ -28,7 +28,8 @@ export enum PaymentMethodTypeEnum {
|
||||
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
|
||||
}
|
||||
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
/** Mirrors `PaymentPlatform` in @edr/types — see there for what each surface means. */
|
||||
export type PaymentPlatformDto = "web" | "mobile" | "inapp";
|
||||
|
||||
export class InitiatePaymentDto {
|
||||
@ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string;
|
||||
@@ -45,12 +46,14 @@ export class InitiatePaymentDto {
|
||||
@IsString()
|
||||
paymentMethodId?: string;
|
||||
@ApiPropertyOptional({
|
||||
enum: ["web", "mobile"],
|
||||
enum: ["web", "mobile", "inapp"],
|
||||
default: "web",
|
||||
description: "Payment platform (web or mobile)",
|
||||
description:
|
||||
"Payer surface. `inapp` = the portal is running inside a SuperApp mini-app WebView " +
|
||||
"(Telebirr), which cannot follow redirect flows and gets a bridge payload instead.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
@IsIn(["web", "mobile", "inapp"])
|
||||
platform?: PaymentPlatformDto;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
@@ -117,9 +120,20 @@ export class SupportedPaymentMethodDto {
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
enum: [
|
||||
"REDIRECT",
|
||||
"LAUNCH_APP",
|
||||
"INVOKE_BRIDGE",
|
||||
"COLLECT_OTP",
|
||||
"SHOW_BILL_REFERENCE",
|
||||
],
|
||||
})
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
type:
|
||||
| "REDIRECT"
|
||||
| "LAUNCH_APP"
|
||||
| "INVOKE_BRIDGE"
|
||||
| "COLLECT_OTP"
|
||||
| "SHOW_BILL_REFERENCE";
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@ApiPropertyOptional({
|
||||
@@ -134,6 +148,18 @@ export class ClientActionDto {
|
||||
description: "Set when type=LAUNCH_APP (mobile flow)",
|
||||
})
|
||||
shortCode?: string;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Set when type=INVOKE_BRIDGE (telebirr mini app) — which SuperApp host bridge to call",
|
||||
enum: ["TELEBIRR"],
|
||||
})
|
||||
bridge?: "TELEBIRR";
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Set when type=INVOKE_BRIDGE (telebirr mini app). Signed query string handed verbatim " +
|
||||
"to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.",
|
||||
})
|
||||
rawRequest?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
|
||||
providerOrderId?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { EmailClientService } from '../notifications/email-client.service';
|
||||
import { PaymentClientService } from './payment-client.service';
|
||||
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
|
||||
import { PaymentPlatformDto } from './payments.dto';
|
||||
|
||||
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
|
||||
|
||||
@@ -119,7 +120,7 @@ export class SupplementaryChargesService {
|
||||
async pay(
|
||||
token: string,
|
||||
method: string,
|
||||
platform?: 'web' | 'mobile',
|
||||
platform?: PaymentPlatformDto,
|
||||
requestOrigin?: string | null,
|
||||
) {
|
||||
const charge = await this.getByToken(token); // validates status/expiry
|
||||
|
||||
@@ -6,7 +6,12 @@ import { usePaymentStore } from "@/lib/payment-store";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
isTelebirrMiniApp,
|
||||
onTelebirrPayResult,
|
||||
startTelebirrPay,
|
||||
} from "@/lib/telebirr-bridge";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { PaymentMethod } from "@/types";
|
||||
import { format } from "date-fns";
|
||||
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
|
||||
@@ -55,6 +60,17 @@ export default function PaymentPage() {
|
||||
} | null>(null);
|
||||
const [billCopied, setBillCopied] = useState(false);
|
||||
|
||||
// Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're
|
||||
// polling our own status endpoint for the webhook-backed outcome.
|
||||
const [verifyingPayment, setVerifyingPayment] = useState(false);
|
||||
|
||||
// Resolved once on mount — SSR has no `window`, so this must not be read during render
|
||||
// of the first (server) pass.
|
||||
const [inMiniApp, setInMiniApp] = useState(false);
|
||||
useEffect(() => {
|
||||
setInMiniApp(isTelebirrMiniApp());
|
||||
}, []);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
// Use the same display currency as the review page — stored on the schedule at search time.
|
||||
@@ -72,8 +88,26 @@ export default function PaymentPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Inside the telebirr SuperApp only telebirr can complete: every other method is a
|
||||
// redirect/HPP flow, and the mini-app WebView cannot follow the scheme handoffs those
|
||||
// gateways use. Offering them would strand the payer on a dead page.
|
||||
// Memoised: this feeds an effect's dep array, and a fresh array identity every render
|
||||
// would re-run that effect on every render.
|
||||
const availableMethods = useMemo(
|
||||
() => paymentMethods.filter((m) => m.enabled && (!inMiniApp || m.type === 'TELEBIRR')),
|
||||
[paymentMethods, inMiniApp],
|
||||
);
|
||||
|
||||
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null;
|
||||
|
||||
// A method chosen before the container was known (or carried over in state) may no longer
|
||||
// be offerable — drop it rather than letting Pay fire against a hidden method.
|
||||
useEffect(() => {
|
||||
if (selectedMethod && !availableMethods.some((m) => m.type === selectedMethod)) {
|
||||
setSelectedMethod(null);
|
||||
}
|
||||
}, [selectedMethod, availableMethods]);
|
||||
|
||||
// Derive charge currency directly from the selected method — no separate state that can lag.
|
||||
const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase();
|
||||
|
||||
@@ -128,6 +162,70 @@ export default function PaymentPage() {
|
||||
}
|
||||
}, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]);
|
||||
|
||||
/**
|
||||
* Poll our own status endpoint until the payment reaches a terminal state.
|
||||
*
|
||||
* Used by the telebirr mini-app flow, where nothing navigates and therefore no return page
|
||||
* ever runs. The bridge callback only tells us the sheet closed; the authoritative outcome
|
||||
* is the webhook-backed status the API reports here.
|
||||
*/
|
||||
const pollPaymentStatus = useCallback(
|
||||
async (attemptsLeft: number): Promise<void> => {
|
||||
if (!bookingId) return;
|
||||
try {
|
||||
const res: any = await apiClient.get(`/payments/status/${bookingId}`);
|
||||
if (res?.status === 'SUCCEEDED') {
|
||||
setVerifyingPayment(false);
|
||||
updateStatus("SUCCEEDED");
|
||||
router.push("/booking/confirmation");
|
||||
return;
|
||||
}
|
||||
if (res?.status === 'FAILED' || res?.status === 'CANCELLED') {
|
||||
setVerifyingPayment(false);
|
||||
setIsProcessing(false);
|
||||
updateStatus("FAILED");
|
||||
setPaymentError(res?.failureMessage || "Payment was not completed. Please try again.");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Transient read failure — keep polling; the attempt budget bounds it.
|
||||
}
|
||||
|
||||
if (attemptsLeft <= 0) {
|
||||
// Don't call it failed: telebirr may have taken the money and the webhook is simply
|
||||
// still in flight. Stop spinning, tell the truth, and let the payer re-check.
|
||||
setVerifyingPayment(false);
|
||||
setIsProcessing(false);
|
||||
setPaymentError(
|
||||
"We haven't received confirmation yet. If you completed the payment, your booking " +
|
||||
"will be confirmed shortly — check My Bookings in a moment before paying again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => void pollPaymentStatus(attemptsLeft - 1), 1500);
|
||||
},
|
||||
[bookingId, router, updateStatus],
|
||||
);
|
||||
|
||||
/**
|
||||
* Telebirr mini app reports the sheet outcome on a global callback rather than a redirect.
|
||||
* Registered on mount — the SuperApp can call back the moment the sheet closes, so it must
|
||||
* already be installed before the bridge is invoked.
|
||||
*/
|
||||
useEffect(() => {
|
||||
return onTelebirrPayResult((succeeded) => {
|
||||
if (!succeeded) {
|
||||
setVerifyingPayment(false);
|
||||
setIsProcessing(false);
|
||||
updateStatus("FAILED");
|
||||
setPaymentError("Payment was cancelled or declined. Please try again.");
|
||||
return;
|
||||
}
|
||||
setVerifyingPayment(true);
|
||||
void pollPaymentStatus(15);
|
||||
});
|
||||
}, [pollPaymentStatus, updateStatus]);
|
||||
|
||||
const paymentMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
return await apiClient.post("/payments/initiate", {
|
||||
@@ -135,7 +233,7 @@ export default function PaymentPage() {
|
||||
method: data.method,
|
||||
paymentMethodId: data.paymentMethodId,
|
||||
payerAccount: data.payerAccount,
|
||||
platform: 'web',
|
||||
platform: isTelebirrMiniApp() ? 'inapp' : 'web',
|
||||
});
|
||||
},
|
||||
onSuccess: async (data: any) => {
|
||||
@@ -163,6 +261,23 @@ export default function PaymentPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Telebirr mini app: hand the signed rawRequest to the SuperApp bridge. Nothing
|
||||
// navigates — telebirr draws its payment sheet over the WebView and reports back on
|
||||
// the global callback registered above, which starts the status polling.
|
||||
if (data?.clientAction?.type === 'INVOKE_BRIDGE') {
|
||||
setPaymentIntent(data.intentId);
|
||||
updateStatus("REQUIRES_ACTION");
|
||||
if (!startTelebirrPay(data.clientAction.rawRequest)) {
|
||||
setIsProcessing(false);
|
||||
updateStatus("FAILED");
|
||||
setPaymentError(
|
||||
"Couldn't open the telebirr payment sheet. Please reopen this page from the " +
|
||||
"telebirr app and try again.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
|
||||
setPaymentIntent(data.intentId);
|
||||
updateStatus("REQUIRES_ACTION");
|
||||
@@ -505,7 +620,15 @@ export default function PaymentPage() {
|
||||
{isProcessing && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
|
||||
{paymentMutation.isSuccess ? (
|
||||
{verifyingPayment ? (
|
||||
<>
|
||||
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Confirming payment</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Checking with telebirr — this only takes a moment.
|
||||
</p>
|
||||
</>
|
||||
) : paymentMutation.isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold mb-4 text-gray-900 dark:text-gray-100">Loading...</h3>
|
||||
@@ -688,13 +811,13 @@ export default function PaymentPage() {
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-800 dark:text-red-200 text-sm">Failed to load payment methods. Please refresh.</p>
|
||||
</div>
|
||||
) : paymentMethods.length === 0 ? (
|
||||
) : availableMethods.length === 0 ? (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-yellow-800 dark:text-yellow-200 text-sm">No payment methods available at the moment.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.filter(m => m.enabled).map((method) => {
|
||||
{availableMethods.map((method) => {
|
||||
const Icon = getIconForMethod(method.type);
|
||||
const isSelected = selectedMethod === method.type;
|
||||
return (
|
||||
|
||||
106
apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts
Normal file
106
apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Telebirr SuperApp mini-app bridge.
|
||||
*
|
||||
* When the portal runs inside the telebirr SuperApp, the ordinary web checkout is unusable:
|
||||
* the H5 paygate page hands off to the native wallet with a custom scheme
|
||||
* (`kcbconsumer://h5checkout?...`) that the SuperApp's WebView cannot resolve, so the payer
|
||||
* only ever sees `net::ERR_UNKNOWN_URL_SCHEME`.
|
||||
*
|
||||
* The in-app flow never navigates. The API returns a signed `rawRequest` string
|
||||
* (clientAction.type === "INVOKE_BRIDGE") which is handed to the host's JS bridge; telebirr
|
||||
* renders its own payment sheet over the WebView and reports the outcome on a global callback.
|
||||
*
|
||||
* See docs/telebirr-miniapp/inapp-payment-plan.md.
|
||||
*/
|
||||
|
||||
/** Name of the global the SuperApp calls back into. Must be a property of `window`. */
|
||||
export const TELEBIRR_PAY_CALLBACK = "handleEdrPaymentCallback";
|
||||
|
||||
type ConsumerApp = { evaluate: (payload: string) => void };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Injected by the telebirr SuperApp WebView. Absent everywhere else. */
|
||||
consumerapp?: ConsumerApp;
|
||||
[TELEBIRR_PAY_CALLBACK]?: (response: unknown) => void;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when the telebirr host bridge is actually present.
|
||||
*
|
||||
* Deliberately does NOT sniff the user agent. A UA match without `window.consumerapp` would
|
||||
* make us request `platform: "inapp"` and get back a bare rawRequest we have no way to use —
|
||||
* there is no navigating our way out of that, because by then the server has already committed
|
||||
* to the bridge payload. Gating on the bridge object keeps the decision and the capability in
|
||||
* sync: if we can't call it, we don't ask for it.
|
||||
*/
|
||||
export function isTelebirrMiniApp(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.consumerapp?.evaluate === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a signed rawRequest to the SuperApp to open its payment sheet.
|
||||
*
|
||||
* Register the callback (see `onTelebirrPayResult`) BEFORE calling this — the host may invoke
|
||||
* it as soon as the sheet closes. Returns false when the bridge is missing or throws, so the
|
||||
* caller can surface an error instead of leaving the payer on a dead spinner.
|
||||
*/
|
||||
export function startTelebirrPay(rawRequest: string): boolean {
|
||||
if (!isTelebirrMiniApp()) return false;
|
||||
try {
|
||||
window.consumerapp!.evaluate(
|
||||
JSON.stringify({
|
||||
functionName: "js_fun_start_pay",
|
||||
params: {
|
||||
rawRequest,
|
||||
functionCallBackName: TELEBIRR_PAY_CALLBACK,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[telebirr] bridge evaluate failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the global result callback; returns a disposer for effect cleanup.
|
||||
*
|
||||
* The result is a TRIGGER TO VERIFY, never proof of payment — the payer can close the sheet,
|
||||
* the host can report success before settlement lands, and the payload shape is not a contract.
|
||||
* Confirmation always comes from polling our own payment status (webhook-backed).
|
||||
*/
|
||||
export function onTelebirrPayResult(handler: (succeeded: boolean) => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window[TELEBIRR_PAY_CALLBACK] = (response: unknown) => {
|
||||
handler(isSuccessResponse(response));
|
||||
};
|
||||
return () => {
|
||||
delete window[TELEBIRR_PAY_CALLBACK];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Telebirr reports `code: 0` (number or string) for success. The payload arrives as either a
|
||||
* JSON string or an object depending on host version, and an unparseable payload is treated as
|
||||
* success on purpose: polling is what decides the outcome, and a false "failed" would strand a
|
||||
* payer who actually paid.
|
||||
*/
|
||||
function isSuccessResponse(response: unknown): boolean {
|
||||
let parsed: unknown = response;
|
||||
if (typeof response === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(response);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (parsed && typeof parsed === "object" && "code" in parsed) {
|
||||
const code = (parsed as { code: unknown }).code;
|
||||
if (code === undefined || code === null) return true;
|
||||
return code === 0 || code === "0";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -62,9 +62,14 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
|
||||
@IsEnum(ProviderMethod)
|
||||
provider!: ProviderMethod;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["web", "mobile"] })
|
||||
@ApiPropertyOptional({
|
||||
enum: ["web", "mobile", "inapp"],
|
||||
description:
|
||||
"Payer surface. `inapp` = running inside a SuperApp mini-app WebView (Telebirr), " +
|
||||
"which cannot follow redirect/HPP flows and gets a bridge payload instead.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
@IsIn(["web", "mobile", "inapp"])
|
||||
platform?: PaymentPlatform;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,7 +32,8 @@ export enum ProviderMethod {
|
||||
CBE_BILL = "CBE_BILL",
|
||||
}
|
||||
|
||||
export type PaymentPlatform = "web" | "mobile";
|
||||
|
||||
export type PaymentPlatform = "web" | "mobile" | "inapp";
|
||||
|
||||
export type ClientAction =
|
||||
| { type: "REDIRECT"; url: string }
|
||||
@@ -42,6 +43,16 @@ export type ClientAction =
|
||||
receiveCode?: string;
|
||||
shortCode: string;
|
||||
}
|
||||
| {
|
||||
type: "INVOKE_BRIDGE";
|
||||
/** Which SuperApp host bridge the payload targets. */
|
||||
bridge: "TELEBIRR";
|
||||
/**
|
||||
* Signed query string handed verbatim to the host bridge (`js_fun_start_pay`).
|
||||
* NOT a URL — it has no scheme or host and must never be navigated to.
|
||||
*/
|
||||
rawRequest: string;
|
||||
}
|
||||
| {
|
||||
type: "COLLECT_OTP";
|
||||
providerOrderId: string;
|
||||
|
||||
Reference in New Issue
Block a user