From 0d46f3715e5a1dd0894d829564adf4f52b375218 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 28 Jul 2026 11:39:48 +0300 Subject: [PATCH] fix: ( payments ) bounce D-Money checkout via /go and follow user domain --- .../modules/payments/payments.controller.ts | 12 +++- .../src/modules/payments/payments.service.ts | 8 ++- .../payments/supplementary-charges.service.ts | 14 ++++- .../portal/src/app/booking/detail/page.tsx | 10 ++-- .../portal/src/app/booking/payment/page.tsx | 3 +- .../portal/src/app/go/page.tsx | 30 +--------- .../src/app/pay-balance/[token]/page.tsx | 3 +- .../src/app/reserve/pay/[token]/page.tsx | 3 +- .../portal/src/lib/payment-redirect.ts | 59 +++++++++++++++++++ 9 files changed, 101 insertions(+), 41 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/lib/payment-redirect.ts diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 88510f1a5..7da37df33 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -415,8 +415,18 @@ export class PaymentsController { paySupplementaryCharge( @Param('token') token: string, @Body() dto: PaySupplementaryChargeDto, + @Headers('origin') origin?: string, + @Headers('referer') referer?: string, + @Headers('x-frontend-base-url') frontendBaseUrl?: string, ) { - return this.supplementaryService.pay(token, dto.method, dto.platform); + // Same domain-follows-the-user rule as /initiate — the self-pay page can be + // opened on either portal domain. + return this.supplementaryService.pay( + token, + dto.method, + dto.platform, + resolveAllowedOrigin(origin, referer, frontendBaseUrl), + ); } @Post('supplementary/:id/mark-paid') diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index bfc23cad6..8f3aabbab 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -48,12 +48,14 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ ]; // Methods whose return/failure URLs are browser-facing pages on the passenger -// portal, so they should follow whichever domain the user came in on. DMONEY is -// deliberately excluded — its return URL is a server-to-server webhook host, not -// a page the browser lands on. +// portal, so they should follow whichever domain the user came in on. For DMONEY +// this is the preOrder `redirect_url` (the page the browser lands on after +// checkout) — NOT `notify_url`, which is the server-to-server webhook and is +// configured provider-side, never rebased. const DOMAIN_AWARE_METHODS = new Set([ PaymentMethodType.TELEBIRR, PaymentMethodType.WAAFI, + PaymentMethodType.DMONEY, ]); @Injectable() diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index 9b62d433c..f8c316d87 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -116,11 +116,21 @@ export class SupplementaryChargesService { return updated; } - async pay(token: string, method: string, platform?: 'web' | 'mobile') { + async pay( + token: string, + method: string, + platform?: 'web' | 'mobile', + requestOrigin?: string | null, + ) { const charge = await this.getByToken(token); // validates status/expiry const paymentMethod = method as ProviderMethod; - const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + // Self-pay links are opened on whichever portal domain the recipient used + // (bookingedr.et vs passenger.edrsc.com), so the return pages must live on + // that same domain. `requestOrigin` is already allowlist-validated by the + // controller; PORTAL_URL is the fallback for non-browser callers. + const portalUrl = + requestOrigin ?? process.env.PORTAL_URL ?? 'http://localhost:5174'; const returnUrl = `${portalUrl}/pay-balance/${token}/success`; const failureUrl = `${portalUrl}/pay-balance/${token}/failed`; diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 67df46686..d42ab0dc3 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -4,6 +4,7 @@ import { Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; +import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { useEffect, useState } from "react"; import { Clock, @@ -205,11 +206,10 @@ function BookingDetailContent() { onSuccess: async (data: any) => { setPaymentError(null); - if ( - (selectedMethod === "TELEBIRR" || selectedMethod === "WAAFI") && - data?.clientAction?.type === "REDIRECT" - ) { - window.location.href = data.clientAction.url; + // Any provider that hands back a REDIRECT (Telebirr, Waafi, D-Money) — + // the action type is the signal, not the method name. + if (data?.clientAction?.type === "REDIRECT" && data.clientAction.url) { + window.location.href = resolvePaymentRedirectUrl(data.clientAction.url); return; } diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index c03397fee..d27a505fa 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -5,6 +5,7 @@ import { useBookingStore } from "@/lib/booking-store"; 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 { PaymentMethod } from "@/types"; import { format } from "date-fns"; @@ -146,7 +147,7 @@ export default function PaymentPage() { if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') { setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); - window.location.href = data.clientAction.url; + window.location.href = resolvePaymentRedirectUrl(data.clientAction.url); return; } diff --git a/apps/edr-passenger-web/portal/src/app/go/page.tsx b/apps/edr-passenger-web/portal/src/app/go/page.tsx index 7d2063022..b8fc8769f 100644 --- a/apps/edr-passenger-web/portal/src/app/go/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/go/page.tsx @@ -3,6 +3,8 @@ import { Suspense, useEffect, useMemo } from "react"; import { useSearchParams } from "next/navigation"; +import { isDMoneyCheckoutUrl } from "@/lib/payment-redirect"; + /** * /go — payment redirect bounce page for D-Money web checkout. * @@ -22,36 +24,10 @@ import { useSearchParams } from "next/navigation"; * See scripts/test-go-redirect.mjs. */ -const ALLOWED_HOSTS = ( - // 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); - -/** True only for https URLs whose host is (or is a subdomain of) an allowlisted host. */ -function isTrustedDMoneyUrl(raw: string | null): 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}`), - ); -} - function RedirectView() { const searchParams = useSearchParams(); const raw = searchParams.get("url"); - const target = useMemo(() => (isTrustedDMoneyUrl(raw) ? raw : null), [raw]); + const target = useMemo(() => (isDMoneyCheckoutUrl(raw) ? raw : null), [raw]); useEffect(() => { if (!target) return; diff --git a/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx index 2b2e69549..fb2135cd3 100644 --- a/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; +import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { PaymentMethod } from "@/types"; import { Loader2, @@ -52,7 +53,7 @@ export default function PayBalancePage() { }), onSuccess: (data: any) => { if (data?.clientAction?.type === "REDIRECT") { - window.location.href = data.clientAction.url; + window.location.href = resolvePaymentRedirectUrl(data.clientAction.url); return; } // Immediate success (e.g. wallet) diff --git a/apps/edr-passenger-web/portal/src/app/reserve/pay/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/reserve/pay/[token]/page.tsx index 8999ad939..8a8d9aa2e 100644 --- a/apps/edr-passenger-web/portal/src/app/reserve/pay/[token]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/reserve/pay/[token]/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; +import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { PaymentMethod } from "@/types"; import { Loader2, @@ -63,7 +64,7 @@ export default function ReservationPayPage() { }), onSuccess: (data: any) => { if (data?.clientAction?.type === "REDIRECT") { - window.location.href = data.clientAction.url; + window.location.href = resolvePaymentRedirectUrl(data.clientAction.url); return; } router.push(`/reserve/pay/${token}/success`); diff --git a/apps/edr-passenger-web/portal/src/lib/payment-redirect.ts b/apps/edr-passenger-web/portal/src/lib/payment-redirect.ts new file mode 100644 index 000000000..b235401d1 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/payment-redirect.ts @@ -0,0 +1,59 @@ +/** + * D-Money web checkout only opens when the request carries a whitelisted + * `Referer`. The paygate URL therefore must never be opened directly from an + * arbitrary page/origin — it has to bounce through `/go?url=` on the + * portal origin, which navigates from its own document so D-Money sees + * `Referer: `. + * + * Every place that follows a `clientAction.url` from `/payments/initiate` runs + * it through `resolvePaymentRedirectUrl()`; non-D-Money providers (Telebirr, + * Waafi, card) pass through untouched. + */ + +const ALLOWED_HOSTS = ( + // 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); + +/** + * Origin the `/go` bounce page is served from — must be the exact origin D-Money + * whitelists as `Referer`. Deliberately NOT NEXT_PUBLIC_SITE_URL: that one is the + * SEO canonical (layout/sitemap/robots) and may legitimately point at another + * portal domain (e.g. bookingedr.et), which D-Money would reject. Users on other + * domains bounce through here on purpose; `redirect_url` still returns them to + * the domain they started on. + */ +const BOUNCE_ORIGIN = ( + process.env.NEXT_PUBLIC_DMONEY_BOUNCE_ORIGIN ?? "https://passenger.edrsc.com" +).replace(/\/+$/, ""); + +/** 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}`), + ); +} + +/** + * Wraps a D-Money checkout URL in the `/go` bounce page; returns any other + * provider URL unchanged. The URL is percent-encoded — without it the query + * parser truncates the D-Money URL at its first `&` and merch_code/sign are lost. + */ +export function resolvePaymentRedirectUrl(url: string): string { + if (!isDMoneyCheckoutUrl(url)) return url; + return `${BOUNCE_ORIGIN}/go?url=${encodeURIComponent(url)}`; +}