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

View File

@@ -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>([
PaymentMethodType.TELEBIRR,
PaymentMethodType.WAAFI,
PaymentMethodType.DMONEY,
]);
@Injectable()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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)}`;
}