From 2eaf78fabbaa41e9085168fd81c4488ef823c833 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Fri, 16 Oct 2026 18:54:44 -0700 Subject: [PATCH 1/4] Build isolated test dues checkout and automatic payer membership --- .github/lint-baseline.json | 22 +- GITHUB_ISSUES.md | 4 +- IMPLEMENTATION_PLAN.md | 4 +- OFFICER_START_HERE.md | 4 +- OPERATIONS_RUNBOOK.md | 4 +- SECURITY.md | 4 +- STRIPE_COMMERCE_DESIGN.md | 2 + SYSTEM_DESIGN.md | 4 +- docs/TEST_DUES_ENGINEERING.md | 21 ++ docs/officers/MEMBER_SCREEN.md | 2 +- docs/officers/README.md | 4 +- docs/officers/SYSTEM_MAPS.md | 15 + docs/officers/TEST_DUES.md | 39 +++ functions/duesTestOnly.js | 29 ++ functions/membershipDuesCheckout.js | 4 +- functions/membershipDuesPayment.js | 4 +- functions/testDuesHandlers.js | 29 ++ functions/testDuesHandlers.test.js | 21 ++ functions/testDuesPolicy.js | 49 +++ functions/testDuesStripe.js | 79 +++++ functions/testDuesStripe.test.js | 62 ++++ functions/testMembershipDues.emulator.test.js | 51 +++ functions/testMembershipDues.js | 175 +++++++++++ functions/testMembershipDues.test.js | 126 ++++++++ src/App.jsx | 3 + src/pages/community/Community.tsx | 4 + src/pages/community/TestDues.test.tsx | 197 ++++++++++++ src/pages/community/TestDues.tsx | 291 ++++++++++++++++++ .../community/testDuesService.test.ts | 87 ++++++ src/services/community/testDuesService.ts | 196 ++++++++++++ tests/ci-workflow.test.js | 2 +- tests/firestore-rules/shop-and-locked.test.js | 8 + 32 files changed, 1526 insertions(+), 20 deletions(-) create mode 100644 docs/TEST_DUES_ENGINEERING.md create mode 100644 docs/officers/TEST_DUES.md create mode 100644 functions/duesTestOnly.js create mode 100644 functions/testDuesHandlers.js create mode 100644 functions/testDuesHandlers.test.js create mode 100644 functions/testDuesPolicy.js create mode 100644 functions/testDuesStripe.js create mode 100644 functions/testDuesStripe.test.js create mode 100644 functions/testMembershipDues.emulator.test.js create mode 100644 functions/testMembershipDues.js create mode 100644 functions/testMembershipDues.test.js create mode 100644 src/pages/community/TestDues.test.tsx create mode 100644 src/pages/community/TestDues.tsx create mode 100644 src/services/community/testDuesService.test.ts create mode 100644 src/services/community/testDuesService.ts diff --git a/.github/lint-baseline.json b/.github/lint-baseline.json index 34e9aa47..61c4e119 100644 --- a/.github/lint-baseline.json +++ b/.github/lint-baseline.json @@ -10,12 +10,12 @@ "targets": [ "src" ], - "scannedFileCount": 133, + "scannedFileCount": 137, "scannedFilesByExtension": { ".js": 12, ".jsx": 35, - ".ts": 50, - ".tsx": 36 + ".ts": 52, + ".tsx": 38 }, "errorCount": 113, "warningCount": 6, @@ -31,7 +31,7 @@ ".ts": 0, ".tsx": 6 }, - "fingerprint": "sha256:f2e9add8612f9e8be9e77ae321ee247b21439f5f5999cbeca43dfe2a5cb46c31", + "fingerprint": "sha256:b4fdd4e8efba6c011d293d96f8453a4e2dccccddf7907e2ff9b67cf2b74ba81f", "scannedFiles": [ "src/App.jsx", "src/App.test.jsx", @@ -85,6 +85,8 @@ "src/pages/community/CommunityAdmin.tsx", "src/pages/community/FictionalRaces.test.tsx", "src/pages/community/FictionalRaces.tsx", + "src/pages/community/TestDues.test.tsx", + "src/pages/community/TestDues.tsx", "src/pages/contact/Contact.jsx", "src/pages/events/EventCalendar.tsx", "src/pages/events/EventDetail.tsx", @@ -134,6 +136,8 @@ "src/services/community/communityService.ts", "src/services/community/fictionalRaceService.test.ts", "src/services/community/fictionalRaceService.ts", + "src/services/community/testDuesService.test.ts", + "src/services/community/testDuesService.ts", "src/services/community/testSupport/fictionalRaceFixtures.ts", "src/services/events/adminService.ts", "src/services/events/eventsService.ts", @@ -254,7 +258,7 @@ "endColumn": 25, "severity": 2, "ruleId": "react/jsx-no-useless-fragment", - "message": "Fragments should contain more than one child - otherwise, there’s no need for a Fragment at all.", + "message": "Fragments should contain more than one child - otherwise, there\u2019s no need for a Fragment at all.", "messageId": "NeedsMoreChildren", "nodeType": "JSXFragment", "fatal": false @@ -1346,7 +1350,7 @@ "endColumn": 9, "severity": 2, "ruleId": "react/jsx-one-expression-per-line", - "message": "` Seal Point Park → 3rd Ave Launch → Bridgeview Park → back ` must be placed on a new line", + "message": "` Seal Point Park \u2192 3rd Ave Launch \u2192 Bridgeview Park \u2192 back ` must be placed on a new line", "messageId": "moveToNewLine", "nodeType": "JSXText", "fatal": false @@ -1463,7 +1467,7 @@ "endColumn": 11, "severity": 2, "ruleId": "react/jsx-one-expression-per-line", - "message": "` — payment processing (subject to Stripe's Privacy Policy) ` must be placed on a new line", + "message": "` \u2014 payment processing (subject to Stripe's Privacy Policy) ` must be placed on a new line", "messageId": "moveToNewLine", "nodeType": "JSXText", "fatal": false @@ -1476,7 +1480,7 @@ "endColumn": 11, "severity": 2, "ruleId": "react/jsx-one-expression-per-line", - "message": "` — hosting, auth, database, email delivery ` must be placed on a new line", + "message": "` \u2014 hosting, auth, database, email delivery ` must be placed on a new line", "messageId": "moveToNewLine", "nodeType": "JSXText", "fatal": false @@ -1489,7 +1493,7 @@ "endColumn": 11, "severity": 2, "ruleId": "react/jsx-one-expression-per-line", - "message": "` — transactional email delivery ` must be placed on a new line", + "message": "` \u2014 transactional email delivery ` must be placed on a new line", "messageId": "moveToNewLine", "nodeType": "JSXText", "fatal": false diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 74a8add0..1e4e7a75 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,6 +1,8 @@ # GitHub Issue Backlog: Secure Commerce Program -**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The markup correction is source-only until its matching Hosting release is recorded in [#739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739); no Function rebuild or permission change is needed. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). + +**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). **COMMUNITY-004 / [#737](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/737) — SOURCE IN REVIEW:** October 15 owner rule requires current paid membership plus existing verified email and waiver for new free-event entries. The transaction checks current authority after reads; cancellation after expiry/suspension and immutable retry remain safe. Unit/UI and emulator regressions cover nonmembers, exact expiry, suspension, wrong UID, forgery, final seats and no-write denials. $25 individual/$30 family calendar-year policy is recorded, not activated; online dues and separate family accounts under a paid Family ID are selected for first launch; minor/privacy/retention and actual payment integration remain separate. Requested [policy drafts](./docs/legal/MEMBER_LAUNCH_DRAFTS.md) are unapproved and unused. No deployed/prod/real-payment claim; #731 stays closed with final6186640177. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 5bc18537..af9efd8d 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,6 +1,8 @@ # MPRC Secure Commerce Implementation Plan -**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The markup correction is source-only until its matching Hosting release is recorded in [#739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739); no Function rebuild or permission change is needed. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). + +**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). **#731 native tests complete — 2026-10-14, handoff gated:** actual fictional lifecycle/privacy/expiry/mobile plus 38 native evaluations/27 distinct security/replay checks passed. UI response discarded only after real server commit recovered through the exact saved action with all six protected update times unchanged. Recipient-loss replay/revoke and terminal-state/caller-revocation protection passed. Two fresh accounts/26 owned records removed with fences/immediate+late absence; browser test credentials/cache cleared and unused receiver closed. Final browser-permission confirmation and reviewed documentation/current-CI receipt remain before issue closure or supervised use with fresh fixtures. No new deploy/build/service/IAM/Rules/provider/production/payment work. [Dated native receipt](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186206758); earlier #731/#732 source/anonymous-only milestones below are historical. Whole-backend completion is not claimed. diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 95915893..71ea3be2 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -1,6 +1,8 @@ # MPRC Website: Officer Start Here -**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The markup correction is source-only until its matching Hosting release is recorded in [#739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739); no Function rebuild or permission change is needed. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). + +**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). **Minimum member launch — October 15 decisions; #737 SOURCE ONLY:** Dave set $25 individual/$30 family calendar-year dues. New free-event signup requires verified email, accepted waiver and current paid membership; no additional discretionary member approval follows satisfied requirements. The new source checks membership in the booking transaction and preserves cancellation after expiry. This change is **NOT AVAILABLE YET** on the deployed website. Online dues are selected for first launch; separate family accounts share a paid Family ID. Final policy/guardian handling remains undecided. Review the [privacy/waiver drafts](./docs/legal/MEMBER_LAUNCH_DRAFTS.md) and [source-only test procedure](./docs/officers/STAGING_COMMUNITY.md#current-membership-for-free-events--737-source-only). This source change adds no checkout or public external-payment instructions; paid-race commerce remains later. Earlier source checkpoints below are historical. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 0b57ef65..a89d4405 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -1,6 +1,8 @@ # MPRC Platform Operations Runbook -**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The markup correction is source-only until its matching Hosting release is recorded in [#739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739); no Function rebuild or permission change is needed. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). + +**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). **#731 native acceptance/handoff — 2026-10-14:** [current officer procedure](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared) separates actual native/UI proof from final acceptance and cleanup gates. Existing v6/669e2fb/101386-byte94380430 archive and Hosting e21be35 remain unchanged; no redeploy is needed for these documentation facts. Actual lost-response retry, current authority/privacy, natural expiry and recipient-loss recovery passed. Two fresh fake accounts/26 exact owned records were removed with identity/create-time/update-time fences and immediate/late absence; private browser helper/Auth/matching staging SDK cache were cleared. The unused receiver closed with zero captures and a refused port. The specialist must record the human's temporary browser-permission OFF confirmation and owned-tab closure before final handoff. No workaround for blocked browser settings; no accounts/server left waiting for that input. Fresh fixtures, final receipt and Dave's supervision are required; production/payments stay untouched. Earlier #731/#732 source/anonymous-only notes below are historical. diff --git a/SECURITY.md b/SECURITY.md index 12709772..07a6a58e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,8 @@ # MPRC Security, Privacy, and Risk Plan -**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The markup correction is source-only until its matching Hosting release is recorded in [#739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739); no Function rebuild or permission change is needed. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). + +**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). **#731 native staging security — 2026-10-14:** [native receipt](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/731#issuecomment-6186206758) records real verified password Auth and normal App Check: isolated missing/invalid Auth or attestation401, caller substitution403, extra authority/malformed input400, current/expired marker/capability and current native account denials403. Populated status omits other UIDs/contact/waiver/count/capacity. Firebase refuses unsupported tenant sign-in with INVALID_TENANT_ID; no tenant or valid nondefault-tenant token was created. Actual UI response loss after commit, exact retry without writes, terminal-state protection, recipient-loss replay/revoke and revoked-caller replay passed. No debug attestation/custom claims, new permission, Rule allowance, production or money action. Supervised use still requires the complete final cleanup/acceptance receipt and fresh fixtures. Earlier #731/#732 source/anonymous-only notes below are historical. diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 55e6abd2..0c392920 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -1,5 +1,7 @@ # Stripe, Race Registration, and Merchandise Design +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). + **#731 fictional native acceptance — 2026-10-14:** actual signup, full fictional credit return, same-bib recipient transfer, revoke and original credit ownership passed in staging. Native committed/lost-response retry preserved one seat/receipt/audit, did not resurrect cancellation and could not bypass current authority. [Proof and supervised gate](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared). These integer-credit simulations are not provider-backed payment/refund evidence; no real policy, finance authorization, Stripe object or production launch changed. Complete acceptance/cleanup receipt and fresh fake fixtures remain prerequisites; earlier #731/#732 source-only notes below are historical. **#731 staging deployment checkpoint — 2026-10-13:** Function v6/website `669e2fb` and their archive/public assets are verified. Fictional credits remain disconnected from Stripe and real money; signed-in race/cleanup acceptance is pending. [Current handoff](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared). Earlier source-only/not-deployed notes are historical. No real policy, provider payment or production launch is approved. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index da9d2e0f..14d48b09 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,6 +1,8 @@ # MPRC Platform System Design -**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The markup correction is source-only until its matching Hosting release is recorded in [#739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739); no Function rebuild or permission change is needed. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). +**Test annual dues — #742, source preparation only:** a separate, default-disabled test checkout now saves one server-priced purchase per fictional account and activates the payer from verified Stripe test state. No manual membership approval follows satisfied checks. No sandbox connection, dues Function/webhook deployment or dues Hosting publication is supplied; real payments and family invitation enrollment remain unavailable. [Officer review](./docs/officers/TEST_DUES.md), [engineering boundary](./docs/TEST_DUES_ENGINEERING.md). + +**Member-screen staging checkpoint — #739, October 16, 2026:** Function v7 and Hosting source `799565e` are verified, including the downloaded archive, all 62 website files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks with fresh fictional fixtures and verified cleanup. Full-site screenshots exposed a fixed-header style collision on the member navigation. The navigation correction is now published at `d08a6a3`; all 62 final assets, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed in [closed #739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). Function v7 remains unchanged. Online dues and real membership/payments remain unavailable. [Officer review](./docs/officers/MEMBER_SCREEN.md). **#731 native staging acceptance — 2026-10-14:** actual lifecycle/privacy/expiry/mobile and 38 native evaluations/27 distinct checks passed, including isolated Auth/App Check/caller/current-authority denials and actual UI committed/lost-response saved retry. Same existing v6/669e2fb archive and Hosting e21be35; no architecture/runtime/Rules/IAM/dependency/provider/production change. Exact owned accounts/records and browser SDK state were cleaned. Use remains gated on the complete dated acceptance/cleanup receipt and fresh fake fixtures; #731 stays open until final handoff gates are recorded. [Current officer handoff](./docs/officers/STAGING_COMMUNITY.md#fictional-race-member-screen--731-source-prepared). Earlier #731/#732 source/anonymous-only checkpoints below are historical, not current test status. The full backend/real-payment/public-launch objective remains incomplete. diff --git a/docs/TEST_DUES_ENGINEERING.md b/docs/TEST_DUES_ENGINEERING.md new file mode 100644 index 00000000..6117e2de --- /dev/null +++ b/docs/TEST_DUES_ENGINEERING.md @@ -0,0 +1,21 @@ +# Test-only annual dues runtime — #742 + +This bounded staging integration consumes `membershipDuesCheckout`, `membershipDuesPayment` and `membershipAuthority`. It is isolated in `duesTestOnly.js`; neither legacy `index.js` nor `communityOnly.js` imports it. No provider connection or deployment is supplied. Existing member/event records remain compatible; no migration or roster import. + +## Trust and transitions + +Native callable Auth/App Check, matching caller/default tenant, recent verified token and current enabled/verified Auth metadata precede requests. The private `membershipTestParticipants/{uid}` marker must be current and synthetic; it is never a profile role. Exact staging or all three explicit loopback emulator hosts under a demo project, `COMMERCE_ENABLED=false` and `MPRC_TEST_DUES_ENABLED=true` are required. The frontend additionally needs its default-off staging flag. There is no production/live fallback. + +Server offers fix 2500/3000 integer USD cents, LA calendar boundaries and a synthetic consent version/hash. No future term, automatic renewal or public external-payment instructions. One account lock and request-derived order ID are committed with immutable expectation, nonce, price, consent, expiration and provider idempotency key before any Checkout creation. Parallel commands/plans conflict; an exact request reuses byte-identical provider parameters. Unbound creation retries stop after five minutes, well within the provider's idempotency retention. Past that horizon the unresolved order stays held for private reconciliation; never guess that it is safe to charge again. Bound open Sessions are retrieved for resume. Verified empty expired Sessions may release the purchase lock; paid/held/pending orders cannot be discarded for a replacement purchase in the same year. + +The adapter verifies the actual Stripe account for each operation, accepts only test credentials and objects, fixes Checkout URLs, and projects no customer/contact data. Raw signed webhook bodies are bounded and verified before a minimal durable event receipt. SDK retrieval checks Session, nonce/order/client reference, test realm, amount/currency and current PaymentIntent/Charge, including refunds/disputes before an older paid callback. Charge reversals resolve the exact Session with a bounded PaymentIntent query. Invalid or foreign observations grant nothing. Duplicate/out-of-order deliveries re-read current provider state and cannot regress an active/held term. Cancellation return is a read, never a payment or refund command. + +Verified paid evidence, immutable term receipt, canonical `communityMemberships/{uid}` activation, optional private family root and transition audit are atomic. Changed revision/withdrawal/suspension/association, invalid current account/participant or an expired original term leaves durable paid-pending evidence. No silent rebase, renewal, unsuspension or refund. A defensive test reversal holds the order permanently and suspends only the authority still bound to that payment; it cannot revoke a later unrelated term. This is deliberately not real refund policy. Auth metadata is rechecked immediately before the transaction but cannot be atomically locked with Firestore; native distributed revocation guarantees are not claimed. + +Server-only collections: `membershipTestOrders`, `membershipTestAccounts`, `membershipTestParticipants`, `membershipTestTerms`, `membershipTestFamilies`, `membershipTestEvents`, `membershipTestAudit`, `membershipTestLimits`. Existing default-deny Rules are unchanged; denial regressions cover anonymous/member/admin reads and admin create/update/delete. No claims, mail, scheduled worker, dependency, payment-method storage or direct browser database access. Checkout URLs exist only in an immediate response and navigation, never saved records/logs. All outward errors are fixed and redacted. + +## Release still required + +A separate reviewed release 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. Existing community release scripts do not deploy this entrypoint. 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. diff --git a/docs/officers/MEMBER_SCREEN.md b/docs/officers/MEMBER_SCREEN.md index fd406814..4054ace6 100644 --- a/docs/officers/MEMBER_SCREEN.md +++ b/docs/officers/MEMBER_SCREEN.md @@ -1,6 +1,6 @@ # Review membership and free events -**Status, October 16, 2026:** native member behavior is staging-verified from `799565e`: Function v7/archive/settings, all 62 Hosting files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks passed. Fresh fictional fixtures were removed and their absence checked. A full-site visual check found member links inheriting the fixed site header styles. The correction needs its matching Hosting release; use [the latest #739 receipt](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739) before reviewing the screen. Production and real payments are **NOT AVAILABLE YET**. Earlier local-only screenshots are historical. +**Status, October 16, 2026:** native member behavior is staging-verified from `799565e`: Function v7/archive/settings, all 62 Hosting files, genuine Auth/App Check and ten member/retry/expiry/cancellation checks passed. Fresh fictional fixtures were removed and their absence checked. A full-site visual check found member links inheriting the fixed site header styles. The correction is published at `d08a6a3`; all 62 final file hashes, four deep routes, genuine sign-in, desktop/390px navigation/focus and fresh cleanup passed. Use [the final #739 receipt](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739) before reviewing the screen. Production and real payments are **NOT AVAILABLE YET**. Earlier local-only screenshots are historical. The specialist's native checks used ordinary browser attestation and separately supplied synthetic terms. They do not prove email delivery, online dues, officer MFA, Stripe or production behavior. All permissions and resource limits remain unchanged. diff --git a/docs/officers/README.md b/docs/officers/README.md index c2bd64ba..7ee24637 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -1,6 +1,8 @@ # Officer Website Handbook -**October 16 member-screen preparation:** [Review membership and free events](./MEMBER_SCREEN.md). Native staging behavior and cleanup passed; the latest #739 receipt must also verify the navigation correction. No production or payment release. +[Test annual dues](./TEST_DUES.md) is source prepared and **NOT AVAILABLE YET**. The club-owned sandbox and isolated dues release are still required; real payments remain separate. + +**October 16 member-screen preparation:** [Review membership and free events](./MEMBER_SCREEN.md). Native staging behavior, final navigation correction and cleanup passed at Hosting `d08a6a3`. No production or payment release. **Minimum member launch — October 15 decisions; #737 SOURCE ONLY:** Dave set $25 individual/$30 family calendar-year dues. New free-event signup requires verified email, accepted waiver and current paid membership; no additional discretionary member approval follows satisfied requirements. The new source checks membership in the booking transaction and preserves cancellation after expiry. This change is **NOT AVAILABLE YET** on the deployed website. Online dues are selected for first launch; separate family accounts share a paid Family ID. Final policy/guardian handling remains undecided. Review the [privacy/waiver drafts](../legal/MEMBER_LAUNCH_DRAFTS.md) and [source-only test procedure](./STAGING_COMMUNITY.md#current-membership-for-free-events--737-source-only). This source change adds no checkout or public external-payment instructions; paid-race commerce remains later. Earlier source checkpoints below are historical. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 717e6a12..6930cc2f 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -429,3 +429,18 @@ flowchart TD ``` In words: stop and escalate anything involving privacy, access, money, or secrets; otherwise use one reviewed rollback and check every affected service. + +## Test annual dues — #742 source only, NOT AVAILABLE YET + +```mermaid +flowchart LR + A[Verified fictional account and current private marker] --> B[Native Auth and App Check test dues boundary] + B --> C[Saved order and one account purchase lock] + C --> D[Club Stripe sandbox - not connected] + D --> E[Signed receipt and verified current test payment] + E --> F[Atomic term receipt and payer membership] + F --> G[Existing current-member free-event gate] + F --> H[Private family root - invitations unavailable] +``` + +Text alternative: a marked fictional account prepares one saved test purchase; only verified sandbox payment can atomically activate the payer for free events, and family invitations remain separate. Source and emulator proof do not prove provider configuration or deployment. diff --git a/docs/officers/TEST_DUES.md b/docs/officers/TEST_DUES.md new file mode 100644 index 00000000..4bf43ff3 --- /dev/null +++ b/docs/officers/TEST_DUES.md @@ -0,0 +1,39 @@ +# Review annual dues in test mode + +**October 16, 2026: source preparation only; NOT AVAILABLE YET.** #742 adds a separate, disabled test-only checkout and payer activation flow. The club's Stripe sandbox has not been identified or connected. No dues Function, webhook, secret access or dues screen is deployed by this change. The existing member/free-event staging release remains qualified by [#739](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/739). + +**Purpose:** confirm that one fictional purchase activates the payer automatically and allows free-event signup, without a second officer membership approval. + +**Approver:** Dave Liu. Exact-head Opus 5.5 medium/max APPROVE plus green CI counts as delegated code approval. Stripe account connection, provider settings, legal wording and real money require their separate owner evidence. No backup release owner has been appointed. + +**Prerequisites:** the specialist supplies the exact staging release receipt, a club-owned Stripe sandbox, its matching test webhook, a fresh owner-marked fictional verified account and ordinary App Check. Every test account needs a current private test-participant marker. The specialist verifies all deployed surfaces before supplying a review link. Do not use the sealed #731 fixtures or real information. + +## Planned screen review — NOT AVAILABLE YET + +1. Ask the specialist to connect and qualify the sandbox through the club's private provider setup. +2. Open the supplied staging dues link and confirm the test-only notice. +3. Sign in with the supplied fictional account. +4. Read the two server offers: $25 individual or $30 family in test USD. +5. Confirm the term is the current full calendar year in Pacific time, with no automatic renewal. +6. Read the synthetic waiver and deliberately accept it. +7. Continue to the selected Stripe test checkout. +8. Use only the specialist's provider-approved test instructions. +9. Return to the dues page and choose Check payment status. +10. Wait for verified active test membership. +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 to demonstrate a lost response, a duplicate request, unpaid checkout, expired term and revoked test access. +14. Refresh status or resume the saved checkout after an interrupted session. +15. Sign out and ask the specialist to remove the exact new fixtures. + +**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. + +**Stop conditions:** real information or money is requested; the test notice disappears; another account's state appears; an unresolved purchase offers another charge; an old term extends into a new year; a failed read leaves controls; or a provider, account, App Check or release check is missing. Near year end, checkout may be unavailable because the provider's minimum session lifetime would cross the term boundary. + +**Uncertain or held purchase:** keep the saved order and ask Dave for specialist reconciliation through a tracked change. Do not pay again, edit records, grant roles, issue refunds or change provider settings. Payment received after withdrawal, suspension, account loss, conflicting revisions or term expiry is retained for reconciliation rather than overriding that state. Rechecking verified identity can finish an otherwise unchanged pending activation. Any observed test refund/dispute places a sticky defensive hold; this does not choose a real club refund policy. + +**Success proof:** exact reviewed head and green CI, native Firestore concurrency results, actual Stripe sandbox account/webhook verification, genuine staging identity/App Check, matching Function and Hosting release receipts, dated phone/desktop checks, and exact owned cleanup. Unit doubles, generated signatures and emulator transactions do not prove native Stripe or hosted payment behavior. + +**Undo:** stop the review and ask the specialist to disable the test feature and apply a compatible reviewed rollback. Keep unresolved orders and immutable evidence. No officer terminal, source edit, secret or database operation is part of the review. + +**Escalation:** Dave for release, test access and recovery; treasurer/provider owner for the sandbox and future live setup; officers for privacy, retention, waiver, guardian and family-definition approval. Real checkout, imports, automatic emails, subscriptions, paid races and refund issuance remain unavailable. diff --git a/functions/duesTestOnly.js b/functions/duesTestOnly.js new file mode 100644 index 00000000..cf125aed --- /dev/null +++ b/functions/duesTestOnly.js @@ -0,0 +1,29 @@ +'use strict'; +// Separate, default-disabled entrypoint. Never imported by index.js/communityOnly.js. +// Provider/IAM/secret configuration and deployment require a separate release receipt. +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const { assertTestDuesEnvironment } = require('./testDuesPolicy'); +const { createTestStripe } = require('./testDuesStripe'); +const { createTestDuesService } = require('./testMembershipDues'); +const { createTestDuesHandlers } = require('./testDuesHandlers'); +let handlers; +function configured() { + assertTestDuesEnvironment(); + if (!handlers) { + const provider = createTestStripe({ key: process.env.MPRC_TEST_STRIPE_KEY, + webhookSecret: process.env.MPRC_TEST_STRIPE_WEBHOOK_SECRET, account: process.env.MPRC_TEST_STRIPE_ACCOUNT }); + let app; try { app = admin.app('test-dues'); } catch { app = admin.initializeApp({ projectId: process.env.GCLOUD_PROJECT }, 'test-dues'); } + handlers = createTestDuesHandlers({ provider, service: createTestDuesService({ db: app.firestore(), auth: app.auth(), provider, account: process.env.MPRC_TEST_STRIPE_ACCOUNT }) }); + } + return handlers; +} +const runtime = { minInstances: 0, maxInstances: 2, memory: '256MB', timeoutSeconds: 30, + secrets: ['MPRC_TEST_STRIPE_KEY', 'MPRC_TEST_STRIPE_WEBHOOK_SECRET'] }; +exports.membershipTestDues = functions.runWith({ ...runtime, enforceAppCheck: true }).https.onCall(async (data, context) => { + try { return await configured().callable(data, context); } + catch (e) { throw new functions.https.HttpsError(['unauthenticated', 'permission-denied', 'invalid-argument', 'failed-precondition', 'already-exists', 'resource-exhausted'].includes(e?.code) ? e.code : 'unavailable', 'Test dues are unavailable. Refresh status or ask the test owner.'); } +}); +exports.membershipTestDuesWebhook = functions.runWith(runtime).https.onRequest(async (req, res) => { + try { await configured().webhook(req, res); } catch { res.status(503).send('unavailable'); } +}); diff --git a/functions/membershipDuesCheckout.js b/functions/membershipDuesCheckout.js index e640f9dc..f6122877 100644 --- a/functions/membershipDuesCheckout.js +++ b/functions/membershipDuesCheckout.js @@ -2,7 +2,9 @@ // MEMBERS-DUES-001D — pure server-authoritative dues checkout/expectation producer (pure contract). // -// SOURCE ONLY, UNUSED: this module is imported by nothing (not by +// Historical pure-contract scope below. #742 now consumes this module only in +// the separate disabled test-dues runtime; production remains unavailable. +// ORIGINAL SOURCE-ONLY SCOPE: not imported by // functions/index.js, no route, no Firestore Rules, no callable, no webhook, no // scheduled worker). It has zero runtime behavior. It is the price-integrity // decision core for item 2 of parent #114 (MEMBERS-DUES-001) — "the server decides diff --git a/functions/membershipDuesPayment.js b/functions/membershipDuesPayment.js index 2ddf4d2b..760de72e 100644 --- a/functions/membershipDuesPayment.js +++ b/functions/membershipDuesPayment.js @@ -2,7 +2,9 @@ // MEMBERS-DUES-001B — pure verified-online-dues-payment → approved-term decision (pure contract). // -// SOURCE ONLY, UNUSED: this module is imported by nothing (not by +// Historical pure-contract scope below. #742 now consumes this module only in +// the separate disabled test-dues runtime; production remains unavailable. +// ORIGINAL SOURCE-ONLY SCOPE: not imported by // functions/index.js, no route, no Firestore Rules, no callable, no webhook, no // scheduled worker). It has zero runtime behavior. It is the money-critical // decision core for item 3 of parent #114 (MEMBERS-DUES-001) — "a verified payment diff --git a/functions/testDuesHandlers.js b/functions/testDuesHandlers.js new file mode 100644 index 00000000..f8f51b47 --- /dev/null +++ b/functions/testDuesHandlers.js @@ -0,0 +1,29 @@ +'use strict'; +const { assertTestDuesEnvironment, parseDuesRequest, check, fail } = require('./testDuesPolicy'); +function createTestDuesHandlers({ service, provider, env = process.env, clock = Date.now }) { + return { + async callable(data, context) { + assertTestDuesEnvironment(env); + check(context.app && typeof context.auth?.uid === 'string', 'unauthenticated'); + const r = parseDuesRequest(data); const token = context.auth.token; + check(r.accountUid === context.auth.uid && token?.email_verified === true && !token.firebase?.tenant + && Number.isSafeInteger(token.auth_time) && token.auth_time * 1000 <= clock() + && clock() - token.auth_time * 1000 <= 24 * 3600000, 'permission-denied'); + try { + return r.action === 'prepare' ? await service.prepare(context.auth.uid, r) : await service[r.action](context.auth.uid); + } catch (e) { + if (['permission-denied', 'failed-precondition', 'already-exists', 'resource-exhausted'].includes(e?.code)) fail(e.code); + fail('unavailable'); + } + }, + async webhook(req, res) { + try { + assertTestDuesEnvironment(env); + check(req.method === 'POST', 'invalid-argument'); + const event = provider.verify(req.rawBody, req.get('stripe-signature')); + await service.webhook(event); res.status(200).send('received'); + } catch (e) { res.status(e?.code === 'invalid-argument' ? 400 : 503).send('unavailable'); } + }, + }; +} +module.exports = { createTestDuesHandlers }; diff --git a/functions/testDuesHandlers.test.js b/functions/testDuesHandlers.test.js new file mode 100644 index 00000000..8be7b871 --- /dev/null +++ b/functions/testDuesHandlers.test.js @@ -0,0 +1,21 @@ +'use strict'; +const { createTestDuesHandlers } = require('./testDuesHandlers'); +const env = { GCLOUD_PROJECT: 'run-mprc-staging', COMMERCE_ENABLED: 'false', MPRC_TEST_DUES_ENABLED: 'true' }; +const context = { app: { appId: 'native' }, auth: { uid: 'fictional', token: { email_verified: true, auth_time: 1000, firebase: {} } } }; +const request = { action: 'status', accountUid: 'fictional' }; +let service; let provider; let handlers; +beforeEach(() => { service = { status: jest.fn(async () => 'safe'), webhook: jest.fn() }; provider = { verify: jest.fn() }; handlers = createTestDuesHandlers({ service, provider, env, clock: () => 1000000 }); }); +test('native context accepted', async () => { expect(await handlers.callable(request, context)).toBe('safe'); }); +test.each([{}, { app: context.app }, { ...context, app: undefined }, { ...context, auth: { uid: 'foreign', token: context.auth.token } }, + { ...context, auth: { ...context.auth, token: { ...context.auth.token, email_verified: false } } }, + { ...context, auth: { ...context.auth, token: { ...context.auth.token, firebase: { tenant: 'foreign' } } } }, + { ...context, auth: { ...context.auth, token: { ...context.auth.token, auth_time: 1001 } } }])('missing/foreign/stale identity rejected %j', async c => { + await expect(handlers.callable(request, c)).rejects.toThrow(); expect(service.status).not.toHaveBeenCalled(); +}); +test('no raw provider exception escapes', async () => { service.status.mockRejectedValue(new Error('SENSITIVE')); await expect(handlers.callable(request, context)).rejects.toThrow('Test dues are unavailable'); }); +test('invalid signature never writes; webhook failure responds retryable fixed503', async () => { + const res = { status: jest.fn().mockReturnThis(), send: jest.fn() }; const req = { method: 'POST', rawBody: Buffer.from('{}'), get: () => 'signature' }; + provider.verify.mockImplementationOnce(() => { const e = new Error('raw'); e.code = 'invalid-argument'; throw e; }); + await handlers.webhook(req, res); expect(res.status).toHaveBeenLastCalledWith(400); expect(service.webhook).not.toHaveBeenCalled(); + service.webhook.mockRejectedValue(new Error('raw')); await handlers.webhook(req, res); expect(res.status).toHaveBeenLastCalledWith(503); expect(res.send).toHaveBeenLastCalledWith('unavailable'); +}); diff --git a/functions/testDuesPolicy.js b/functions/testDuesPolicy.js new file mode 100644 index 00000000..61786e2b --- /dev/null +++ b/functions/testDuesPolicy.js @@ -0,0 +1,49 @@ +'use strict'; +const { createHash } = require('node:crypto'); +const { parseStrictObject } = require('./requestValidation'); +const fail = (code = 'failed-precondition') => { const e = new Error('Test dues are unavailable. Refresh status or ask the test owner.'); e.code = code; throw e; }; +const check = (ok, code) => { if (!ok) fail(code); }; +const UID = /^[A-Za-z0-9_-]{1,80}$/; +const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const hash = value => createHash('sha256').update(value).digest('hex'); +const WAIVER = Object.freeze({ version: 'synthetic-dues-v1', text: 'SYNTHETIC TEST ONLY. I understand this checkout uses fictional membership and test payment information. This is not an approved club waiver.' }); +const RETURN_URL = 'https://run-mprc-staging.web.app/community/dues'; +function assertTestDuesEnvironment(env = process.env) { + const local = /^demo-[a-z0-9-]+$/.test(env.GCLOUD_PROJECT || '') + && ['FIRESTORE_EMULATOR_HOST', 'FIREBASE_AUTH_EMULATOR_HOST', 'FUNCTIONS_EMULATOR_HOST'].every(k => /^127\.0\.0\.1:\d+$/.test(env[k] || '')); + check((env.GCLOUD_PROJECT === 'run-mprc-staging' || local) + && env.COMMERCE_ENABLED === 'false' && env.MPRC_TEST_DUES_ENABLED === 'true'); +} +function parseDuesRequest(input) { + try { + const r = parseStrictObject(input, { requiredKeys: ['action', 'accountUid'], optionalKeys: ['requestId', 'offerId', 'waiverVersion', 'waiverAccepted'] }); + check(UID.test(r.accountUid) && typeof r.accountUid === 'string', 'invalid-argument'); + const keys = r.action === 'prepare' ? ['action', 'accountUid', 'requestId', 'offerId', 'waiverVersion', 'waiverAccepted'] : ['action', 'accountUid']; + check(['status', 'prepare', 'resume', 'reconcile'].includes(r.action), 'invalid-argument'); + parseStrictObject(r, { requiredKeys: keys }); + if (r.action === 'prepare') check(typeof r.requestId === 'string' && UUID.test(r.requestId) + && typeof r.offerId === 'string' && /^dues-\d{4}-(individual|family)-v1$/.test(r.offerId) + && r.waiverAccepted === true && r.waiverVersion === WAIVER.version, 'invalid-argument'); + return r; + } catch { fail('invalid-argument'); } +} +// Find local Jan 1 midnight from the timezone itself rather than assuming an offset. +function yearStart(year) { + let instant = Date.UTC(year, 0, 1); + for (let n = 0; n < 3; n++) { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).formatToParts(instant).map(p => [p.type, p.value])); + const represented = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second); + instant += Date.UTC(year, 0, 1) - represented; + } + return instant; +} +function offers(now) { + check(Number.isSafeInteger(now) && now >= Date.UTC(2020, 0, 1) && now < Date.UTC(2100, 0, 1)); + const year = +new Intl.DateTimeFormat('en-US', { year: 'numeric', timeZone: 'America/Los_Angeles' }).format(now); + return ['individual', 'family'].map(kind => ({ membershipDuesCheckoutSchemaVersion: 1, + planRef: `dues-${year}-${kind}-v1`, policyVersion: 'test-dues-calendar-v1', termId: `calendar-${year}`, + amountMinor: kind === 'individual' ? 2500 : 3000, currency: 'usd', startsAtMs: yearStart(year), endsAtMs: yearStart(year + 1), + offerable: yearStart(year + 1) - now >= 31 * 60_000, + })); +} +module.exports = { fail, check, hash, UID, WAIVER, RETURN_URL, offers, yearStart, assertTestDuesEnvironment, parseDuesRequest }; diff --git a/functions/testDuesStripe.js b/functions/testDuesStripe.js new file mode 100644 index 00000000..a4142b42 --- /dev/null +++ b/functions/testDuesStripe.js @@ -0,0 +1,79 @@ +'use strict'; +// No logging or raw provider persistence. This adapter uses only the existing SDK. +const Stripe = require('stripe'); +const { check, RETURN_URL } = require('./testDuesPolicy'); +const ref = (s, prefix) => typeof s === 'string' && new RegExp(`^${prefix}_[A-Za-z0-9]{1,120}$`).test(s); +function checkoutUrl(url) { + try { const u = new URL(url); check(u.protocol === 'https:' && u.hostname === 'checkout.stripe.com' && !u.username && !u.password && !u.port && u.pathname.startsWith('/c/pay/cs_test_')); return url; } catch { check(false); } +} +function checkoutParameters(o) { + return { mode: 'payment', payment_method_types: ['card'], client_reference_id: o.id, + metadata: { schema: '1', purpose: 'membership_test_dues', order: o.id, nonce: o.nonce }, + line_items: [{ quantity: 1, price_data: { currency: o.expectation.currency, unit_amount: o.expectation.expectedAmountMinor, + product_data: { name: `TEST ONLY: ${o.kind} annual membership` } } }], + success_url: RETURN_URL, cancel_url: RETURN_URL, expires_at: Math.floor(o.expiresAtMs / 1000), + allow_promotion_codes: false, automatic_tax: { enabled: false }, + }; +} +function createTestStripe({ key, webhookSecret, account }) { + check(typeof key === 'string' && key.startsWith('sk_test_') && typeof webhookSecret === 'string' && webhookSecret.startsWith('whsec_') && ref(account, 'acct')); + const stripe = new Stripe(key, { apiVersion: '2023-10-16', timeout: 5000, maxNetworkRetries: 0 }); + async function own() { const a = await stripe.accounts.retrieve(); check(a.id === account); } + async function project(s, includeUrl = false) { + check(ref(s.id, 'cs_test') && s.object === 'checkout.session' && s.livemode === false && s.mode === 'payment' + && ['open', 'complete', 'expired'].includes(s.status) && ['paid', 'unpaid', 'no_payment_required'].includes(s.payment_status) + && Number.isSafeInteger(s.amount_total) && s.currency === 'usd' + && s.metadata?.schema === '1' && s.metadata?.purpose === 'membership_test_dues' + && /^[a-f0-9]{64}$/.test(s.metadata.order) && /^[a-f0-9]{32}$/.test(s.metadata.nonce) + && s.client_reference_id === s.metadata.order); + let paid = false; let reversal = false; let paymentRef = null; + if (s.payment_intent) { + check(ref(s.payment_intent, 'pi')); + const pi = await stripe.paymentIntents.retrieve(s.payment_intent, { expand: ['latest_charge'] }); + check(pi.id === s.payment_intent && pi.livemode === false && pi.amount === s.amount_total && pi.currency === s.currency); + const c = pi.latest_charge; + if (c) { + check(typeof c === 'object' && ref(c.id, 'ch') && c.livemode === false && c.payment_intent === pi.id + && c.amount === s.amount_total && c.currency === s.currency && typeof c.disputed === 'boolean' && Number.isSafeInteger(c.amount_refunded)); + reversal = c.disputed || c.amount_refunded > 0; + } + paid = s.status === 'complete' && s.payment_status === 'paid' && pi.status === 'succeeded' + && pi.amount_received === s.amount_total && c?.paid === true && !reversal; + paymentRef = pi.id; + } + return { checkoutRef: s.id, orderId: s.metadata.order, nonce: s.metadata.nonce, providerAccountRef: account, + livemode: false, amountMinor: s.amount_total, currency: s.currency, paymentRef, paid, reversal, + expired: s.status === 'expired' && s.payment_status === 'unpaid' && !s.payment_intent, + ...(includeUrl && s.status === 'open' && s.url ? { url: checkoutUrl(s.url) } : {}), + }; + } + return { + async create(order) { await own(); return project(await stripe.checkout.sessions.create(checkoutParameters(order), { idempotencyKey: order.id }), true); }, + async retrieve(id, includeUrl = false) { check(ref(id, 'cs_test')); await own(); return project(await stripe.checkout.sessions.retrieve(id), includeUrl); }, + async resolve(reference) { + await own(); + if (reference.kind === 'session') return project(await stripe.checkout.sessions.retrieve(reference.id)); + let pi = reference.id; + if (reference.kind === 'charge') { const c = await stripe.charges.retrieve(reference.id); check(c.livemode === false && ref(c.payment_intent, 'pi')); pi = c.payment_intent; } + check(ref(pi, 'pi')); + const result = await stripe.checkout.sessions.list({ payment_intent: pi, limit: 2 }); + check(result.data.length === 1 && !result.has_more); + return project(result.data[0]); + }, + verify(raw, signature) { + check(Buffer.isBuffer(raw) && raw.length <= 65536 && typeof signature === 'string' && signature.length <= 2048, 'invalid-argument'); + let event; try { event = stripe.webhooks.constructEvent(raw, signature, webhookSecret); } catch { check(false, 'invalid-argument'); } + check(event.livemode === false && (!event.account || event.account === account) && ref(event.id, 'evt'), 'invalid-argument'); + const object = event.data?.object; let reference; + if (['checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired'].includes(event.type)) { + check(object?.livemode === false && ref(object.id, 'cs_test'), 'invalid-argument'); reference = { kind: 'session', id: object.id }; + } else if (event.type === 'charge.refunded') { + check(object?.livemode === false && ref(object.id, 'ch'), 'invalid-argument'); reference = { kind: 'charge', id: object.id }; + } else if (['charge.dispute.created', 'charge.dispute.updated', 'charge.dispute.closed'].includes(event.type)) { + check(object?.livemode === false && ref(object.charge, 'ch'), 'invalid-argument'); reference = { kind: 'charge', id: object.charge }; + } else return null; + return { id: event.id, type: event.type, reference }; + }, + }; +} +module.exports = { createTestStripe, checkoutParameters, checkoutUrl }; diff --git a/functions/testDuesStripe.test.js b/functions/testDuesStripe.test.js new file mode 100644 index 00000000..a84de2b4 --- /dev/null +++ b/functions/testDuesStripe.test.js @@ -0,0 +1,62 @@ +'use strict'; +jest.mock('stripe', () => jest.fn()); +const Stripe = require('stripe'); +const RealStripe = jest.requireActual('stripe'); +const { createTestStripe, checkoutParameters, checkoutUrl } = require('./testDuesStripe'); +let sdk; let adapter; +const secret = 'whsec_synthetic_only'; const account = 'acct_synthetic'; const id = 'a'.repeat(64); const nonce = 'b'.repeat(32); +const session = () => ({ object: 'checkout.session', id: 'cs_test_synthetic', livemode: false, mode: 'payment', status: 'complete', payment_status: 'paid', + amount_total: 2500, currency: 'usd', payment_intent: 'pi_synthetic', client_reference_id: id, + metadata: { schema: '1', purpose: 'membership_test_dues', order: id, nonce } }); +const intent = () => ({ id: 'pi_synthetic', livemode: false, amount: 2500, amount_received: 2500, currency: 'usd', status: 'succeeded', latest_charge: { + id: 'ch_synthetic', livemode: false, payment_intent: 'pi_synthetic', amount: 2500, currency: 'usd', disputed: false, amount_refunded: 0, paid: true } }); +beforeEach(() => { + sdk = { accounts: { retrieve: jest.fn(async () => ({ id: account })) }, checkout: { sessions: { create: jest.fn(async () => session()), retrieve: jest.fn(async () => session()), list: jest.fn(async () => ({ data: [session()], has_more: false })) } }, + paymentIntents: { retrieve: jest.fn(async () => intent()) }, charges: { retrieve: jest.fn(async () => ({ payment_intent: 'pi_synthetic', livemode: false })) }, webhooks: new RealStripe('sk_test_synthetic').webhooks }; + Stripe.mockImplementation(() => sdk); adapter = createTestStripe({ key: 'sk_test_synthetic', webhookSecret: secret, account }); +}); +test('server parameters use fixed URLs, exact integer cents and no member/customer metadata', () => { + const p = checkoutParameters({ id, nonce, kind: 'individual', expiresAtMs: 1792155600000, expectation: { expectedAmountMinor: 2500, currency: 'usd' } }); + expect(p.line_items[0].price_data.unit_amount).toBe(2500); expect(p.metadata).toEqual({ schema: '1', purpose: 'membership_test_dues', order: id, nonce }); + expect(p.success_url).toBe(p.cancel_url); expect(p).not.toHaveProperty('customer_email'); expect(p).not.toHaveProperty('subscription_data'); +}); +test('paid projection verifies own account, session and current payment/charge without leaking PII', async () => { + sdk.checkout.sessions.retrieve.mockResolvedValue({ ...session(), customer_details: { email: 'never-retain@example.invalid' } }); + expect(await adapter.retrieve('cs_test_synthetic')).toEqual({ checkoutRef: 'cs_test_synthetic', orderId: id, nonce, providerAccountRef: account, livemode: false, amountMinor: 2500, currency: 'usd', paymentRef: 'pi_synthetic', paid: true, reversal: false, expired: false }); +}); +test.each([{ livemode: true }, { mode: 'subscription' }, { amount_total: 2.5 }, { currency: 'eur' }, { client_reference_id: 'foreign' }, { metadata: { schema: '1' } }])('invalid session rejected %j', async patch => { + sdk.checkout.sessions.retrieve.mockResolvedValue({ ...session(), ...patch }); await expect(adapter.retrieve('cs_test_synthetic')).rejects.toThrow(); +}); +test.each([{ livemode: true }, { amount: 1 }, { currency: 'eur' }])('invalid intent rejected %j', async patch => { + sdk.paymentIntents.retrieve.mockResolvedValue({ ...intent(), ...patch }); await expect(adapter.retrieve('cs_test_synthetic')).rejects.toThrow(); +}); +test.each([{ amount_received: 0 }, { status: 'processing' }, { latest_charge: null }])('incomplete payment cannot activate %j', async patch => { + sdk.paymentIntents.retrieve.mockResolvedValue({ ...intent(), ...patch }); expect((await adapter.retrieve('cs_test_synthetic')).paid).toBe(false); +}); +test.each([{ amount_refunded: 100 }, { disputed: true }])('current reversal detected before older completion %j', async patch => { + const pi = intent(); pi.latest_charge = { ...pi.latest_charge, ...patch }; sdk.paymentIntents.retrieve.mockResolvedValue(pi); + expect(await adapter.retrieve('cs_test_synthetic')).toMatchObject({ paid: false, reversal: true }); +}); +test('wrong actual Stripe account and live secret rejected', async () => { + sdk.accounts.retrieve.mockResolvedValue({ id: 'acct_foreign' }); await expect(adapter.retrieve('cs_test_synthetic')).rejects.toThrow(); + expect(() => createTestStripe({ key: 'sk_live_synthetic', webhookSecret: secret, account })).toThrow(); +}); +test('raw signed webhook verified; invalid, stale, live, wrong-account and oversized payloads reject', () => { + const event = { id: 'evt_synthetic', type: 'checkout.session.completed', livemode: false, data: { object: session() } }; + const signed = e => { const raw = Buffer.from(JSON.stringify(e)); const signature = sdk.webhooks.generateTestHeaderString({ payload: raw.toString(), secret }); return [raw, signature]; }; + expect(adapter.verify(...signed(event))).toEqual({ id: event.id, type: event.type, reference: { kind: 'session', id: session().id } }); + expect(() => adapter.verify(...signed({ ...event, livemode: true }))).toThrow(); + expect(() => adapter.verify(...signed({ ...event, account: 'acct_other' }))).toThrow(); + expect(() => adapter.verify(Buffer.from('{}'), 'invalid')).toThrow(); + expect(() => adapter.verify(Buffer.alloc(65537), 'invalid')).toThrow(); + const [raw] = signed(event); expect(() => adapter.verify(raw, sdk.webhooks.generateTestHeaderString({ payload: raw.toString(), secret, timestamp: 1 }))).toThrow(); +}); +test('refund/dispute resolves exact same-account checkout even before completed event is stored', async () => { + expect(await adapter.resolve({ kind: 'charge', id: 'ch_synthetic' })).toMatchObject({ orderId: id }); + expect(sdk.checkout.sessions.list).toHaveBeenCalledWith({ payment_intent: 'pi_synthetic', limit: 2 }); + sdk.checkout.sessions.list.mockResolvedValue({ data: [session(), session()], has_more: false }); await expect(adapter.resolve({ kind: 'charge', id: 'ch_synthetic' })).rejects.toThrow(); +}); +test('only ephemeral test Checkout URL can be returned', () => { + expect(checkoutUrl('https://checkout.stripe.com/c/pay/cs_test_synthetic')).toContain('cs_test_'); + for (const url of ['https://checkout.stripe.com.evil/c/pay/cs_test_x', 'https://checkout.stripe.com/c/pay/cs_live_x', 'http://checkout.stripe.com/c/pay/cs_test_x', 'https://user@checkout.stripe.com/c/pay/cs_test_x']) expect(() => checkoutUrl(url)).toThrow(); +}); diff --git a/functions/testMembershipDues.emulator.test.js b/functions/testMembershipDues.emulator.test.js new file mode 100644 index 00000000..48b3b2e2 --- /dev/null +++ b/functions/testMembershipDues.emulator.test.js @@ -0,0 +1,51 @@ +'use strict'; +const admin = require('firebase-admin'); +const { randomUUID } = require('node:crypto'); +const { createTestDuesService } = require('./testMembershipDues'); +const { offers, WAIVER } = require('./testDuesPolicy'); +const { createCommunityService, parseRequest } = require('./communityActions'); +const { freeEvent } = require('./testSupport/communityFixtures'); +const native = process.env.FIRESTORE_EMULATOR_HOST && process.env.FIREBASE_AUTH_EMULATOR_HOST ? describe : describe.skip; +native('test dues native Firestore concurrency (synthetic provider)', () => { + let app; let db; let service; let provider; let now; let order; let paid; + const uid = 'fixture-dues-native'; const roots = ['membershipTestOrders', 'membershipTestAccounts', 'membershipTestParticipants', 'membershipTestTerms', 'membershipTestFamilies', 'membershipTestEvents', 'membershipTestAudit', 'membershipTestLimits']; + beforeAll(() => { app = admin.initializeApp({ projectId: process.env.GCLOUD_PROJECT }, 'dues-native'); db = app.firestore(); }); + beforeEach(async () => { + now = Date.parse('2026-10-16T12:00:00Z'); order = null; paid = false; + for (const root of roots) await db.recursiveDelete(db.collection(root)); + await db.doc(`communityMemberships/${uid}`).delete(); + await db.doc(`membershipTestParticipants/${uid}`).create({ schemaVersion: 1, synthetic: true, expiresAtMs: now + 86400000 }); + const project = o => ({ orderId: o.id, nonce: o.nonce, checkoutRef: 'cs_test_native', providerAccountRef: 'acct_native', livemode: false, amountMinor: o.expectation.expectedAmountMinor, currency: 'usd', paymentRef: paid ? 'pi_native' : null, paid, reversal: false, expired: false }); + provider = { create: jest.fn(async o => { expect((await db.doc(`membershipTestOrders/${o.id}`).get()).exists).toBe(true); order = o; return project(o); }), retrieve: jest.fn(async () => project(order)), resolve: jest.fn(async () => project(order)) }; + service = createTestDuesService({ db, auth: { getUser: async id => ({ uid: id, disabled: false, emailVerified: true }) }, provider, account: 'acct_native', clock: () => now }); + }); + afterAll(async () => { + for (const root of roots) await db.recursiveDelete(db.collection(root)); + await db.doc(`communityMemberships/${uid}`).delete(); + await db.recursiveDelete(db.doc('communityEvents/fixture-dues-free')); + await db.recursiveDelete(db.doc(`communityCommands/${uid}`)); await db.doc(`communityLimits/${uid}`).delete(); await app.delete(); + }); + const request = (kind = 'individual', id = randomUUID()) => ({ action: 'prepare', accountUid: uid, requestId: id, offerId: offers(now).find(o => o.planRef.includes(kind)).planRef, waiverAccepted: true, waiverVersion: WAIVER.version }); + test('concurrent different plans/commands create one purchase; concurrent paid callbacks activate once and admit free event', async () => { + const results = await Promise.allSettled(Array.from({ length: 16 }, (_, n) => service.prepare(uid, request(n % 2 ? 'family' : 'individual')))); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1); expect(provider.create).toHaveBeenCalledTimes(1); + expect((await db.collection('membershipTestOrders').get()).size).toBe(1); + paid = true; + const event = { id: 'evt_native', type: 'checkout.session.completed', reference: { kind: 'session', id: 'cs_test_native' } }; + await Promise.all(Array.from({ length: 5 }, (_, n) => n % 2 ? service.reconcile(uid) : service.webhook(event))); + expect((await service.status(uid)).active).toBe(true); expect((await db.doc(`communityMemberships/${uid}`).get()).data().revision).toBe(1); + expect((await db.collection('membershipTestTerms').get()).size).toBe(1); + await db.doc('communityEvents/fixture-dues-free').set(freeEvent(now)); + const community = createCommunityService(db, () => now); + await community.mutate(uid, parseRequest({ action: 'event_join', accountUid: uid, requestId: randomUUID(), expectedRevision: 0, eventId: 'fixture-dues-free', waiverVersion: 'synthetic-only-v1', waiverAccepted: true })); + expect((await db.doc(`communityEvents/fixture-dues-free/registrations/${uid}`).get()).data().status).toBe('confirmed'); + }, 60000); + test('native same-command race shares original nonce, price and expiry; retry after paid is read-only', async () => { + const r = request(); const results = await Promise.all(Array.from({ length: 5 }, () => service.prepare(uid, r))); + expect(new Set(results.map(x => x.order.id)).size).toBe(1); + const calls = provider.create.mock.calls.map(c => JSON.stringify(c[0])); expect(new Set(calls).size).toBe(1); + paid = true; await service.reconcile(uid); + const before = await db.doc(`communityMemberships/${uid}`).get(); await service.reconcile(uid); + expect((await db.doc(`communityMemberships/${uid}`).get()).updateTime.isEqual(before.updateTime)).toBe(true); + }, 60000); +}); diff --git a/functions/testMembershipDues.js b/functions/testMembershipDues.js new file mode 100644 index 00000000..a343bb98 --- /dev/null +++ b/functions/testMembershipDues.js @@ -0,0 +1,175 @@ +'use strict'; +const { randomBytes } = require('node:crypto'); +const { check, hash, offers, WAIVER } = require('./testDuesPolicy'); +const { deriveDuesCheckoutOrder } = require('./membershipDuesCheckout'); +const { classifyVerifiedDuesPayment } = require('./membershipDuesPayment'); +const { createMembershipAuthority, applyMembershipAuthorityCommand, deriveMembershipEntitlement } = require('./membershipAuthority'); +const EMPTY = { schemaVersion: 1, revision: 0, applicationStatus: 'none', authority: null }; +const integer = n => Number.isSafeInteger(n) && n >= 0 && n < 1_000_000; +function authorityFor(member, uid) { + check(member.schemaVersion === 1 && integer(member.revision) + && ['none', 'pending', 'approved', 'withdrawn', 'cancellation_requested'].includes(member.applicationStatus)); + if (member.authority) { + check(member.authority.association.uid === uid); + deriveMembershipEntitlement({ membershipAuthoritySchemaVersion: 1, record: member.authority, uid, asOfMs: 0 }); + return member.authority; + } + let record = createMembershipAuthority({ membershipAuthoritySchemaVersion: 1, membershipId: `test-${hash(uid).slice(0, 48)}`, commandId: 'test-dues-create' }); + record = applyMembershipAuthorityCommand(record, { membershipAuthoritySchemaVersion: 1, commandType: 'associate_account', commandId: 'test-dues-associate', expectedRevision: 1, uid }); + return record; +} +function view(order) { + return order ? { id: order.id, state: order.state, offerId: order.expectation.planRef, + amountMinor: order.expectation.expectedAmountMinor, currency: order.expectation.currency, + endsAtMs: order.expectation.endsAtMs, familyId: order.state === 'active' ? order.familyId : null } : null; +} +function createTestDuesService({ db, auth, provider, account, clock = Date.now }) { + check(/^acct_[A-Za-z0-9]{1,120}$/.test(account)); + const memberRef = uid => db.doc(`communityMemberships/${uid}`); + const accountRef = uid => db.doc(`membershipTestAccounts/${uid}`); + const orderRef = id => db.doc(`membershipTestOrders/${id}`); + const participantRef = uid => db.doc(`membershipTestParticipants/${uid}`); + const marked = (p, now) => p?.schemaVersion === 1 && p.synthetic === true && Number.isSafeInteger(p.expiresAtMs) && p.expiresAtMs > now; + async function actor(uid) { + const user = await auth.getUser(uid); + check(user.uid === uid && user.disabled === false && user.emailVerified === true && !user.tenantId, 'permission-denied'); + } + async function status(uid) { + await actor(uid); + return db.runTransaction(async tx => { + const [a, p, m] = await tx.getAll(accountRef(uid), participantRef(uid), memberRef(uid)); + const o = a.exists ? await tx.get(orderRef(a.data().orderId)) : null; + const now = clock(); check(marked(p.data(), now), 'permission-denied'); + const member = m.exists ? m.data() : EMPTY; const authority = authorityFor(member, uid); + const active = deriveMembershipEntitlement({ membershipAuthoritySchemaVersion: 1, record: authority, uid, asOfMs: now }).entitlement === 'current_member'; + return { schemaVersion: 1, observedAtMs: now, offers: offers(now), waiver: WAIVER, active, accessThroughMs: active ? authority.term.endsAtMs : null, order: view(o?.data() || null) }; + }); + } + async function rate(uid) { + await db.runTransaction(async tx => { + const ref = db.doc(`membershipTestLimits/${uid}`); const snap = await tx.get(ref); const window = Math.floor(clock() / 3600000); + const count = snap.data()?.window === window ? snap.data().count : 0; + check(integer(count) && count < 30, 'resource-exhausted'); tx.set(ref, { window, count: count + 1 }); + }); + } + async function prepare(uid, request) { + await actor(uid); + const id = hash(`${uid}:${request.requestId}`); const nonce = randomBytes(16).toString('hex'); + const order = await db.runTransaction(async tx => { + const [old, lock, m, p] = await tx.getAll(orderRef(id), accountRef(uid), memberRef(uid), participantRef(uid)); + const previous = lock.exists && lock.data().orderId !== id ? await tx.get(orderRef(lock.data().orderId)) : null; + const now = clock(); check(marked(p.data(), now), 'permission-denied'); + if (old.exists) { + check(old.data().uid === uid && old.data().expectation.planRef === request.offerId && old.data().waiver.version === request.waiverVersion, 'already-exists'); + return old.data(); + } + check(!previous || (previous.exists && ['active', 'expired'].includes(previous.data().state)), 'already-exists'); + const member = m.exists ? m.data() : EMPTY; const authority = authorityFor(member, uid); + check(authority.term.state !== 'suspended'); + const plan = offers(now).find(o => o.planRef === request.offerId); check(plan?.offerable); + // Buying another plan in the same paid year is not a renewal or an upgrade. + check(authority.term.termId !== plan.termId && (authority.term.endsAtMs === null || authority.term.endsAtMs <= now)); + const prepared = deriveDuesCheckoutOrder(plan, { membershipDuesCheckoutSchemaVersion: 1, membershipId: authority.membershipId, + recordRevision: authority.revision, termRevision: authority.term.revision, activeTermId: null }, + { membershipDuesCheckoutSchemaVersion: 1, membershipId: authority.membershipId, planRef: plan.planRef, providerAccountRef: account, livemode: false, idempotencyKey: id }); + check(prepared.decision === 'prepared'); + const result = { schemaVersion: 1, id, uid, nonce, kind: plan.amountMinor === 2500 ? 'individual' : 'family', state: 'prepared', + expectation: prepared.expectation, createdAtMs: now, retryUntilMs: now + 5 * 60_000, + expiresAtMs: Math.floor(Math.min(now + 3600000, plan.endsAtMs) / 1000) * 1000, + waiver: { version: WAIVER.version, hash: hash(WAIVER.text), acceptedAtMs: now }, + memberRevision: member.revision, checkoutRef: null, paymentRef: null, familyId: null }; + tx.create(orderRef(id), result); tx.set(accountRef(uid), { schemaVersion: 1, orderId: id }); return result; + }); + return open(uid, order); + } + async function latest(uid) { + return db.runTransaction(async tx => { + const a = await tx.get(accountRef(uid)); check(a.exists); const o = await tx.get(orderRef(a.data().orderId)); check(o.exists && o.data().uid === uid); return o.data(); + }); + } + function matches(o, observation) { + check(observation.orderId === o.id && observation.nonce === o.nonce && observation.providerAccountRef === account + && observation.livemode === false && observation.amountMinor === o.expectation.expectedAmountMinor && observation.currency === o.expectation.currency + && (!o.checkoutRef || o.checkoutRef === observation.checkoutRef)); + } + async function observe(observation) { + const initial = await db.runTransaction(async tx => (await tx.get(orderRef(observation.orderId))).data()); check(initial); + matches(initial, observation); + // Authentication metadata cannot be atomically locked with Firestore. Recheck + // immediately before the entitlement transaction, retain paid evidence on loss. + let enabled = false; try { await actor(initial.uid); enabled = true; } catch { /* hold, never invent Auth */ } + return db.runTransaction(async tx => { + const ref = orderRef(initial.id); const [s, m, p, receipt] = await tx.getAll(ref, memberRef(initial.uid), participantRef(initial.uid), db.doc(`membershipTestTerms/${initial.id}`)); + const o = s.data(); check(o && o.uid === initial.uid); matches(o, observation); + const member = m.exists ? m.data() : EMPTY; const now = clock(); + const next = { ...o, checkoutRef: observation.checkoutRef }; + const authority = authorityFor(member, o.uid); + if (observation.reversal) { + next.state = 'held'; + // Test-only defensive hold, not a club refund or dispute policy. + if (o.state === 'active' && authority.term.evidenceRef === o.paymentRef && authority.term.state === 'approved') { + const suspended = applyMembershipAuthorityCommand(authority, { membershipAuthoritySchemaVersion: 1, + commandType: 'record_term_decision', commandId: `hold-${o.id}`, expectedRevision: authority.revision, + termRevision: authority.term.revision + 1, termState: 'suspended', termId: authority.term.termId, + startsAtMs: authority.term.startsAtMs, endsAtMs: authority.term.endsAtMs, planRef: authority.term.planRef, + evidenceRef: authority.term.evidenceRef, policyVersion: authority.term.policyVersion }); + tx.set(memberRef(o.uid), { ...member, authority: suspended, revision: member.revision + 1 }); + } + } else if (observation.paid && !['held', 'active'].includes(o.state)) { + check(typeof observation.paymentRef === 'string'); + next.paymentRef = observation.paymentRef; next.state = 'paid_pending_activation'; + const expectation = { ...o.expectation, checkoutRef: observation.checkoutRef }; + const verdict = classifyVerifiedDuesPayment(expectation, { membershipDuesPaymentSchemaVersion: 1, checkoutRef: observation.checkoutRef, + providerAccountRef: account, livemode: false, paymentRef: observation.paymentRef, paymentStatus: 'paid', + paidAmountMinor: observation.amountMinor, currency: observation.currency, observedAtMs: now }); + check(verdict.decision === 'accepted'); + if (!receipt.exists) tx.create(db.doc(`membershipTestTerms/${o.id}`), { ...verdict.auditRecord, waiver: o.waiver }); + // Preserve any intervening withdrawal, suspension, association, or term decision. + // Non-authority revision drift is surfaced rather than silently overwriting it. + if (enabled && marked(p.data(), now) && now < expectation.endsAtMs && now >= expectation.startsAtMs + && member.revision === o.memberRevision && authority.revision === expectation.expectedRevision + && authority.term.revision + 1 === expectation.termRevision && authority.term.state !== 'suspended') { + const approved = applyMembershipAuthorityCommand(authority, verdict.reducerCommand); + next.state = 'active'; next.familyId = o.kind === 'family' ? `family-${o.id.slice(0, 32)}` : null; + tx.set(memberRef(o.uid), { ...member, schemaVersion: 1, applicationStatus: 'approved', revision: member.revision + 1, authority: approved }); + if (next.familyId) tx.create(db.doc(`membershipTestFamilies/${next.familyId}`), { schemaVersion: 1, payerUid: o.uid, orderId: o.id, termId: expectation.termId }); + } + } else if (!['held', 'active', 'paid_pending_activation', 'expired'].includes(o.state)) next.state = observation.expired ? 'expired' : 'open'; + if (JSON.stringify(next) !== JSON.stringify(o)) { + tx.set(ref, next); + tx.create(db.doc(`membershipTestAudit/${hash(`${o.id}:${next.state}:${next.checkoutRef}:${next.paymentRef}`)}`), + { schemaVersion: 1, orderId: o.id, from: o.state, to: next.state, observedAtMs: now }); + } + return view(next); + }); + } + async function open(uid, order) { + check(order.uid === uid); + const p = await db.runTransaction(async tx => (await tx.get(participantRef(uid))).data()); + check(marked(p, clock()), 'permission-denied'); await rate(uid); + if (['active', 'held', 'paid_pending_activation', 'expired'].includes(order.state)) return { order: view(order) }; + check(order.checkoutRef || clock() < order.retryUntilMs); + const observation = order.checkoutRef ? await provider.retrieve(order.checkoutRef, true) : await provider.create(order); + const result = await observe(observation); + return { order: result, ...(result.state === 'open' && observation.url ? { checkoutUrl: observation.url } : {}) }; + } + async function resume(uid) { await actor(uid); return open(uid, await latest(uid)); } + async function reconcile(uid) { + await actor(uid); await rate(uid); const o = await latest(uid); check(o.checkoutRef); + return { order: await observe(await provider.retrieve(o.checkoutRef)) }; + } + async function webhook(event) { + if (!event) return; + const fingerprint = hash(JSON.stringify(event)); const ref = db.doc(`membershipTestEvents/${event.id}`); + await db.runTransaction(async tx => { + const existing = await tx.get(ref); + if (existing.exists) { check(existing.data().fingerprint === fingerprint); return; } + tx.create(ref, { schemaVersion: 1, fingerprint, reference: event.reference, type: event.type, state: 'pending' }); + }); + // Retrieve current provider state on every retry; old events never regress paid. + await observe(await provider.resolve(event.reference)); + await db.runTransaction(async tx => { const s = await tx.get(ref); check(s.data().fingerprint === fingerprint); tx.set(ref, { ...s.data(), state: 'processed' }); }); + } + return { status, prepare, resume, reconcile, webhook }; +} +module.exports = { createTestDuesService, authorityFor }; diff --git a/functions/testMembershipDues.test.js b/functions/testMembershipDues.test.js new file mode 100644 index 00000000..ea16320d --- /dev/null +++ b/functions/testMembershipDues.test.js @@ -0,0 +1,126 @@ +'use strict'; +const { randomUUID } = require('node:crypto'); +const { memoryDb } = require('./testSupport/communityMemoryDb'); +const { createTestDuesService } = require('./testMembershipDues'); +const { offers, WAIVER, yearStart, parseDuesRequest, assertTestDuesEnvironment } = require('./testDuesPolicy'); +const { deriveMembershipEntitlement } = require('./membershipAuthority'); +const UID = 'fictional-dues-user'; const ACCOUNT = 'acct_fictional'; +function fixture(database) { + const memory = database || memoryDb(); const { db, docs } = memory; + let now = Date.parse('2026-10-16T12:00:00Z'); let enabled = true; + docs?.set(`membershipTestParticipants/${UID}`, { schemaVersion: 1, synthetic: true, expiresAtMs: now + 86400000 }); + const auth = { getUser: jest.fn(async uid => ({ uid, disabled: !enabled, emailVerified: true })) }; + let providerOrder; let observation; + const provider = { + create: jest.fn(async order => { providerOrder = order; observation = observation || { orderId: order.id, nonce: order.nonce, checkoutRef: 'cs_test_fictional', providerAccountRef: ACCOUNT, livemode: false, amountMinor: order.expectation.expectedAmountMinor, currency: 'usd', paymentRef: null, paid: false, reversal: false, expired: false, url: 'https://checkout.stripe.com/c/pay/cs_test_fictional' }; return { ...observation }; }), + retrieve: jest.fn(async () => ({ ...observation })), resolve: jest.fn(async () => ({ ...observation })), + }; + const service = createTestDuesService({ db, auth, provider, account: ACCOUNT, clock: () => now }); + const request = (kind = 'individual', id = randomUUID()) => parseDuesRequest({ action: 'prepare', accountUid: UID, requestId: id, offerId: offers(now).find(o => o.planRef.includes(kind)).planRef, waiverVersion: WAIVER.version, waiverAccepted: true }); + const paid = () => { observation = { ...observation, paid: true, paymentRef: 'pi_fictional', url: undefined }; }; + const event = (id = 'evt_fictional') => ({ id, type: 'checkout.session.completed', reference: { kind: 'session', id: 'cs_test_fictional' } }); + return { ...memory, service, provider, request, paid, event, auth, getOrder: () => providerOrder, + setObservation: patch => { observation = { ...observation, ...patch }; }, disable: () => { enabled = false; }, + advance: ms => { now += ms; }, setNow: value => { now = value; }, now: () => now }; +} +test('calendar prices, LA rollover, leap year and minimum Stripe expiration window', () => { + const date = Date.parse('2027-01-01T07:59:59Z'); + expect(offers(date).map(o => [o.amountMinor, o.currency, o.termId, o.offerable])).toEqual([[2500, 'usd', 'calendar-2026', false], [3000, 'usd', 'calendar-2026', false]]); + expect(offers(date + 1000)[0]).toMatchObject({ termId: 'calendar-2027', startsAtMs: date + 1000 }); + expect(yearStart(2025) - yearStart(2024)).toBe(366 * 86400000); +}); +test.each([{ amount: 1 }, { accountUid: 'foreign' }, { waiverAccepted: false }, { action: 'pay' }, { requestId: 'bad' }])('closed input rejects forged %j', patch => { + const f = fixture(); const r = { ...f.request(), ...patch }; + if (patch.accountUid) expect(parseDuesRequest(r).accountUid).toBe('foreign'); // Native wrapper owns caller binding. + else expect(() => parseDuesRequest(r)).toThrow(); +}); +test('environment rejects production, missing explicit flags and partial emulators', () => { + const env = { GCLOUD_PROJECT: 'run-mprc-staging', COMMERCE_ENABLED: 'false', MPRC_TEST_DUES_ENABLED: 'true' }; + expect(() => assertTestDuesEnvironment(env)).not.toThrow(); + for (const patch of [{ GCLOUD_PROJECT: 'run-mprc' }, { COMMERCE_ENABLED: 'true' }, { MPRC_TEST_DUES_ENABLED: undefined }, { GCLOUD_PROJECT: 'demo-test', FIRESTORE_EMULATOR_HOST: '127.0.0.1:8080' }]) expect(() => assertTestDuesEnvironment({ ...env, ...patch })).toThrow(); +}); +test('persist-first exact retry, concurrent IDs and plan changes use one saved order', async () => { + const f = fixture(); const r = f.request(); + f.provider.create.mockImplementationOnce(async order => { expect(f.docs.get(`membershipTestOrders/${order.id}`)).toEqual(order); throw new Error('lost response'); }); + await expect(f.service.prepare(UID, r)).rejects.toThrow(); + await expect(f.service.prepare(UID, f.request('family'))).rejects.toThrow(); + const results = await Promise.all([f.service.prepare(UID, r), f.service.prepare(UID, r)]); + expect(results[0]).toEqual(results[1]); expect([...f.docs.keys()].filter(k => k.startsWith('membershipTestOrders/'))).toHaveLength(1); + const creates = f.provider.create.mock.calls.map(c => c[0]); expect(creates.every(o => o.id === creates[0].id && o.nonce === creates[0].nonce && o.expiresAtMs === creates[0].expiresAtMs)).toBe(true); +}); +test('failed database commit never contacts Stripe', async () => { + const f = fixture(); f.db.runTransaction = jest.fn(async () => { throw new Error('write failure'); }); + await expect(f.service.prepare(UID, f.request())).rejects.toThrow(); expect(f.provider.create).not.toHaveBeenCalled(); +}); +test('uncertain unbound create cannot be recreated after bounded retry horizon', async () => { + const f = fixture(); f.provider.create.mockRejectedValue(new Error('timeout')); const r = f.request(); + await expect(f.service.prepare(UID, r)).rejects.toThrow(); f.advance(300001); + await expect(f.service.prepare(UID, r)).rejects.toThrow(); expect(f.provider.create).toHaveBeenCalledTimes(1); +}); +test('paid family activates automatically once in canonical event membership, records one private Family ID', async () => { + const f = fixture(); const first = await f.service.prepare(UID, f.request('family')); expect(first.order.state).toBe('open'); + f.paid(); await Promise.all([f.service.webhook(f.event()), f.service.reconcile(UID), f.service.webhook(f.event())]); + const member = f.docs.get(`communityMemberships/${UID}`); + expect(member.applicationStatus).toBe('approved'); + expect(deriveMembershipEntitlement({ membershipAuthoritySchemaVersion: 1, record: member.authority, uid: UID, asOfMs: f.now() }).entitlement).toBe('current_member'); + expect((await f.service.status(UID)).order.familyId).toMatch(/^family-/); + expect([...f.docs.keys()].filter(k => k.startsWith('membershipTestFamilies/'))).toHaveLength(1); + expect([...f.docs.keys()].filter(k => k.startsWith('membershipTestTerms/'))).toHaveLength(1); + expect(member.revision).toBe(1); expect(f.auth.getUser).toHaveBeenCalled(); + await expect(f.service.prepare(UID, f.request())).rejects.toThrow(); + expect(JSON.stringify([...f.docs.values()])).not.toContain('checkout.stripe.com'); + f.setObservation({ paid: false, expired: true }); await f.service.webhook(f.event('evt_older')); + expect((await f.service.status(UID)).order.state).toBe('active'); expect(f.docs.get(`communityMemberships/${UID}`)).toEqual(member); +}); +test('unpaid/async failure/return without provider payment never activates; async success does', async () => { + const f = fixture(); await f.service.prepare(UID, f.request()); await f.service.webhook(f.event()); + await f.service.reconcile(UID); expect(f.docs.has(`communityMemberships/${UID}`)).toBe(false); + f.paid(); await f.service.webhook({ ...f.event('evt_async'), type: 'checkout.session.async_payment_succeeded' }); + expect((await f.service.status(UID)).active).toBe(true); +}); +test.each([{ amountMinor: 2499 }, { amountMinor: 2501 }, { currency: 'eur' }, { livemode: true }, { providerAccountRef: 'acct_other' }, { nonce: 'foreign' }, { checkoutRef: 'cs_test_other' }])('mismatched provider observation cannot activate: %j', async patch => { + const f = fixture(); await f.service.prepare(UID, f.request()); f.paid(); f.setObservation(patch); + await expect(f.service.reconcile(UID)).rejects.toThrow(); expect(f.docs.has(`communityMemberships/${UID}`)).toBe(false); +}); +test('verified webhook can bind before create returns; committed lost response resumes same order', async () => { + const f = fixture(); const normal = f.provider.create.getMockImplementation(); + f.provider.create.mockImplementationOnce(async o => { await normal(o); f.paid(); await f.service.webhook(f.event()); throw new Error('lost committed response'); }); + const r = f.request(); await expect(f.service.prepare(UID, r)).rejects.toThrow(); + expect((await f.service.resume(UID)).order.state).toBe('active'); expect(f.provider.create).toHaveBeenCalledTimes(1); +}); +test('receipt survives account loss and safely activates after current verification returns', async () => { + const f = fixture(); await f.service.prepare(UID, f.request()); f.paid(); f.auth.getUser.mockResolvedValueOnce({ uid: UID, disabled: true, emailVerified: true }); + await f.service.webhook(f.event()); expect(f.docs.get(`membershipTestOrders/${f.getOrder().id}`).state).toBe('paid_pending_activation'); + expect(f.docs.has(`communityMemberships/${UID}`)).toBe(false); await f.service.reconcile(UID); expect((await f.service.status(UID)).active).toBe(true); +}); +test('intervening member revision/withdrawal is retained with paid evidence; no automatic override', async () => { + const f = fixture(); await f.service.prepare(UID, f.request()); + f.docs.set(`communityMemberships/${UID}`, { schemaVersion: 1, revision: 1, applicationStatus: 'withdrawn', authority: null }); + f.paid(); await f.service.webhook(f.event()); expect(f.docs.get(`communityMemberships/${UID}`).applicationStatus).toBe('withdrawn'); + expect(f.docs.get(`membershipTestOrders/${f.getOrder().id}`).state).toBe('paid_pending_activation'); + expect(f.docs.has(`membershipTestTerms/${f.getOrder().id}`)).toBe(true); +}); +test('current reversal holds active test term once; duplicate and old paid events cannot reactivate', async () => { + const f = fixture(); await f.service.prepare(UID, f.request()); f.paid(); await f.service.reconcile(UID); + f.setObservation({ reversal: true, paid: false }); await f.service.webhook(f.event('evt_refund')); const m = f.docs.get(`communityMemberships/${UID}`); + await f.service.webhook(f.event('evt_refund')); expect(f.docs.get(`communityMemberships/${UID}`)).toEqual(m); + expect((await f.service.status(UID)).active).toBe(false); f.setObservation({ reversal: false, paid: true }); await f.service.reconcile(UID); + expect((await f.service.status(UID)).order.state).toBe('held'); +}); +test('expired unpaid order allows one fresh purchase, expired state does not regress on old unpaid event', async () => { + const f = fixture(); await f.service.prepare(UID, f.request()); f.setObservation({ expired: true }); await f.service.reconcile(UID); + f.setObservation({ expired: false }); await f.service.reconcile(UID); expect((await f.service.status(UID)).order.state).toBe('expired'); + f.provider.create.mockRejectedValue(new Error('test stop')); await expect(f.service.prepare(UID, f.request())).rejects.toThrow(); + expect([...f.docs.keys()].filter(k => k.startsWith('membershipTestOrders/'))).toHaveLength(2); +}); +test('year rollover retains original paid term; never extends an old payment into new year', async () => { + const f = fixture(); await f.service.prepare(UID, f.request()); f.paid(); f.setNow(yearStart(2027)); + f.docs.set(`membershipTestParticipants/${UID}`, { schemaVersion: 1, synthetic: true, expiresAtMs: f.now() + 86400000 }); + await f.service.webhook(f.event()); expect((await f.service.status(UID)).active).toBe(false); + expect(f.docs.get(`membershipTestTerms/${f.getOrder().id}`).endsAtMs).toBe(yearStart(2027)); +}); +test('revoked marker/current disabled account blocks resume and no extra provider create', async () => { + const f = fixture(); f.provider.create.mockRejectedValue(new Error('timeout')); await expect(f.service.prepare(UID, f.request())).rejects.toThrow(); + f.docs.delete(`membershipTestParticipants/${UID}`); await expect(f.service.resume(UID)).rejects.toThrow(); expect(f.provider.create).toHaveBeenCalledTimes(1); + f.disable(); await expect(f.service.status(UID)).rejects.toThrow(); +}); diff --git a/src/App.jsx b/src/App.jsx index 94dd1dde..1f8e338a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -30,6 +30,8 @@ import JoinUsConditionalRoute from './pages/joinUs/JoinUsConditionalRoute'; import Terms from './pages/legal/Terms'; import Privacy from './pages/legal/Privacy'; import Community from './pages/community/Community'; +import TestDues from './pages/community/TestDues'; +import { TEST_DUES_ENABLED } from './services/community/testDuesService'; import CommunityAdmin from './pages/community/CommunityAdmin'; import FictionalRaces from './pages/community/FictionalRaces'; import { COMMUNITY_ENABLED } from './services/community/communityService'; @@ -89,6 +91,7 @@ function App() { } /> } /> } /> + {TEST_DUES_ENABLED && } />} {COMMUNITY_ENABLED && } />} {COMMUNITY_ENABLED && } />} {COMMUNITY_ENABLED && } />} diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx index 0e4aa7e9..62d2b977 100644 --- a/src/pages/community/Community.tsx +++ b/src/pages/community/Community.tsx @@ -9,6 +9,7 @@ import { callCommunity, COMMUNITY_ENABLED, CommunityRequest, CommunityStatus } from '../../services/community/communityService'; import SEO from '../../components/SEO'; +import { TEST_DUES_ENABLED } from '../../services/community/testDuesService'; import { communityFreshFor, parseCommunityResult, parseCommunityStatus } from '../../services/community/communityMemberStatus'; import './Community.css'; @@ -174,6 +175,9 @@ export default function Community() { No real membership, race entry or payment is created.

+ {TEST_DUES_ENABLED && ( + Test annual dues + )} My Account {' '} · diff --git a/src/pages/community/TestDues.test.tsx b/src/pages/community/TestDues.test.tsx new file mode 100644 index 00000000..ad470a0f --- /dev/null +++ b/src/pages/community/TestDues.test.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { + act, fireEvent, render, screen, waitFor +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { HelmetProvider } from 'react-helmet-async'; +import TestDues from './TestDues'; +import { callTestDues, openTestCheckout } from '../../services/community/testDuesService'; + +let mockUser: any; +let mockAuth: any; +let mockServices: any; +let mockCallback: (u: any) => void; +const mockApp = { options: { projectId: 'run-mprc-staging' } }; +jest.mock('firebase/auth', () => ({ + onIdTokenChanged: (auth: any, cb: (u: any) => void) => { + mockCallback = cb; + cb(auth.currentUser); + return jest.fn(); + } +})); +jest.mock('../../services/hooks/useAuth', () => ({ + useAuth: () => ({ + user: mockUser, + isLoading: false + }) +})); +jest.mock('../../services/ServiceLocatorContext', () => ({ useServiceLocator: () => ({ services: mockServices }) })); +jest.mock('../../services/community/testDuesService', () => ({ + ...jest.requireActual('../../services/community/testDuesService'), + TEST_DUES_ENABLED: true, + callTestDues: jest.fn(), + openTestCheckout: jest.fn() +})); +const call = callTestDues as jest.Mock; +const state = () => ({ + schemaVersion: 1, + observedAtMs: 1792152000000, + active: false, + accessThroughMs: null, + order: null, + waiver: { version: 'synthetic-dues-v1', text: 'SYNTHETIC TEST ONLY. No real waiver.' }, + offers: ['individual', 'family'].map((kind) => ({ + membershipDuesCheckoutSchemaVersion: 1, + planRef: `dues-2026-${kind}-v1`, + policyVersion: 'test-dues-calendar-v1', + termId: 'calendar-2026', + amountMinor: kind === 'individual' ? 2500 : 3000, + currency: 'usd', + startsAtMs: 1767254400000, + endsAtMs: 1798790400000, + offerable: true + })) +}); +const order = { + id: 'a'.repeat(64), + state: 'open', + offerId: 'dues-2026-individual-v1', + amountMinor: 2500, + currency: 'usd', + endsAtMs: 1798790400000, + familyId: null +}; +const show = () => ( + + + + + +); +const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); +beforeEach(() => { + jest.clearAllMocks(); + mockUser = { uid: 'fictional' }; + mockAuth = { + app: mockApp, + tenantId: null, + currentUser: { + uid: 'fictional', + emailVerified: true + } + }; + mockServices = { + firebaseResources: { + auth: mockAuth, + functions: { app: mockApp } + } + }; + call.mockResolvedValue(state()); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { randomUUID: () => '123e4567-e89b-42d3-a456-426614174000' } + }); +}); +afterEach(() => { jest.useRealTimers(); }); +afterAll(() => { + if (originalCrypto) { + Object.defineProperty(globalThis, 'crypto', originalCrypto); + } + else { + Reflect.deleteProperty(globalThis, 'crypto'); + } +}); +test('signed-out page calls no backend', () => { + mockUser = null; + mockAuth.currentUser = null; + render(show()); + expect(screen.getByText('Sign in to continue')).toBeInTheDocument(); + expect(call).not.toHaveBeenCalled(); +}); +test('consent precedes server-priced test checkout and redirects only to verified response', async () => { + render(show()); + const button = await screen.findByRole('button', { name: 'Continue to individual test checkout' }); + expect(button).toBeDisabled(); + fireEvent.click(screen.getByRole('checkbox')); + call.mockResolvedValueOnce({ + order, + checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_synthetic' + }); + fireEvent.click(button); + await waitFor(() => expect(openTestCheckout).toHaveBeenCalledWith('https://checkout.stripe.com/c/pay/cs_test_synthetic')); + expect(call.mock.calls[1][2]).toEqual({ + action: 'prepare', + accountUid: 'fictional', + requestId: '123e4567-e89b-42d3-a456-426614174000', + offerId: 'dues-2026-individual-v1', + waiverVersion: 'synthetic-dues-v1', + waiverAccepted: true + }); +}); +test('lost prepare retains exact request; retry does not create another ID or trust return query', async () => { + render(show()); + const button = await screen.findByRole('button', { name: 'Continue to individual test checkout' }); + fireEvent.click(screen.getByRole('checkbox')); + call.mockRejectedValueOnce(new Error('uncertain')); + fireEvent.click(button); + const retry = await screen.findByRole('button', { name: 'Retry saved request' }); + await waitFor(() => expect(retry).toBeEnabled()); + const saved = call.mock.calls[1][2]; + call.mockResolvedValueOnce({ order }).mockResolvedValueOnce({ + ...state(), + order + }); + fireEvent.click(retry); + await screen.findByText('Your saved purchase'); + expect(call.mock.calls[2][2]).toEqual(saved); + expect(openTestCheckout).not.toHaveBeenCalled(); +}); +test('reload recovers server order; no new-purchase controls while pending', async () => { + call.mockResolvedValue({ + ...state(), + order + }); + render(show()); + await screen.findByText('Your saved purchase'); + expect(screen.getByRole('button', { name: 'Resume saved checkout' })).toBeEnabled(); + expect(screen.queryByText('Continue to individual test checkout')).not.toBeInTheDocument(); +}); +test('same-UID new session discards late result and saved consent', async () => { + let resolve: (v: any) => void = () => { }; + call.mockReturnValueOnce(new Promise((r) => { + resolve = r; + })); + render(show()); + call.mockRejectedValue(new Error('unavailable')); + await act(async () => { + mockCallback(mockAuth.currentUser); + }); + await act(async () => { + resolve(state()); + }); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); +}); +test('deadline removes controls without polling', async () => { + jest.useFakeTimers(); + render(show()); + await screen.findByRole('checkbox'); + const calls = call.mock.calls.length; + act(() => { + jest.advanceTimersByTime(60001); + }); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(call).toHaveBeenCalledTimes(calls); +}); +test('bad response hides all purchase controls', async () => { + call.mockResolvedValue({ + ...state(), + unexpected: true + }); + render(show()); + await screen.findByText(/We could not confirm/); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); +}); diff --git a/src/pages/community/TestDues.tsx b/src/pages/community/TestDues.tsx new file mode 100644 index 00000000..75970a9e --- /dev/null +++ b/src/pages/community/TestDues.tsx @@ -0,0 +1,291 @@ +import React, { + useEffect, useMemo, useRef, useState +} from 'react'; +import { onIdTokenChanged, User } from 'firebase/auth'; +import { Link } from 'react-router-dom'; +import { useAuth } from '../../services/hooks/useAuth'; +import { useServiceLocator } from '../../services/ServiceLocatorContext'; +import { + callTestDues, DuesRequest, DuesStatus, openTestCheckout, + parseDuesResult, parseDuesStatus, TEST_DUES_ENABLED +} from '../../services/community/testDuesService'; +import SEO from '../../components/SEO'; +import './Community.css'; + +const date = (ms: number) => new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Los_Angeles', + dateStyle: 'medium' +}).format(ms - 1); +export default function TestDues() { + const { services } = useServiceLocator(); + const { user, isLoading } = useAuth(); + const auth = services?.firebaseResources.auth; + const functions = services?.firebaseResources.functions; + const [session, setSession] = useState<{ + user: User; + epoch: symbol; + } | null>(null); + const enabled = TEST_DUES_ENABLED + && functions?.app.options.projectId === 'run-mprc-staging' + && auth?.app === functions?.app + && auth?.tenantId === null; + const identity = useMemo(() => Symbol('test-dues'), [enabled, + services, + auth, + functions, + session, + user?.uid, + isLoading]); + const currentIdentity = useRef(identity); + currentIdentity.current = identity; + const serial = useRef(0); + const inFlight = useRef(false); + const pending = useRef<{ + identity: symbol; + request: DuesRequest; + } | null>(null); + const [snapshot, setSnapshot] = useState<{ + identity: symbol; + value: DuesStatus; + began: number; + wall: number; + budget: number; + } | null>(null); + const [busy, setBusy] = useState(false); + const [accepted, setAccepted] = useState(false); + const [message, setMessage] = useState(''); + const current = () => enabled + && !isLoading + && session + && auth?.currentUser === session.user + && session.user.uid === user?.uid + && session.user.emailVerified; + const fresh = () => snapshot + && snapshot.identity === identity + && Math.max( + performance.now() - snapshot.began, + Date.now() - snapshot.wall, + ) < snapshot.budget; + const status = current() && fresh() ? snapshot?.value : null; + const saved = pending.current?.identity === identity ? pending.current.request : null; + async function perform(action: DuesRequest['action'] = 'status', offerId?: string, retry = false) { + if (!current() || !auth || !functions || !session || inFlight.current) return; + if (action === 'prepare' && !retry && (!status || !fresh() || !accepted)) return; + const capture = identity; + serial.current += 1; + const generation = serial.current; + const valid = () => currentIdentity.current === capture + && serial.current === generation + && current(); + let request = retry ? saved : { action, accountUid: session.user.uid } as DuesRequest; + if (action === 'prepare' && !retry) { + try { + request = { + ...request, + requestId: crypto.randomUUID(), + offerId, + waiverVersion: status?.waiver.version, + waiverAccepted: true + }; + } + catch { + setMessage('Use an up-to-date browser to prepare checkout.'); + return; + } + } + if (!request) return; + if (request.action === 'prepare') pending.current = { identity: capture, request }; + inFlight.current = true; + setBusy(true); + setSnapshot(null); + setAccepted(false); + setMessage(''); + try { + if (request.action !== 'status') { + const result = parseDuesResult(await callTestDues(functions, auth, request)); + if (!valid()) return; + pending.current = null; + if (result.checkoutUrl) { + openTestCheckout(result.checkoutUrl); + return; + } + } + const began = performance.now(); + const wall = Date.now(); + const value = parseDuesStatus(await callTestDues(functions, auth, { + action: 'status', + accountUid: session.user.uid + })); + if (!valid()) return; + const budget = Math.min( + 60000, + ...value.offers.map((o) => o.endsAtMs - value.observedAtMs), + ...(value.accessThroughMs ? [value.accessThroughMs - value.observedAtMs] : []), + ); + if (Math.max(performance.now() - began, Date.now() - wall) >= budget) throw new Error('stale'); + setSnapshot({ + identity: capture, value, began, wall, budget + }); + setMessage('Current test status loaded.'); + } + catch { + if (valid()) { + setSnapshot(null); + setMessage('We could not confirm this request. Refresh status or resume the saved purchase. Do not start another payment.'); + } + } + finally { + if (valid()) { + inFlight.current = false; + setBusy(false); + } + } + } + useEffect(() => { + if (!enabled || !auth) { + setSession(null); + return undefined; + } + return onIdTokenChanged(auth, (next) => { + serial.current += 1; + pending.current = null; + inFlight.current = false; + setSnapshot(null); + setAccepted(false); + setBusy(false); + setSession(next ? { + user: next, + epoch: Symbol('native-dues') + } : null); + }); + }, [enabled, auth]); + useEffect(() => { + serial.current += 1; + pending.current = null; + inFlight.current = false; + setSnapshot(null); + setAccepted(false); + setBusy(false); + setMessage(''); + if (current()) perform(); + return () => { serial.current += 1; }; + }, [identity]); + useEffect(() => { + if (!snapshot) return undefined; + let timer: number; + const expire = () => { + window.clearTimeout(timer); + const remaining = snapshot.budget - Math.max( + performance.now() - snapshot.began, + Date.now() - snapshot.wall, + ); + if (remaining <= 0) { + setSnapshot(null); + setAccepted(false); + setMessage('Refresh status before continuing.'); + } + else timer = window.setTimeout(expire, Math.ceil(remaining)); + }; + expire(); + window.addEventListener('focus', expire); + document.addEventListener('visibilitychange', expire); + return () => { + window.clearTimeout(timer); + window.removeEventListener('focus', expire); + document.removeEventListener('visibilitychange', expire); + }; + }, [snapshot]); + const canBuy = status && !status.active && (!status.order || status.order.state === 'expired'); + let accountMessage = ''; + if (!enabled) accountMessage = 'Test checkout is not connected here yet.'; + else if (isLoading) accountMessage = 'Loading account…'; + else if (user?.uid && !current()) accountMessage = 'Verify your email and sign in again to continue.'; + return ( +
+ +

MID-PENINSULA RUNNING CLUB

+

Test annual dues

+

+ Staging only. Use a fictional account and Stripe test details. + No real payment or club membership is created. +

+
+ Membership and free events + {' · '} + My Account +
+ {accountMessage &&

{accountMessage}

} + {enabled && !isLoading && !user?.uid && (Sign in to continue)} + {current() && ( + <> + + {saved && ( + +)} +

{busy ? 'Checking test membership…' : message}

+ {status?.active &&

Your test membership is active.

} + {status?.order && ( +
+

Your saved purchase

+

{`Status: ${status.order.state.replace(/_/g, ' ')}.`}

+

{`$${(status.order.amountMinor / 100).toFixed(2)} USD test dues.`}

+

{`Calendar-year term ends ${date(status.order.endsAtMs)}, Pacific time.`}

+ {status.order.familyId && ( +

+ {`Family ID: ${status.order.familyId}. `} + Invitations for other accounts are not available yet. + An ID alone does not grant membership. +

+)} + {['prepared', 'open'].includes(status.order.state) && ( + +)} + {['open', 'prepared', 'paid_pending_activation'].includes(status.order.state) && ( + +)} + {['held', 'paid_pending_activation'].includes(status.order.state) && ( +

+ Ask the test owner to reconcile this purchase. + Keep the saved order; do not pay again. +

+)} +
+)} + {canBuy && status && ( + <> +
+ Read the test waiver +

{status.waiver.text}

+
+ +
+ {status.offers.map((o) => { + const kind = o.planRef.includes('-family-') ? 'family' : 'individual'; + return ( +
+

{kind === 'family' ? 'Family' : 'Individual'}

+

{`$${(o.amountMinor / 100).toFixed(2)} USD per calendar year.`}

+

{`Ends ${date(o.endsAtMs)}, Pacific time. No automatic renewal.`}

+ +
+ ); + })} +
+ +)} + +)} +
+ ); +} diff --git a/src/services/community/testDuesService.test.ts b/src/services/community/testDuesService.test.ts new file mode 100644 index 00000000..65312509 --- /dev/null +++ b/src/services/community/testDuesService.test.ts @@ -0,0 +1,87 @@ +import { callTestDues, parseDuesResult, parseDuesStatus } from './testDuesService'; + +const value = () => ({ + schemaVersion: 1, + observedAtMs: 1792152000000, + active: false, + accessThroughMs: null, + order: null, + waiver: { version: 'synthetic-dues-v1', text: 'SYNTHETIC TEST ONLY. No real waiver.' }, + offers: ['individual', 'family'].map((kind) => ({ + membershipDuesCheckoutSchemaVersion: 1, + planRef: `dues-2026-${kind}-v1`, + policyVersion: 'test-dues-calendar-v1', + termId: 'calendar-2026', + amountMinor: kind === 'individual' ? 2500 : 3000, + currency: 'usd', + startsAtMs: 1767254400000, + endsAtMs: 1798790400000, + offerable: true + })) +}); +test('parses only bounded test offers/status', () => { + expect(parseDuesStatus(value()).offers).toHaveLength(2); +}); +test.each([{ active: true }, + { accessThroughMs: 1798790400000 }, + { schemaVersion: 2 }, + { offers: [] }, + { arbitrary: 'data' }])('rejects unknown/inconsistent status %j', (patch) => { + expect(() => parseDuesStatus({ + ...value(), + ...patch + })).toThrow(); +}); +test('rejects price drift, getters and duplicate plans', () => { + const v = value(); + v.offers[0].amountMinor = 1; + expect(() => parseDuesStatus(v)).toThrow(); + const getter = jest.fn(); + const a = value(); + Object.defineProperty(a, 'order', { + get: getter, + enumerable: true + }); + expect(() => parseDuesStatus(a)).toThrow(); + expect(getter).not.toHaveBeenCalled(); + const b = value(); + [, + b.offers[1]] = [null, + b.offers[0]]; + expect(() => parseDuesStatus(b)).toThrow(); +}); +test('ephemeral URL must be test Checkout on an open order', () => { + const order = { + id: 'a'.repeat(64), + state: 'open', + offerId: 'dues-2026-individual-v1', + amountMinor: 2500, + currency: 'usd', + endsAtMs: 1798790400000, + familyId: null + }; + expect(parseDuesResult({ + order, + checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_synthetic' + }).checkoutUrl).toContain('cs_test_'); + ['https://evil.example/c/pay/cs_test_x', + 'https://checkout.stripe.com/c/pay/cs_live_x'].forEach((checkoutUrl) => { + expect(() => parseDuesResult({ + order, + checkoutUrl + })).toThrow(); + }); + expect(() => parseDuesResult({ + order: { + ...order, + state: 'active' + }, + checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_x' + })).toThrow(); +}); +test('default-off frontend cannot contact Firebase', async () => { + await expect(callTestDues({ app: { options: { projectId: 'run-mprc-staging' } } } as any, { currentUser: null } as any, { + action: 'status', + accountUid: 'fictional' + })).rejects.toThrow(); +}); diff --git a/src/services/community/testDuesService.ts b/src/services/community/testDuesService.ts new file mode 100644 index 00000000..7ea3fd21 --- /dev/null +++ b/src/services/community/testDuesService.ts @@ -0,0 +1,196 @@ +import { httpsCallable, Functions } from 'firebase/functions'; +import { Auth } from 'firebase/auth'; +import { COMMUNITY_ENABLED } from './communityService'; + +export const TEST_DUES_ENABLED = COMMUNITY_ENABLED + && process.env.REACT_APP_TEST_DUES_STAGING === 'true'; +export type DuesRequest = { + action: 'status' | 'prepare' | 'resume' | 'reconcile'; + accountUid: string; + requestId?: string; + offerId?: string; + waiverVersion?: string; + waiverAccepted?: true; +}; +export type DuesOrder = { + id: string; + state: 'prepared' | 'open' | 'expired' | 'paid_pending_activation' | 'active' | 'held'; + offerId: string; + amountMinor: number; + currency: 'usd'; + endsAtMs: number; + familyId: string | null; +}; +export type DuesOffer = { + planRef: string; + amountMinor: number; + currency: 'usd'; + startsAtMs: number; + endsAtMs: number; + offerable: boolean; +}; +export type DuesStatus = { + schemaVersion: 1; + observedAtMs: number; + offers: DuesOffer[]; + waiver: { + version: string; + text: string; + }; + active: boolean; + accessThroughMs: number | null; + order: DuesOrder | null; +}; +const check = (v: unknown) => { if (!v) throw new Error('Test dues status is unavailable.'); }; +const integer = (v: unknown): v is number => typeof v === 'number' + && Number.isSafeInteger(v) + && v >= 0 + && v <= 8640000000000000; +function object(v: unknown, keys: string[], optional: string[] = []): Record { + check(v !== null + && typeof v === 'object' + && !Array.isArray(v) + && Object.getPrototypeOf(v) === Object.prototype); + const d = Object.getOwnPropertyDescriptors(v); + check(keys.every((k) => Object.prototype.hasOwnProperty.call(d, k)) + && Reflect.ownKeys(d).every((k) => typeof k === 'string' + && [...keys, + ...optional].includes(k) + && d[k].enumerable + && Object.prototype.hasOwnProperty.call(d[k], 'value'))); + return Object.fromEntries(Object.entries(d).map(([k, p]) => [k, p.value])); +} +function offerId(v: unknown): v is string { + return typeof v === 'string' + && /^dues-\d{4}-(individual|family)-v1$/.test(v); +} +function price(v: Record) { + check(offerId(v.offerId ?? v.planRef) && v.currency === 'usd' + && v.amountMinor === (String(v.offerId ?? v.planRef).includes('-individual-') ? 2500 : 3000)); +} +function order(v: unknown): DuesOrder | null { + if (v === null) return null; + const o = object(v, ['id', + 'state', + 'offerId', + 'amountMinor', + 'currency', + 'endsAtMs', + 'familyId']); + price(o); + check(typeof o.id === 'string' && /^[a-f0-9]{64}$/.test(o.id) && integer(o.endsAtMs) + && ['prepared', + 'open', + 'expired', + 'paid_pending_activation', + 'active', + 'held'].includes(o.state) + && (o.familyId === null + || (o.state === 'active' + && o.offerId.includes('-family-') + && o.familyId === `family-${o.id.slice(0, 32)}`))); + return o as DuesOrder; +} +export function parseDuesStatus(v: unknown): DuesStatus { + try { + const s = object(v, ['schemaVersion', + 'observedAtMs', + 'offers', + 'waiver', + 'active', + 'accessThroughMs', + 'order']); + check(s.schemaVersion === 1 && integer(s.observedAtMs) && typeof s.active === 'boolean' + && (s.active ? integer(s.accessThroughMs) + && s.accessThroughMs > s.observedAtMs : s.accessThroughMs === null)); + check(Array.isArray(s.offers)); + const entries = Object.getOwnPropertyDescriptors(s.offers); + check(entries.length?.value === 2 && Reflect.ownKeys(entries).length === 3); + const plans = [0, 1].map((index) => { + const descriptor = entries[String(index)]; + check(descriptor?.enumerable + && Object.prototype.hasOwnProperty.call(descriptor, 'value')); + const p = object(descriptor.value, ['membershipDuesCheckoutSchemaVersion', + 'planRef', + 'policyVersion', + 'termId', + 'amountMinor', + 'currency', + 'startsAtMs', + 'endsAtMs', + 'offerable']); + price(p); + check(p.membershipDuesCheckoutSchemaVersion === 1 + && p.policyVersion === 'test-dues-calendar-v1' + && p.termId === `calendar-${p.planRef.slice(5, 9)}` + && integer(p.startsAtMs) + && integer(p.endsAtMs) + && p.startsAtMs <= s.observedAtMs + && s.observedAtMs < p.endsAtMs + && typeof p.offerable === 'boolean'); + return p as DuesOffer; + }); + check(plans[0].planRef !== plans[1].planRef + && plans[0].startsAtMs === plans[1].startsAtMs + && plans[0].endsAtMs === plans[1].endsAtMs); + const w = object(s.waiver, ['version', 'text']); + check(w.version === 'synthetic-dues-v1' + && typeof w.text === 'string' + && w.text.length <= 2000 + && w.text.startsWith('SYNTHETIC TEST ONLY.')); + return { + schemaVersion: 1, + observedAtMs: s.observedAtMs, + offers: plans, + waiver: { + version: w.version, + text: w.text + }, + active: s.active, + accessThroughMs: s.accessThroughMs, + order: order(s.order) + }; + } + catch { + throw new Error('Test dues status is unavailable.'); + } +} +export function parseDuesResult(v: unknown): { + order: DuesOrder; + checkoutUrl?: string; +} { + const r = object(v, ['order'], ['checkoutUrl']); + const o = order(r.order); + check(o); + if (r.checkoutUrl !== undefined) { + check(o?.state === 'open' && typeof r.checkoutUrl === 'string' && r.checkoutUrl.length <= 4096); + const u = new URL(r.checkoutUrl); + check(u.protocol === 'https:' + && u.hostname === 'checkout.stripe.com' + && !u.username + && !u.password + && !u.port + && u.pathname.startsWith('/c/pay/cs_test_')); + } + return { order: o as DuesOrder, ...(r.checkoutUrl ? { checkoutUrl: r.checkoutUrl } : {}) }; +} +export async function callTestDues( + functions: Functions, + auth: Auth, + request: DuesRequest, +): Promise { + const user = auth.currentUser; + const bound = () => TEST_DUES_ENABLED + && functions.app.options.projectId === 'run-mprc-staging' + && auth.app === functions.app + && auth.tenantId === null + && user + && auth.currentUser === user + && user.uid === request.accountUid + && user.emailVerified; + if (!bound()) throw new Error('Test dues are unavailable.'); + const result = await httpsCallable(functions, 'membershipTestDues')(request); + if (!bound()) throw new Error('Test dues are unavailable.'); + return result.data; +} +export function openTestCheckout(url: string) { window.location.assign(url); } diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index 50e1feaf..0249d826 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -43,7 +43,7 @@ const CLEAN_COMMANDS = Object.freeze([ 'test -z "$frontend_git_status"', ]); const LINT_SCRIPT = 'node .github/scripts/check-frontend-lint.cjs'; -const EXPECTED_LINT_FILES = 133; +const EXPECTED_LINT_FILES = 137; const EXPECTED_LINT_ERRORS = 113; const EXPECTED_LINT_WARNINGS = 6; const YAML_TO_JSON = [ diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index cb52b51b..f10270f3 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -774,6 +774,14 @@ describe('server-only operational collections', () => { ['promoCodes/PROMO1', 'promoCodes/PROMO2', { discountPercent: 10 }], ['ratelimits/checkout_ip__1.2.3.4', 'ratelimits/checkout_ip__5.6.7.8', { count: 5 }], ['mail/message1', 'mail/message2', { to: ['runner@example.com'] }], + ['membershipTestOrders/fictional1', 'membershipTestOrders/fictional2', { schemaVersion: 1 }], + ['membershipTestAccounts/fictional1', 'membershipTestAccounts/fictional2', { schemaVersion: 1 }], + ['membershipTestParticipants/fictional1', 'membershipTestParticipants/fictional2', { schemaVersion: 1 }], + ['membershipTestTerms/fictional1', 'membershipTestTerms/fictional2', { schemaVersion: 1 }], + ['membershipTestFamilies/fictional1', 'membershipTestFamilies/fictional2', { schemaVersion: 1 }], + ['membershipTestEvents/fictional1', 'membershipTestEvents/fictional2', { schemaVersion: 1 }], + ['membershipTestAudit/fictional1', 'membershipTestAudit/fictional2', { schemaVersion: 1 }], + ['membershipTestLimits/fictional1', 'membershipTestLimits/fictional2', { schemaVersion: 1 }], ['stripeEvents/evt_test_1', 'stripeEvents/evt_test_2', { status: 'processed' }], ['checkoutRequests/request1', 'checkoutRequests/request2', { status: 'created' }], ['auditEvents/audit1', 'auditEvents/audit2', { action: 'refund' }], From 54dd8a9e284d3b86fb14b17531b56945b0691321 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Fri, 16 Oct 2026 18:57:00 -0700 Subject: [PATCH 2/4] Keep prepared dues request explicit for optimized type checking --- src/pages/community/TestDues.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/community/TestDues.tsx b/src/pages/community/TestDues.tsx index 75970a9e..7b1154db 100644 --- a/src/pages/community/TestDues.tsx +++ b/src/pages/community/TestDues.tsx @@ -81,7 +81,8 @@ export default function TestDues() { if (action === 'prepare' && !retry) { try { request = { - ...request, + action: 'prepare', + accountUid: session.user.uid, requestId: crypto.randomUUID(), offerId, waiverVersion: status?.waiver.version, From 7bbcebdb451a452da8e468c0baf97b1c650771ee Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Fri, 16 Oct 2026 19:05:32 -0700 Subject: [PATCH 3/4] Fence dues recovery and bind exact verified Checkout navigation --- docs/TEST_DUES_ENGINEERING.md | 4 ++-- functions/testDuesStripe.js | 8 ++++---- functions/testDuesStripe.test.js | 10 ++++++++++ functions/testMembershipDues.js | 7 ++++++- functions/testMembershipDues.test.js | 9 +++++++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/TEST_DUES_ENGINEERING.md b/docs/TEST_DUES_ENGINEERING.md index 6117e2de..067b673b 100644 --- a/docs/TEST_DUES_ENGINEERING.md +++ b/docs/TEST_DUES_ENGINEERING.md @@ -4,11 +4,11 @@ This bounded staging integration consumes `membershipDuesCheckout`, `membershipD ## Trust and transitions -Native callable Auth/App Check, matching caller/default tenant, recent verified token and current enabled/verified Auth metadata precede requests. The private `membershipTestParticipants/{uid}` marker must be current and synthetic; it is never a profile role. Exact staging or all three explicit loopback emulator hosts under a demo project, `COMMERCE_ENABLED=false` and `MPRC_TEST_DUES_ENABLED=true` are required. The frontend additionally needs its default-off staging flag. There is no production/live fallback. +Native callable Auth/App Check, matching caller/default tenant, recent verified token and current enabled/verified Auth metadata precede requests. The private `membershipTestParticipants/{uid}` marker must be current and synthetic, including before reconciliation consumes a provider read or rate write; it is never a profile role. Exact staging or all three explicit loopback emulator hosts under a demo project, `COMMERCE_ENABLED=false` and `MPRC_TEST_DUES_ENABLED=true` are required. The frontend additionally needs its default-off staging flag. There is no production/live fallback. Server offers fix 2500/3000 integer USD cents, LA calendar boundaries and a synthetic consent version/hash. No future term, automatic renewal or public external-payment instructions. One account lock and request-derived order ID are committed with immutable expectation, nonce, price, consent, expiration and provider idempotency key before any Checkout creation. Parallel commands/plans conflict; an exact request reuses byte-identical provider parameters. Unbound creation retries stop after five minutes, well within the provider's idempotency retention. Past that horizon the unresolved order stays held for private reconciliation; never guess that it is safe to charge again. Bound open Sessions are retrieved for resume. Verified empty expired Sessions may release the purchase lock; paid/held/pending orders cannot be discarded for a replacement purchase in the same year. -The adapter verifies the actual Stripe account for each operation, accepts only test credentials and objects, fixes Checkout URLs, and projects no customer/contact data. Raw signed webhook bodies are bounded and verified before a minimal durable event receipt. SDK retrieval checks Session, nonce/order/client reference, test realm, amount/currency and current PaymentIntent/Charge, including refunds/disputes before an older paid callback. Charge reversals resolve the exact Session with a bounded PaymentIntent query. Invalid or foreign observations grant nothing. Duplicate/out-of-order deliveries re-read current provider state and cannot regress an active/held term. Cancellation return is a read, never a payment or refund command. +The adapter verifies the actual Stripe account for each operation, accepts only test credentials and objects, binds each Checkout URL to its verified exact Session, rejects malformed refund counters, and projects no customer/contact data. Raw signed webhook bodies are bounded and verified before a minimal durable event receipt. SDK retrieval checks Session, nonce/order/client reference, test realm, amount/currency and current PaymentIntent/Charge, including refunds/disputes before an older paid callback. Charge reversals resolve the exact Session with a bounded PaymentIntent query. Invalid or foreign observations grant nothing. Duplicate/out-of-order deliveries re-read current provider state and cannot regress an active/held term. Cancellation return is a read, never a payment or refund command. Verified paid evidence, immutable term receipt, canonical `communityMemberships/{uid}` activation, optional private family root and transition audit are atomic. Changed revision/withdrawal/suspension/association, invalid current account/participant or an expired original term leaves durable paid-pending evidence. No silent rebase, renewal, unsuspension or refund. A defensive test reversal holds the order permanently and suspends only the authority still bound to that payment; it cannot revoke a later unrelated term. This is deliberately not real refund policy. Auth metadata is rechecked immediately before the transaction but cannot be atomically locked with Firestore; native distributed revocation guarantees are not claimed. diff --git a/functions/testDuesStripe.js b/functions/testDuesStripe.js index a4142b42..e2ffc1cb 100644 --- a/functions/testDuesStripe.js +++ b/functions/testDuesStripe.js @@ -3,8 +3,8 @@ const Stripe = require('stripe'); const { check, RETURN_URL } = require('./testDuesPolicy'); const ref = (s, prefix) => typeof s === 'string' && new RegExp(`^${prefix}_[A-Za-z0-9]{1,120}$`).test(s); -function checkoutUrl(url) { - try { const u = new URL(url); check(u.protocol === 'https:' && u.hostname === 'checkout.stripe.com' && !u.username && !u.password && !u.port && u.pathname.startsWith('/c/pay/cs_test_')); return url; } catch { check(false); } +function checkoutUrl(url, sessionId) { + try { const u = new URL(url); check(u.protocol === 'https:' && u.hostname === 'checkout.stripe.com' && !u.username && !u.password && !u.port && u.pathname.startsWith('/c/pay/cs_test_') && (!sessionId || u.pathname === `/c/pay/${sessionId}`)); return url; } catch { check(false); } } function checkoutParameters(o) { return { mode: 'payment', payment_method_types: ['card'], client_reference_id: o.id, @@ -34,7 +34,7 @@ function createTestStripe({ key, webhookSecret, account }) { const c = pi.latest_charge; if (c) { check(typeof c === 'object' && ref(c.id, 'ch') && c.livemode === false && c.payment_intent === pi.id - && c.amount === s.amount_total && c.currency === s.currency && typeof c.disputed === 'boolean' && Number.isSafeInteger(c.amount_refunded)); + && c.amount === s.amount_total && c.currency === s.currency && typeof c.disputed === 'boolean' && Number.isSafeInteger(c.amount_refunded) && c.amount_refunded >= 0 && c.amount_refunded <= c.amount); reversal = c.disputed || c.amount_refunded > 0; } paid = s.status === 'complete' && s.payment_status === 'paid' && pi.status === 'succeeded' @@ -44,7 +44,7 @@ function createTestStripe({ key, webhookSecret, account }) { return { checkoutRef: s.id, orderId: s.metadata.order, nonce: s.metadata.nonce, providerAccountRef: account, livemode: false, amountMinor: s.amount_total, currency: s.currency, paymentRef, paid, reversal, expired: s.status === 'expired' && s.payment_status === 'unpaid' && !s.payment_intent, - ...(includeUrl && s.status === 'open' && s.url ? { url: checkoutUrl(s.url) } : {}), + ...(includeUrl && s.status === 'open' && s.url ? { url: checkoutUrl(s.url, s.id) } : {}), }; } return { diff --git a/functions/testDuesStripe.test.js b/functions/testDuesStripe.test.js index a84de2b4..d12b366e 100644 --- a/functions/testDuesStripe.test.js +++ b/functions/testDuesStripe.test.js @@ -60,3 +60,13 @@ test('only ephemeral test Checkout URL can be returned', () => { expect(checkoutUrl('https://checkout.stripe.com/c/pay/cs_test_synthetic')).toContain('cs_test_'); for (const url of ['https://checkout.stripe.com.evil/c/pay/cs_test_x', 'https://checkout.stripe.com/c/pay/cs_live_x', 'http://checkout.stripe.com/c/pay/cs_test_x', 'https://user@checkout.stripe.com/c/pay/cs_test_x']) expect(() => checkoutUrl(url)).toThrow(); }); + +test.each([-1, 1.5, 2501])('malformed refunded amount rejects without activation: %s', async amount => { + const pi = intent(); pi.latest_charge.amount_refunded = amount; + sdk.paymentIntents.retrieve.mockResolvedValue(pi); + await expect(adapter.retrieve('cs_test_synthetic')).rejects.toThrow(); +}); +test('Checkout URL must point at the verified exact Session', async () => { + sdk.checkout.sessions.retrieve.mockResolvedValue({ ...session(), status: 'open', payment_status: 'unpaid', payment_intent: null, url: 'https://checkout.stripe.com/c/pay/cs_test_foreign' }); + await expect(adapter.retrieve('cs_test_synthetic', true)).rejects.toThrow(); +}); diff --git a/functions/testMembershipDues.js b/functions/testMembershipDues.js index a343bb98..78be277a 100644 --- a/functions/testMembershipDues.js +++ b/functions/testMembershipDues.js @@ -155,7 +155,12 @@ function createTestDuesService({ db, auth, provider, account, clock = Date.now } } async function resume(uid) { await actor(uid); return open(uid, await latest(uid)); } async function reconcile(uid) { - await actor(uid); await rate(uid); const o = await latest(uid); check(o.checkoutRef); + await actor(uid); + await db.runTransaction(async tx => { + const p = await tx.get(participantRef(uid)); + check(marked(p.data(), clock()), 'permission-denied'); + }); + await rate(uid); const o = await latest(uid); check(o.checkoutRef); return { order: await observe(await provider.retrieve(o.checkoutRef)) }; } async function webhook(event) { diff --git a/functions/testMembershipDues.test.js b/functions/testMembershipDues.test.js index ea16320d..ea837928 100644 --- a/functions/testMembershipDues.test.js +++ b/functions/testMembershipDues.test.js @@ -124,3 +124,12 @@ test('revoked marker/current disabled account blocks resume and no extra provide f.docs.delete(`membershipTestParticipants/${UID}`); await expect(f.service.resume(UID)).rejects.toThrow(); expect(f.provider.create).toHaveBeenCalledTimes(1); f.disable(); await expect(f.service.status(UID)).rejects.toThrow(); }); + +test('revoked synthetic participant cannot reconcile or consume provider/rate resources', async () => { + const f = fixture(); await f.service.prepare(UID, f.request()); + f.docs.delete(`membershipTestParticipants/${UID}`); + const before = JSON.stringify([...f.docs]); + await expect(f.service.reconcile(UID)).rejects.toThrow(); + expect(f.provider.retrieve).not.toHaveBeenCalled(); + expect(JSON.stringify([...f.docs])).toBe(before); +}); From fec9127ff2727b83a675064e9e6fc050189da0f6 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Fri, 16 Oct 2026 19:10:43 -0700 Subject: [PATCH 4/4] Show annual renewal and useful saved-purchase recovery controls --- docs/officers/TEST_DUES.md | 2 +- functions/testMembershipDues.test.js | 16 ++++++++++++++++ src/pages/community/TestDues.test.tsx | 23 +++++++++++++++++++++++ src/pages/community/TestDues.tsx | 12 +++++++++--- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/officers/TEST_DUES.md b/docs/officers/TEST_DUES.md index 4bf43ff3..362cf7eb 100644 --- a/docs/officers/TEST_DUES.md +++ b/docs/officers/TEST_DUES.md @@ -30,7 +30,7 @@ **Stop conditions:** real information or money is requested; the test notice disappears; another account's state appears; an unresolved purchase offers another charge; an old term extends into a new year; a failed read leaves controls; or a provider, account, App Check or release check is missing. Near year end, checkout may be unavailable because the provider's minimum session lifetime would cross the term boundary. -**Uncertain or held purchase:** keep the saved order and ask Dave for specialist reconciliation through a tracked change. Do not pay again, edit records, grant roles, issue refunds or change provider settings. Payment received after withdrawal, suspension, account loss, conflicting revisions or term expiry is retained for reconciliation rather than overriding that state. Rechecking verified identity can finish an otherwise unchanged pending activation. Any observed test refund/dispute places a sticky defensive hold; this does not choose a real club refund policy. +**Uncertain or held purchase:** keep the saved order and ask Dave for specialist reconciliation through a tracked change. A declined-card attempt can retain a provider payment record even after checkout expires; it stays locked for specialist reconciliation. Do not pay again, edit records, grant roles, issue refunds or change provider settings. Payment received after withdrawal, suspension, account loss, conflicting revisions or term expiry is retained for reconciliation rather than overriding that state. Rechecking verified identity can finish an otherwise unchanged pending activation. Any observed test refund/dispute places a sticky defensive hold; this does not choose a real club refund policy. **Success proof:** exact reviewed head and green CI, native Firestore concurrency results, actual Stripe sandbox account/webhook verification, genuine staging identity/App Check, matching Function and Hosting release receipts, dated phone/desktop checks, and exact owned cleanup. Unit doubles, generated signatures and emulator transactions do not prove native Stripe or hosted payment behavior. diff --git a/functions/testMembershipDues.test.js b/functions/testMembershipDues.test.js index ea837928..8b5cf91a 100644 --- a/functions/testMembershipDues.test.js +++ b/functions/testMembershipDues.test.js @@ -133,3 +133,19 @@ test('revoked synthetic participant cannot reconcile or consume provider/rate re expect(f.provider.retrieve).not.toHaveBeenCalled(); expect(JSON.stringify([...f.docs])).toBe(before); }); + +test('old-year reversal cannot suspend a later paid membership term', async () => { + const f = fixture(); await f.service.prepare(UID, f.request()); f.paid(); await f.service.reconcile(UID); + const old = f.getOrder(); + f.setNow(yearStart(2027)); f.docs.set(`membershipTestParticipants/${UID}`, { schemaVersion: 1, synthetic: true, expiresAtMs: f.now() + 86400000 }); + f.provider.create.mockImplementationOnce(async next => { + f.setObservation({ orderId: next.id, nonce: next.nonce, checkoutRef: 'cs_test_newyear', paymentRef: null, paid: false, reversal: false }); + return f.provider.retrieve(); + }); + await f.service.prepare(UID, f.request()); + f.setObservation({ paid: true, paymentRef: 'pi_newyear' }); await f.service.reconcile(UID); + const before = f.docs.get(`communityMemberships/${UID}`); expect(before.authority.term.termId).toBe('calendar-2027'); + f.setObservation({ orderId: old.id, nonce: old.nonce, checkoutRef: 'cs_test_fictional', paymentRef: 'pi_fictional', paid: false, reversal: true }); + await f.service.webhook(f.event('evt_oldyear_reversal')); + expect(f.docs.get(`communityMemberships/${UID}`)).toEqual(before); expect((await f.service.status(UID)).active).toBe(true); +}); diff --git a/src/pages/community/TestDues.test.tsx b/src/pages/community/TestDues.test.tsx index ad470a0f..1bc6e875 100644 --- a/src/pages/community/TestDues.test.tsx +++ b/src/pages/community/TestDues.test.tsx @@ -195,3 +195,26 @@ test('bad response hides all purchase controls', async () => { await screen.findByText(/We could not confirm/); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); + +test('after a paid year ends the current-year offer is available without showing old access', async () => { + const now = 1798790400000; + const next = { ...state(), observedAtMs: now, order: { ...order, state: 'active', endsAtMs: now } }; + next.offers = next.offers.map((p) => ({ + ...p, +planRef: p.planRef.replace('2026', '2027'), + termId: 'calendar-2027', +startsAtMs: now, +endsAtMs: 1830326400000 +})); + call.mockResolvedValue(next); render(show()); + expect(await screen.findByRole('button', { name: 'Continue to individual test checkout' })).toBeDisabled(); + expect(screen.getByText('Payment confirmed. That term has ended.')).toBeInTheDocument(); + expect(screen.queryByText('Your test membership is active.')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('checkbox')); + expect(screen.getByRole('button', { name: 'Continue to individual test checkout' })).toBeEnabled(); +}); +test('an unbound prepared order offers resume rather than a doomed payment check', async () => { + call.mockResolvedValue({ ...state(), order: { ...order, state: 'prepared' } }); render(show()); + expect(await screen.findByRole('button', { name: 'Resume saved checkout' })).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'Check payment status' })).not.toBeInTheDocument(); +}); diff --git a/src/pages/community/TestDues.tsx b/src/pages/community/TestDues.tsx index 7b1154db..fe5b6cca 100644 --- a/src/pages/community/TestDues.tsx +++ b/src/pages/community/TestDues.tsx @@ -196,7 +196,10 @@ export default function TestDues() { document.removeEventListener('visibilitychange', expire); }; }, [snapshot]); - const canBuy = status && !status.active && (!status.order || status.order.state === 'expired'); + const previousTermPaid = status?.order?.state === 'active' + && status.order.endsAtMs <= status.observedAtMs; + const canBuy = status && !status.active + && (!status.order || status.order.state === 'expired' || previousTermPaid); let accountMessage = ''; if (!enabled) accountMessage = 'Test checkout is not connected here yet.'; else if (isLoading) accountMessage = 'Loading account…'; @@ -230,7 +233,10 @@ export default function TestDues() { {status?.order && (

Your saved purchase

-

{`Status: ${status.order.state.replace(/_/g, ' ')}.`}

+

+ {previousTermPaid ? 'Payment confirmed. That term has ended.' + : `Status: ${status.order.state.replace(/_/g, ' ')}.`} +

{`$${(status.order.amountMinor / 100).toFixed(2)} USD test dues.`}

{`Calendar-year term ends ${date(status.order.endsAtMs)}, Pacific time.`}

{status.order.familyId && ( @@ -245,7 +251,7 @@ export default function TestDues() { Resume saved checkout )} - {['open', 'prepared', 'paid_pending_activation'].includes(status.order.state) && ( + {['open', 'paid_pending_activation'].includes(status.order.state) && (