Merge pull request #1005 from Tria-plc/alpha

fix: ( payments ) allow telebirr checkout to bounce through /go
This commit is contained in:
Abubeker Yasin
2026-07-29 14:52:41 +03:00
committed by GitHub
2 changed files with 89 additions and 26 deletions

View File

@@ -3,36 +3,47 @@
import { Suspense, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { isDMoneyCheckoutUrl } from "@/lib/payment-redirect";
import { isBounceableCheckoutUrl } from "@/lib/payment-redirect";
/**
* /go — payment redirect bounce page for D-Money web checkout.
* /go — payment redirect bounce page for provider web checkout (D-Money, telebirr).
*
* The redirect is done CLIENT-SIDE on purpose: the navigation must originate
* from the loaded https://edrpassenger.triaplc.com/go document so the browser
* sends `Referer: https://edrpassenger.triaplc.com` to D-Money. D-Money only
* sends `Referer: https://edrpassenger.triaplc.com` to the paygate. D-Money only
* whitelists that origin, so a server-side 307 (whose referrer on the redirect
* hop is browser-dependent and can be stripped) must NOT be used here.
*
* Telebirr's paygate has the same requirement — its checkout URL opens when the
* portal navigates to it but returns "the required parameter of the request is
* empty" when opened with no Referer at all (address-bar paste, or a native
* WebView's `loadRequest`). Native clients therefore bounce telebirr through
* here too. The portal's own web flow does NOT: it already navigates from the
* payment page, so it supplies a Referer without the extra hop.
*
* It fires immediately (no delay) and paints a bare white full-screen cover
* above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy
* path. A short message shows only when the link is missing/untrusted.
*
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the
* Flutter app / encodeURIComponent() on web); otherwise the query parser
* truncates the D-Money URL at its first `&` and merch_code/sign are lost.
* See scripts/test-go-redirect.mjs.
* truncates the checkout URL at its first `&`. For telebirr this also matters
* for the base64 `sign`, whose `+` would otherwise decode to a space.
*/
function RedirectView() {
const searchParams = useSearchParams();
const raw = searchParams.get("url");
const target = useMemo(() => (isDMoneyCheckoutUrl(raw) ? raw : null), [raw]);
const target = useMemo(
() => (isBounceableCheckoutUrl(raw) ? raw : null),
[raw],
);
useEffect(() => {
if (!target) return;
// Navigate from this document so the D-Money request carries
// Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists).
// Navigate from this document so the paygate request carries
// Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists,
// and the one telebirr's paygate needs present).
window.location.replace(target);
}, [target]);

View File

@@ -8,17 +8,60 @@
* Every place that follows a `clientAction.url` from `/payments/initiate` runs
* it through `resolvePaymentRedirectUrl()`; non-D-Money providers (Telebirr,
* Waafi, card) pass through untouched.
*
* Telebirr's paygate turns out to behave the same way — the checkout URL loads
* when the portal navigates to it with `window.location.href` but fails with
* "the required parameter of the request is empty" when opened with no Referer
* (address-bar paste, or a native WebView's `loadRequest`). It is therefore
* accepted by `/go` (see `isBounceableCheckoutUrl`) so non-browser clients can
* borrow the portal's origin.
*
* On web, telebirr is deliberately NOT auto-wrapped: `resolvePaymentRedirectUrl`
* still returns telebirr URLs unchanged. The browser flow already supplies a
* Referer for free by navigating from the payment page, it is live, and putting
* an extra hop in front of it buys nothing.
*/
const ALLOWED_HOSTS = (
/** Comma-separated env list → normalized, non-empty, lowercase hosts. */
function parseHosts(raw: string): string[] {
return raw
.split(",")
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
}
const ALLOWED_HOSTS = parseHosts(
// Default allows both D-Money environments:
// test/sandbox → pgtest.d-money.dj (base domain d-money.dj)
// production → pg.d-moneyservice.dj (base domain d-moneyservice.dj)
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj"
)
.split(",")
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj",
);
const TELEBIRR_ALLOWED_HOSTS = parseHosts(
// Default allows both telebirr environments:
// test/sandbox → developerportal.ethiotelebirr.et (base domain ethiotelebirr.et)
// production → superapp.ethiomobilemoney.et (base domain ethiomobilemoney.et)
// Both serve the paygate on port 38443; the port is not part of the match.
process.env.NEXT_PUBLIC_TELEBIRR_ALLOWED_HOSTS ??
"ethiotelebirr.et,ethiomobilemoney.et",
);
/** True for https URLs whose host is (or is a subdomain of) one of `allowed`. */
function isHttpsHostMatch(
raw: string | null | undefined,
allowed: string[],
): raw is string {
if (!raw) return false;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
const host = parsed.hostname.toLowerCase();
return allowed.some((a) => host === a || host.endsWith(`.${a}`));
}
/**
* Origin the `/go` bounce page is served from — must be the exact origin D-Money
@@ -34,18 +77,27 @@ const BOUNCE_ORIGIN = (
/** True only for https URLs whose host is (or is a subdomain of) an allowlisted D-Money host. */
export function isDMoneyCheckoutUrl(raw: string | null | undefined): raw is string {
if (!raw) return false;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
const host = parsed.hostname.toLowerCase();
return ALLOWED_HOSTS.some(
(allowed) => host === allowed || host.endsWith(`.${allowed}`),
);
return isHttpsHostMatch(raw, ALLOWED_HOSTS);
}
/** True only for https URLs whose host is (or is a subdomain of) an allowlisted telebirr host. */
export function isTelebirrCheckoutUrl(
raw: string | null | undefined,
): raw is string {
return isHttpsHostMatch(raw, TELEBIRR_ALLOWED_HOSTS);
}
/**
* Providers whose paygate `/go` is allowed to bounce to.
*
* This is the allowlist that keeps `/go` from being an open redirect, so it must
* stay tight: only hosts that (a) we actually initiate payments against and
* (b) need a portal-origin `Referer` to open.
*/
export function isBounceableCheckoutUrl(
raw: string | null | undefined,
): raw is string {
return isDMoneyCheckoutUrl(raw) || isTelebirrCheckoutUrl(raw);
}
/**