mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: ( payments ) bounce D-Money checkout via /go and follow user domain
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`);
|
||||
|
||||
59
apps/edr-passenger-web/portal/src/lib/payment-redirect.ts
Normal file
59
apps/edr-passenger-web/portal/src/lib/payment-redirect.ts
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user