diff --git a/client/messages/en.json b/client/messages/en.json
index b7a70e9..f037bde 100644
--- a/client/messages/en.json
+++ b/client/messages/en.json
@@ -778,6 +778,11 @@
"total_payable_label": "Total to pay",
"secure_gateway_notice": "Secure payment via bank gateway",
"cta_pay": "Continue to payment",
+ "gateway_title": "Redirecting to the payment gateway",
+ "gateway_hint": "This is a demo page standing in for the bank gateway.",
+ "gateway_reference_label": "Reference",
+ "gateway_pay_success": "Pay (demo)",
+ "gateway_pay_fail": "Cancel",
"bnpl_option": "Or pay in installments",
"state_initiating": "Starting payment…",
"state_redirecting": "Redirecting to the payment gateway…",
diff --git a/client/messages/fa.json b/client/messages/fa.json
index 6922b8e..b411b6c 100644
--- a/client/messages/fa.json
+++ b/client/messages/fa.json
@@ -778,6 +778,11 @@
"total_payable_label": "مبلغ قابل پرداخت",
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
"cta_pay": "ادامه پرداخت",
+ "gateway_title": "در حال انتقال به درگاه پرداخت",
+ "gateway_hint": "این صفحه نمایشی جایگزین درگاه بانکی است.",
+ "gateway_reference_label": "کد مرجع",
+ "gateway_pay_success": "پرداخت (نمایشی)",
+ "gateway_pay_fail": "انصراف",
"bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…",
diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx
new file mode 100644
index 0000000..b6eeb80
--- /dev/null
+++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx
@@ -0,0 +1,71 @@
+'use client';
+import { Suspense } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { Box, Stack, Typography } from '@mui/material';
+import { AppButton, AppLoading, PaymentStateCard } from '@/components';
+import { ROUTES } from '@/constants';
+import {
+ CHECKOUT_QUERY_OUTCOME,
+ CHECKOUT_QUERY_REQUEST_ID,
+ CHECKOUT_QUERY_TRANSACTION_ID,
+} from '@/services/payment/constants';
+import type { GatewayReturnOutcome } from '@/services/payment/types';
+
+/**
+ * Mock-gateway harness — a **test harness, not a product feature**, mirroring the BNPL provider-handoff
+ * harness (`checkout/bnpl/gateway/page.tsx`). It stands in for the PSP so the initiate → redirect → return
+ * round-trip is exercisable without a real gateway: `MockPaymentProvider.InitPaymentAsync` points its
+ * `redirectUrl` here, and the pay/cancel buttons drive both outcome branches of the return surface (a real
+ * PSP redirects back after the cardholder pays or cancels). Deliberately reachable in every build, including
+ * production — unlike the BNPL harness, this one is NOT env-gated: this deployment is a demo with no real
+ * gateway wired up (`mvp/blockers.md` §B.5), so the mock stays reachable until a real acquirer is switched
+ * on, at which point `redirectUrl` becomes the PSP's absolute URL and this page is never reached.
+ */
+export default function MockGatewayPage() {
+ return (
+ }>
+
+
+ );
+}
+
+function MockGatewayScreen() {
+ const t = useTranslations('payment');
+ const locale = useLocale();
+ const router = useRouter();
+ const params = useSearchParams();
+
+ const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
+ const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
+
+ const returnWith = (outcome: GatewayReturnOutcome) => {
+ const query = new URLSearchParams({
+ [CHECKOUT_QUERY_REQUEST_ID]: requestId,
+ [CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
+ [CHECKOUT_QUERY_OUTCOME]: outcome,
+ });
+ router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
+ };
+
+ return (
+
+ {transactionId ? (
+
+ {t('gateway_reference_label')}:{' '}
+
+ #{transactionId}
+
+
+ ) : null}
+
+ returnWith('success')}>
+ {t('gateway_pay_success')}
+
+ returnWith('failure')}>
+ {t('gateway_pay_fail')}
+
+
+
+ );
+}
diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts
index 8856e55..795b15c 100644
--- a/client/src/constants/routes.ts
+++ b/client/src/constants/routes.ts
@@ -27,6 +27,8 @@ export const ROUTES = {
BOOKING_REQUEST_STATUS: '/bookings/request',
// Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`.
CHECKOUT: '/bookings/checkout',
+ // Mock-gateway harness (test-only stand-in for the PSP redirect; the mock initiate points here).
+ CHECKOUT_GATEWAY: '/bookings/checkout/gateway',
// Return-from-gateway surface — pending-callback poll → succeeded/failed states.
CHECKOUT_RETURN: '/bookings/checkout/return',
// Post-payment success screen — links to the booking detail + invoice.
diff --git a/mvp/blockers.md b/mvp/blockers.md
index a7fb16d..ba52b6e 100644
--- a/mvp/blockers.md
+++ b/mvp/blockers.md
@@ -15,9 +15,6 @@ Effort is a rough size, not a schedule: **S** = small/contained, **M** = a real
## A. The product is broken or unusable
### Payments
-- **A card payment can never actually complete.** The test/demo payment page redirects to a page that
- doesn't exist. Nothing about the money logic itself is wrong — it's the very last step, handing off to a
- real payment provider, that isn't connected. *(Effort: M)*
- **The 30-minute payment countdown can lie.** Booking deadlines are stored without a timezone, so the timer
a customer sees can silently show hours more time than they actually have, and expire while they still
think they're fine. *(Effort: S)*
diff --git a/mvp/fix-plan.md b/mvp/fix-plan.md
index 025a579..88b8045 100644
--- a/mvp/fix-plan.md
+++ b/mvp/fix-plan.md
@@ -22,7 +22,7 @@ whatever order you prefer.
| 04 | [payment-timezone](blocker-phases/04-payment-timezone.md) | The 30-minute payment countdown can lie | — | ✅ Done (follow-up filed below) |
| 05 | [bnpl-setup](blocker-phases/05-bnpl-setup.md) | Installments (BNPL) don't work at all | — | — |
| 06 | [catalog-admin-page](blocker-phases/06-catalog-admin-page.md) | No admin page for service categories/pricing | — | — |
-| 07 | [card-payment-redirect](blocker-phases/07-card-payment-redirect.md) | Card payment can never complete | — | — |
+| 07 | [card-payment-redirect](blocker-phases/07-card-payment-redirect.md) | Card payment can never complete | — | ✅ Done (follow-up filed below) |
| 08 | [refunds-demock](blocker-phases/08-refunds-demock.md) | Refunds are demo-only, off by 100× | — | — |
| 09 | [nurse-verification-badge](blocker-phases/09-nurse-verification-badge.md) | Verification badge doesn't reflect reality | pairs with 10 | — |
| 10 | [search-dedup-and-trust](blocker-phases/10-search-dedup-and-trust.md) | Search isn't de-duplicated; trust info hardcoded | pairs with 09 | — |
@@ -52,6 +52,15 @@ whatever order you prefer.
decision (booking-lifecycle's "today's visits" fix), since both are the same missing
`Asia/Tehran`/UTC-boundary discipline.
+- **ZarinPal-callback translator, latent until a real gateway is switched on.** Phase 07 restored the
+ mock-gateway harness and pointed `MockPaymentProvider.InitPaymentAsync`'s `redirectUrl` at it (relative
+ URL, no more dead `mock-psp.local` host) — the card-payment dead end (blockers.md § "Payments") is closed
+ for the mock path, which is the only path reachable pre-e-namad (§B.5). Once a real acquirer is ever
+ switched on, ZarinPal's callback redirects the browser with its **own** query params (`Authority`,
+ `Status`) — nothing under `Controllers/V1` today translates that into what `checkout/return/page.tsx`
+ expects (`request_id`/`transaction_id`/`outcome`). Needs a translating endpoint/route before the real path
+ can work; deliberately out of scope of the mock fix, and moot until §B.5 clears.
+
## Suggested order
1. **01 (admin RBAC)** — unlocks 02, and makes 09/10/11/14 independently testable via the seeded
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs
index a174c79..c5323ef 100644
--- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs
@@ -1,23 +1,28 @@
#nullable enable
+using System.Net;
using Baya.Application.Contracts.Payments;
namespace Baya.Infrastructure.CrossCutting.Seams;
///
/// Deterministic mock — no external call. returns
-/// a stable fake reference derived from the request + idempotency key and a fake redirect URL;
-/// instantly succeeds and echoes the expected amount (the server-side re-check always
-/// passes in the mock); always succeeds (so b11 can call it). A real
-/// ZarinPal/Sadad/Vandar/Jibit adapter (acquirer-with-تسهیم, Shaparak reference, sandbox flag from the
-/// gateway's encrypted config) replaces this registration only — no mock behaviour is baked into a handler.
+/// a stable fake reference derived from the request + idempotency key, and an app-relative redirectUrl
+/// pointing at the client's mock-gateway harness (bookings/checkout/gateway, `ROUTES.CHECKOUT_GATEWAY`) —
+/// the client's `initiatePayment` success handler already branches on an absolute vs. relative URL, so this
+/// harness round-trips without a real PSP; instantly succeeds and echoes the expected
+/// amount (the server-side re-check always passes in the mock); always succeeds (so
+/// b11 can call it). A real ZarinPal/Sadad/Vandar/Jibit adapter (acquirer-with-تسهیم, Shaparak reference,
+/// sandbox flag from the gateway's encrypted config) replaces this registration only — no mock behaviour is
+/// baked into a handler.
///
public sealed class MockPaymentProvider : IPaymentProvider
{
public ValueTask InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
var reference = $"mock-ref-{bookingRequestId}-{idempotencyKey}";
+ var redirectUrl = $"/bookings/checkout/gateway?request_id={bookingRequestId}&transaction_id={WebUtility.UrlEncode(reference)}";
return ValueTask.FromResult(new PaymentInitResult(
- RedirectUrl: $"https://mock-psp.local/pay/{reference}",
+ RedirectUrl: redirectUrl,
GatewayReferenceCode: reference));
}