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