mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +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(
|
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')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
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