Merge pull request #1177 from Tria-plc/alpha

Alpha
This commit is contained in:
Stephanos A.
2026-08-08 11:18:49 +03:00
committed by GitHub
15 changed files with 570 additions and 43 deletions

View File

@@ -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;

View File

@@ -13,11 +13,17 @@ export const MAX_PAYMENT_HOURS = 2;
export const CUTOFF_MINUTES = 30;
/**
* payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes)
*
* checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level
* checkinMinutesBefore so that each route's own window is respected.
* How long a passenger is given to finish one provider payment session, once opened.
* 5 minutes of actual paying (redirect → PIN/OTP → provider callback) + 1 minute of slack.
*/
export const PAYMENT_SESSION_MINUTES = 6;
export const MIN_PAYMENT_WINDOW_MINUTES = 7;
export const PAYMENT_SETTLE_MARGIN_SECONDS = 60;
export function computePaymentDeadline(
createdAt: Date,
departureAt: Date,
@@ -27,3 +33,19 @@ export function computePaymentDeadline(
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
}
export function canOpenPaymentSession(
paymentDeadline: Date,
now: Date = new Date(),
): boolean {
return paymentDeadline.getTime() - now.getTime() >= MIN_PAYMENT_WINDOW_MINUTES * 60 * 1000;
}
export function computePaymentSessionExpiry(
paymentDeadline: Date,
now: Date = new Date(),
): Date {
const sessionEnd = new Date(now.getTime() + PAYMENT_SESSION_MINUTES * 60 * 1000);
return sessionEnd < paymentDeadline ? sessionEnd : paymentDeadline;
}

View File

@@ -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,

View File

@@ -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" })
@@ -154,6 +180,10 @@ export class InitiateResponseDto {
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional() merchantOrderId?: string;
/** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */
@ApiPropertyOptional() sessionExpiresAt?: string;
/** The booking's payment deadline: after it, the booking is auto-cancelled. */
@ApiPropertyOptional() paymentDeadline?: string;
}
export class IntentStatusDto {

View File

@@ -16,6 +16,11 @@ import {
ProviderMethod,
ProviderPaymentStatus,
} from "@edr/types";
import {
MAX_PAYMENT_HOURS,
MIN_PAYMENT_WINDOW_MINUTES,
PAYMENT_SESSION_MINUTES,
} from "../../common/utils/payment-deadline.utils";
describe("PaymentsService", () => {
let service: PaymentsService;
@@ -163,6 +168,69 @@ describe("PaymentsService", () => {
).rejects.toThrow(BadRequestException);
});
/**
* A booking whose payment deadline lands exactly `minutesLeft` from now: the deadline is
* MIN(createdAt + MAX_PAYMENT_HOURS, departure - checkin), so back-date createdAt and keep
* departure far away. Derived from MAX_PAYMENT_HOURS so the test survives changes to it.
*/
const bookingWithDeadlineIn = (minutesLeft: number) => ({
...mockBooking,
createdAt: new Date(
Date.now() - (MAX_PAYMENT_HOURS * 60 - minutesLeft) * 60 * 1000,
),
originStationId: null,
schedule: {
departureAt: new Date(Date.now() + 10 * 60 * 60 * 1000),
stopTimes: [],
route: null,
},
});
it("should refuse to open a provider session that cannot finish before auto-cancel", async () => {
// 2 minutes left — the real incident: the session was opened, the provider captured the
// money, and the auto-cancel cron had already cancelled the booking by then.
mockPrisma.booking.findUnique.mockResolvedValue(bookingWithDeadlineIn(2));
await expect(
service.initiatePayment({
bookingId: "booking-1",
method: "TELEBIRR" as any,
}),
).rejects.toThrow(BadRequestException);
// Nothing may reach the provider — no session, no capture, no orphan payment.
expect(mockPaymentClient.initiate).not.toHaveBeenCalled();
});
it("should open a session and report its expiry when the window is wide enough", async () => {
const minutesLeft = MIN_PAYMENT_WINDOW_MINUTES + 3;
mockPrisma.booking.findUnique.mockResolvedValue(
bookingWithDeadlineIn(minutesLeft),
);
mockPaymentClient.initiate.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.TELEBIRR),
);
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
});
const result = await service.initiatePayment({
bookingId: "booking-1",
method: "TELEBIRR" as any,
});
expect(mockPaymentClient.initiate).toHaveBeenCalled();
// Session ends PAYMENT_SESSION_MINUTES from now — before the deadline, not at it.
const sessionMs =
new Date(result.sessionExpiresAt!).getTime() - Date.now();
expect(sessionMs).toBeLessThanOrEqual(PAYMENT_SESSION_MINUTES * 60 * 1000);
expect(new Date(result.sessionExpiresAt!).getTime()).toBeLessThan(
new Date(result.paymentDeadline!).getTime(),
);
});
it("should initiate a provider payment through the payment microservice", async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPaymentClient.initiate.mockResolvedValue(

View File

@@ -29,7 +29,13 @@ import {
MarkPaidResponseDto,
BillQueryResponseDto,
} from "./internal-payments.dto";
import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils";
import {
computePaymentDeadline,
computePaymentSessionExpiry,
canOpenPaymentSession,
MIN_PAYMENT_WINDOW_MINUTES,
PAYMENT_SETTLE_MARGIN_SECONDS,
} from "../../common/utils/payment-deadline.utils";
import {
PaymentClientService,
PaymentDiagnostic,
@@ -260,6 +266,27 @@ export class PaymentsService {
return this.initiateWalletPayment(booking);
}
// Refuse to open a provider session that cannot finish before auto-cancel. Everything below
// this point hands the passenger off to an external provider (redirect/HPP/OTP), which takes
// minutes; TasksService cancels the booking the first cron tick after its payment deadline.
// Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible
// outcome — the provider captures the money and the booking is already CANCELLED when the
// capture lands. WALLET is exempt (returned above): it is an instant internal balance debit.
const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id);
const sessionExpiresAt = paymentDeadline
? computePaymentSessionExpiry(paymentDeadline)
: undefined;
if (paymentDeadline && !canOpenPaymentSession(paymentDeadline)) {
const remainingMs = paymentDeadline.getTime() - Date.now();
throw new BadRequestException(
remainingMs <= 0
? "The payment window for this booking has expired. Please make a new booking."
: `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` +
`until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` +
`Please make a new booking.`,
);
}
// Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the
// single passenger projection row (upserted by bookingId below) tracks the latest session.
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
@@ -317,9 +344,7 @@ export class PaymentsService {
payerName =
booking.seats.find((s) => s.leg === 1)?.passengerName ??
booking.seats[0]?.passengerName;
expiresAt = (
await this.computeBookingPaymentDeadline(booking.id)
)?.toISOString();
expiresAt = paymentDeadline?.toISOString();
}
const snapshot = await this.paymentClient.initiate({
@@ -350,7 +375,11 @@ export class PaymentsService {
where: { id: intent.id },
});
}
return this.formatIntentResponse(intent);
return {
...this.formatIntentResponse(intent),
sessionExpiresAt: sessionExpiresAt?.toISOString(),
paymentDeadline: paymentDeadline?.toISOString(),
};
}
/**
@@ -436,8 +465,15 @@ export class PaymentsService {
if (booking.status !== "PENDING_PAYMENT") {
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
}
// A CBE debit confirmed now lands in seconds, so this doesn't need the full
// MIN_PAYMENT_WINDOW_MINUTES that opening a session does — but it must not be confirmed so
// close to the deadline that the auto-cancel cron cancels the booking before the capture is
// registered. Refusing here is what keeps CBE from debiting a passenger for a dead booking.
const deadline = await this.computeBookingPaymentDeadline(booking.id);
if (deadline && deadline.getTime() < Date.now()) {
if (
deadline &&
deadline.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < Date.now()
) {
return { ...base, stillPayable: false, reason: "EXPIRED" };
}
return { ...base, stillPayable: true, reason: null };

View File

@@ -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

View File

@@ -30,13 +30,26 @@ export default function ReportsPage() {
const getDateRange = () => {
const end = new Date();
end.setHours(23, 59, 59, 999);
const start = new Date();
if (dateRange === 'custom') {
if (startDate && endDate) {
return startDate <= endDate
? { startDate, endDate }
: { startDate: endDate, endDate: startDate };
}
const fallbackStart = new Date(end);
fallbackStart.setDate(end.getDate() - 30);
return {
startDate: fallbackStart.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
};
}
const start = new Date(end);
switch (dateRange) {
case '7': start.setDate(end.getDate() - 7); break;
case '30': start.setDate(end.getDate() - 30); break;
case '90': start.setDate(end.getDate() - 90); break;
default:
if (startDate && endDate) return { startDate, endDate };
}
return {
startDate: start.toISOString().split('T')[0],

View File

@@ -74,6 +74,7 @@ export default function PassengersReportPage() {
const [tab, setTab] = useState<Tab>("occupancy");
const [listSearch, setListSearch] = useState("");
const [filterOrigin, setFilterOrigin] = useState("");
const [filterDestination, setFilterDestination] = useState("");
const [filterSeatClass, setFilterSeatClass] = useState("");
const [filterCoachNumber, setFilterCoachNumber] = useState("");
@@ -110,17 +111,23 @@ export default function PassengersReportPage() {
const originOptions = [
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
].sort() as string[];
const destinationOptions = [
...new Set(passengerList.map((p) => p.destination).filter(Boolean)),
].sort() as string[];
const filteredList = passengerList
.filter((p) => {
if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false;
if (filterOrigin && p.origin !== filterOrigin) return false;
if (filterDestination && p.destination !== filterDestination) return false;
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
if (listSearch.trim()) {
const q = listSearch.toLowerCase();
return (
p.passengerName.toLowerCase().includes(q) ||
p.bookingRef.toLowerCase().includes(q) ||
(p.origin ?? '').toLowerCase().includes(q) ||
(p.destination ?? '').toLowerCase().includes(q) ||
(p.idDocumentNumber ?? "").toLowerCase().includes(q) ||
(p.passportNumber ?? "").toLowerCase().includes(q)
);
@@ -207,6 +214,7 @@ export default function PassengersReportPage() {
setListSearch("");
setFilterCoachNumber("");
setFilterOrigin("");
setFilterDestination("");
setFilterSeatClass("");
}}
disabled={loadingSchedules}
@@ -499,6 +507,18 @@ export default function PassengersReportPage() {
</option>
))}
</select>
<select
className="input w-36"
value={filterDestination}
onChange={(e) => { setFilterDestination(e.target.value); resetListPage(); }}
>
<option value="">All destinations</option>
{destinationOptions.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
{passengerList.length > 0 && (
<ActionButton
icon={Download}

View File

@@ -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 (

View 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;
}

View File

@@ -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({

View File

@@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider {
return this.config.get<string>("dmoney.returnUrl") ?? "";
}
private get timeoutExpress(): string {
return this.config.get<string>("dmoney.timeoutExpress") ?? "120m";
return this.config.get<string>("dmoney.timeoutExpress") ?? "5m";
}
private get language(): string {
return this.config.get<string>("dmoney.language") ?? "en";

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,

View File

@@ -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;