fix: ( payments ) bounce D-Money checkout via /go and follow user domain

This commit is contained in:
Abubeker Yasin
2026-07-28 11:39:48 +03:00
parent 686350135d
commit 0d46f3715e
9 changed files with 101 additions and 41 deletions

View File

@@ -415,8 +415,18 @@ export class PaymentsController {
paySupplementaryCharge( paySupplementaryCharge(
@Param('token') token: string, @Param('token') token: string,
@Body() dto: PaySupplementaryChargeDto, @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') @Post('supplementary/:id/mark-paid')

View File

@@ -48,12 +48,14 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
]; ];
// Methods whose return/failure URLs are browser-facing pages on the passenger // 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 // portal, so they should follow whichever domain the user came in on. For DMONEY
// deliberately excluded — its return URL is a server-to-server webhook host, not // this is the preOrder `redirect_url` (the page the browser lands on after
// a page the browser lands on. // 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>([ const DOMAIN_AWARE_METHODS = new Set<PaymentMethodType>([
PaymentMethodType.TELEBIRR, PaymentMethodType.TELEBIRR,
PaymentMethodType.WAAFI, PaymentMethodType.WAAFI,
PaymentMethodType.DMONEY,
]); ]);
@Injectable() @Injectable()

View File

@@ -116,11 +116,21 @@ export class SupplementaryChargesService {
return updated; 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 charge = await this.getByToken(token); // validates status/expiry
const paymentMethod = method as ProviderMethod; 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 returnUrl = `${portalUrl}/pay-balance/${token}/success`;
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`; const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;

View File

@@ -4,6 +4,7 @@ import { Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Clock, Clock,
@@ -205,11 +206,10 @@ function BookingDetailContent() {
onSuccess: async (data: any) => { onSuccess: async (data: any) => {
setPaymentError(null); setPaymentError(null);
if ( // Any provider that hands back a REDIRECT (Telebirr, Waafi, D-Money) —
(selectedMethod === "TELEBIRR" || selectedMethod === "WAAFI") && // the action type is the signal, not the method name.
data?.clientAction?.type === "REDIRECT" if (data?.clientAction?.type === "REDIRECT" && data.clientAction.url) {
) { window.location.href = resolvePaymentRedirectUrl(data.clientAction.url);
window.location.href = data.clientAction.url;
return; return;
} }

View File

@@ -5,6 +5,7 @@ import { useBookingStore } from "@/lib/booking-store";
import { usePaymentStore } from "@/lib/payment-store"; import { usePaymentStore } from "@/lib/payment-store";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { PaymentMethod } from "@/types"; import { PaymentMethod } from "@/types";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -146,7 +147,7 @@ export default function PaymentPage() {
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') { if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId); setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION"); updateStatus("REQUIRES_ACTION");
window.location.href = data.clientAction.url; window.location.href = resolvePaymentRedirectUrl(data.clientAction.url);
return; return;
} }

View File

@@ -3,6 +3,8 @@
import { Suspense, useEffect, useMemo } from "react"; import { Suspense, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { isDMoneyCheckoutUrl } from "@/lib/payment-redirect";
/** /**
* /go — payment redirect bounce page for D-Money web checkout. * /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. * 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() { function RedirectView() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const raw = searchParams.get("url"); const raw = searchParams.get("url");
const target = useMemo(() => (isTrustedDMoneyUrl(raw) ? raw : null), [raw]); const target = useMemo(() => (isDMoneyCheckoutUrl(raw) ? raw : null), [raw]);
useEffect(() => { useEffect(() => {
if (!target) return; if (!target) return;

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { PaymentMethod } from "@/types"; import { PaymentMethod } from "@/types";
import { import {
Loader2, Loader2,
@@ -52,7 +53,7 @@ export default function PayBalancePage() {
}), }),
onSuccess: (data: any) => { onSuccess: (data: any) => {
if (data?.clientAction?.type === "REDIRECT") { if (data?.clientAction?.type === "REDIRECT") {
window.location.href = data.clientAction.url; window.location.href = resolvePaymentRedirectUrl(data.clientAction.url);
return; return;
} }
// Immediate success (e.g. wallet) // Immediate success (e.g. wallet)

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { PaymentMethod } from "@/types"; import { PaymentMethod } from "@/types";
import { import {
Loader2, Loader2,
@@ -63,7 +64,7 @@ export default function ReservationPayPage() {
}), }),
onSuccess: (data: any) => { onSuccess: (data: any) => {
if (data?.clientAction?.type === "REDIRECT") { if (data?.clientAction?.type === "REDIRECT") {
window.location.href = data.clientAction.url; window.location.href = resolvePaymentRedirectUrl(data.clientAction.url);
return; return;
} }
router.push(`/reserve/pay/${token}/success`); router.push(`/reserve/pay/${token}/success`);

View File

@@ -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=<encoded>` on the
* portal origin, which navigates from its own document so D-Money sees
* `Referer: <portal origin>`.
*
* 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)}`;
}