phase 7 blockers

This commit is contained in:
hamid
2026-08-02 22:30:24 +03:30
parent 9a55846df3
commit 90e0cdcc34
7 changed files with 104 additions and 10 deletions
+5
View File
@@ -778,6 +778,11 @@
"total_payable_label": "Total to pay", "total_payable_label": "Total to pay",
"secure_gateway_notice": "Secure payment via bank gateway", "secure_gateway_notice": "Secure payment via bank gateway",
"cta_pay": "Continue to payment", "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", "bnpl_option": "Or pay in installments",
"state_initiating": "Starting payment…", "state_initiating": "Starting payment…",
"state_redirecting": "Redirecting to the payment gateway…", "state_redirecting": "Redirecting to the payment gateway…",
+5
View File
@@ -778,6 +778,11 @@
"total_payable_label": "مبلغ قابل پرداخت", "total_payable_label": "مبلغ قابل پرداخت",
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی", "secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
"cta_pay": "ادامه پرداخت", "cta_pay": "ادامه پرداخت",
"gateway_title": "در حال انتقال به درگاه پرداخت",
"gateway_hint": "این صفحه نمایشی جایگزین درگاه بانکی است.",
"gateway_reference_label": "کد مرجع",
"gateway_pay_success": "پرداخت (نمایشی)",
"gateway_pay_fail": "انصراف",
"bnpl_option": "یا پرداخت اقساطی", "bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…", "state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…", "state_redirecting": "در حال انتقال به درگاه پرداخت…",
@@ -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 (
<Suspense fallback={<AppLoading />}>
<MockGatewayScreen />
</Suspense>
);
}
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 (
<PaymentStateCard icon="payment" tone="var(--bal-secondary)" title={t('gateway_title')} body={t('gateway_hint')}>
{transactionId ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gateway_reference_label')}:{' '}
<Box component="span" dir="ltr">
#{transactionId}
</Box>
</Typography>
) : null}
<Stack sx={{ gap: 1.5, alignItems: 'center', width: '100%' }}>
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
{t('gateway_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
{t('gateway_pay_fail')}
</AppButton>
</Stack>
</PaymentStateCard>
);
}
+2
View File
@@ -27,6 +27,8 @@ export const ROUTES = {
BOOKING_REQUEST_STATUS: '/bookings/request', BOOKING_REQUEST_STATUS: '/bookings/request',
// Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`. // Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`.
CHECKOUT: '/bookings/checkout', 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. // Return-from-gateway surface — pending-callback poll → succeeded/failed states.
CHECKOUT_RETURN: '/bookings/checkout/return', CHECKOUT_RETURN: '/bookings/checkout/return',
// Post-payment success screen — links to the booking detail + invoice. // Post-payment success screen — links to the booking detail + invoice.
-3
View File
@@ -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 ## A. The product is broken or unusable
### Payments ### 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 - **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 a customer sees can silently show hours more time than they actually have, and expire while they still
think they're fine. *(Effort: S)* think they're fine. *(Effort: S)*
+10 -1
View File
@@ -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) | | 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 | — | — | | 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 | — | — | | 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× | — | — | | 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 | — | | 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 | — | | 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 decision (booking-lifecycle's "today's visits" fix), since both are the same missing
`Asia/Tehran`/UTC-boundary discipline. `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 ## Suggested order
1. **01 (admin RBAC)** — unlocks 02, and makes 09/10/11/14 independently testable via the seeded 1. **01 (admin RBAC)** — unlocks 02, and makes 09/10/11/14 independently testable via the seeded
@@ -1,23 +1,28 @@
#nullable enable #nullable enable
using System.Net;
using Baya.Application.Contracts.Payments; using Baya.Application.Contracts.Payments;
namespace Baya.Infrastructure.CrossCutting.Seams; namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary> /// <summary>
/// Deterministic mock <see cref="IPaymentProvider"/> — no external call. <see cref="InitPaymentAsync"/> returns /// Deterministic mock <see cref="IPaymentProvider"/> — no external call. <see cref="InitPaymentAsync"/> returns
/// a stable fake reference derived from the request + idempotency key and a fake redirect URL; /// a stable fake reference derived from the request + idempotency key, and an app-relative <c>redirectUrl</c>
/// <see cref="VerifyAsync"/> instantly succeeds and echoes the expected amount (the server-side re-check always /// pointing at the client's mock-gateway harness (<c>bookings/checkout/gateway</c>, `ROUTES.CHECKOUT_GATEWAY`) —
/// passes in the mock); <see cref="RefundAsync"/> always succeeds (so b11 can call it). A real /// the client's `initiatePayment` success handler already branches on an absolute vs. relative URL, so this
/// ZarinPal/Sadad/Vandar/Jibit adapter (acquirer-with-تسهیم, Shaparak reference, sandbox flag from the /// harness round-trips without a real PSP; <see cref="VerifyAsync"/> instantly succeeds and echoes the expected
/// gateway's encrypted config) replaces this registration only — no mock behaviour is baked into a handler. /// amount (the server-side re-check always passes in the mock); <see cref="RefundAsync"/> 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.
/// </summary> /// </summary>
public sealed class MockPaymentProvider : IPaymentProvider public sealed class MockPaymentProvider : IPaymentProvider
{ {
public ValueTask<PaymentInitResult> InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default) public ValueTask<PaymentInitResult> InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{ {
var reference = $"mock-ref-{bookingRequestId}-{idempotencyKey}"; var reference = $"mock-ref-{bookingRequestId}-{idempotencyKey}";
var redirectUrl = $"/bookings/checkout/gateway?request_id={bookingRequestId}&transaction_id={WebUtility.UrlEncode(reference)}";
return ValueTask.FromResult(new PaymentInitResult( return ValueTask.FromResult(new PaymentInitResult(
RedirectUrl: $"https://mock-psp.local/pay/{reference}", RedirectUrl: redirectUrl,
GatewayReferenceCode: reference)); GatewayReferenceCode: reference));
} }