diff --git a/docs/TEST_DUES_ENGINEERING.md b/docs/TEST_DUES_ENGINEERING.md index 77bc843..34811d0 100644 --- a/docs/TEST_DUES_ENGINEERING.md +++ b/docs/TEST_DUES_ENGINEERING.md @@ -19,3 +19,7 @@ Server-only collections: `membershipTestOrders`, `membershipTestAccounts`, `memb For a future release, the reviewed path must package the isolated entrypoint, bind only the test Secret Manager references and club-owned account, qualify minimum runtime/IAM/secret access, webhook subscription and retry/reconciliation operation, prove raw-body signature handling and normal Auth/App Check on the hosted Function, then publish matching test-only Hosting. No secrets belong in tickets/chat/logs. The separate `staging-test-dues-release.js` path packages only this entrypoint and requires existing verified private setup. Ordinary community release scripts do not deploy it; the explicit test-dues Hosting mode requires matching dues backend/archive/authority readback before enabling its flag. Use a fresh fictional marker/account/event and exact-owned cleanup; never reuse #731. Actual sandbox/live evidence, provider changes, real policy approval and production rollout remain separate. Local tests use transactional doubles and the installed SDK's real signature verifier, with no outside network. Hosted Node22 CI runs `testMembershipDues.emulator.test.js` under native Firestore/Auth emulators and a synthetic provider: it proves native order/activation races and free-event admission, not Stripe API behavior. Frontend tests cover consent, account/session fences, unknown responses, expiry without polling, saved retry/reload and verified test URL navigation. + +## Saved Checkout clarification — #747 + +The screen reports a reconciliation stop when a validated Resume result has no Checkout URL and the subsequent fresh server status still identifies the same open, inactive purchase. A newer active/held status takes precedence. Identity/session changes and freshness expiry clear the message through the existing request fences. The message does not infer provider expiry or payment failure and does not prepare, unlock or replace a purchase. No schema, backend, Rules, provider or migration change is required. Hosted verification is **NOT AVAILABLE YET** until the separately recorded #747 release and fresh-fixture test. diff --git a/docs/officers/TEST_DUES.md b/docs/officers/TEST_DUES.md index 09d751a..8a5704d 100644 --- a/docs/officers/TEST_DUES.md +++ b/docs/officers/TEST_DUES.md @@ -23,7 +23,7 @@ 11. Open Membership and free events and accept the separate event test waiver. 12. Sign up for the named free event and confirm your place. 13. Ask the specialist which failure checks are covered by the supplied native receipt. The dated receipts distinguish the core screens, later native failures and injected tests. They do not qualify every provider method or outage. -14. Refresh status or resume the saved checkout after an interrupted session. If it cannot reopen, stop and ask Dave for reconciliation; do not pay again. +14. Refresh status or resume the saved checkout after an interrupted session. If the screen says “Checkout could not be reopened,” keep the saved purchase, stop and ask Dave for reconciliation. Do not start another payment. This clarification is **NOT AVAILABLE YET** until the #747 staging release is verified. 15. Sign out and ask the specialist to remove the exact disposable accounts and event. Keep test payment evidence for reconciliation. **Expected result:** one saved purchase belongs to the payer. A return URL proves nothing. Only verified test payment for the exact account, amount, currency, order and term can activate the canonical membership used by free events. Original term and consent evidence are retained. The current screen expires within 60 seconds or sooner at a known deadline; it never polls. A family purchase creates one private Family ID and the payer's membership. Invitations and other family-account enrollment are **NOT AVAILABLE YET**; an ID alone grants nothing. diff --git a/src/pages/community/TestDues.test.tsx b/src/pages/community/TestDues.test.tsx index 1bc6e87..dd5126b 100644 --- a/src/pages/community/TestDues.test.tsx +++ b/src/pages/community/TestDues.test.tsx @@ -218,3 +218,63 @@ test('an unbound prepared order offers resume rather than a doomed payment check expect(await screen.findByRole('button', { name: 'Resume saved checkout' })).toBeEnabled(); expect(screen.queryByRole('button', { name: 'Check payment status' })).not.toBeInTheDocument(); }); + +const recoveryMessage = 'Checkout could not be reopened. Keep this saved purchase and ask the test owner for reconciliation. Do not start another payment.'; +test('resume without a Checkout URL explains the retained open purchase without navigation or prepare', async () => { + call.mockResolvedValue({ ...state(), order }); + render(show()); + const resume = await screen.findByRole('button', { name: 'Resume saved checkout' }); + call.mockResolvedValueOnce({ order }); + fireEvent.click(resume); + expect(await screen.findByText(recoveryMessage)).toBeInTheDocument(); + expect(screen.getByText('Status: open.')).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(openTestCheckout).not.toHaveBeenCalled(); + expect(call.mock.calls.map((c) => c[2])).toEqual([ + { action: 'status', accountUid: 'fictional' }, + { action: 'resume', accountUid: 'fictional' }, + { action: 'status', accountUid: 'fictional' } + ]); + fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); + await screen.findByText('Current test status loaded.'); + expect(screen.queryByText(recoveryMessage)).not.toBeInTheDocument(); +}); +test.each(['active', 'held'])('fresh %s status supersedes an earlier open resume result', async (nextState) => { + call.mockResolvedValue({ ...state(), order }); + render(show()); + const resume = await screen.findByRole('button', { name: 'Resume saved checkout' }); + call.mockResolvedValueOnce({ order }).mockResolvedValueOnce({ + ...state(), + order: { ...order, state: nextState }, + active: nextState === 'active', + accessThroughMs: nextState === 'active' ? order.endsAtMs : null + }); + fireEvent.click(resume); + await screen.findByText('Current test status loaded.'); + expect(screen.queryByText(recoveryMessage)).not.toBeInTheDocument(); + expect(screen.getByText(`Status: ${nextState}.`)).toBeInTheDocument(); + expect(openTestCheckout).not.toHaveBeenCalled(); +}); +test.each(['session', 'account'])('%s changes discard a pending resume message and the displayed message', async (change) => { + call.mockResolvedValue({ ...state(), order }); + const view = render(show()); + const resume = await screen.findByRole('button', { name: 'Resume saved checkout' }); + call.mockResolvedValueOnce({ order }); + fireEvent.click(resume); + await screen.findByText(recoveryMessage); + let resolve: (v: any) => void = () => {}; + call.mockReturnValueOnce(new Promise((r) => { resolve = r; })); + fireEvent.click(screen.getByRole('button', { name: 'Resume saved checkout' })); + call.mockResolvedValue(state()); + await act(async () => { + if (change === 'account') mockUser = { uid: 'another-fictional' }; + mockAuth.currentUser = { uid: mockUser.uid, emailVerified: true }; + mockCallback(mockAuth.currentUser); + view.rerender(show()); + }); + await screen.findByRole('checkbox'); + await act(async () => { resolve({ order }); }); + expect(screen.queryByText(recoveryMessage)).not.toBeInTheDocument(); + expect(screen.queryByText('Your saved purchase')).not.toBeInTheDocument(); + expect(openTestCheckout).not.toHaveBeenCalled(); +}); diff --git a/src/pages/community/TestDues.tsx b/src/pages/community/TestDues.tsx index fe5b6cc..16c43c0 100644 --- a/src/pages/community/TestDues.tsx +++ b/src/pages/community/TestDues.tsx @@ -102,6 +102,7 @@ export default function TestDues() { setAccepted(false); setMessage(''); try { + let unopenedOrderId: string | undefined; if (request.action !== 'status') { const result = parseDuesResult(await callTestDues(functions, auth, request)); if (!valid()) return; @@ -110,6 +111,9 @@ export default function TestDues() { openTestCheckout(result.checkoutUrl); return; } + if (request.action === 'resume' && result.order.state === 'open') { + unopenedOrderId = result.order.id; + } } const began = performance.now(); const wall = Date.now(); @@ -127,7 +131,10 @@ export default function TestDues() { setSnapshot({ identity: capture, value, began, wall, budget }); - setMessage('Current test status loaded.'); + setMessage(unopenedOrderId === value.order?.id + && value.order?.state === 'open' && !value.active + ? 'Checkout could not be reopened. Keep this saved purchase and ask the test owner for reconciliation. Do not start another payment.' + : 'Current test status loaded.'); } catch { if (valid()) {