Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/TEST_DUES_ENGINEERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/officers/TEST_DUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions src/pages/community/TestDues.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
9 changes: 8 additions & 1 deletion src/pages/community/TestDues.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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()) {
Expand Down
Loading