Human Note
This is a subtask of #1132 , and is managed via wayfinder
Phase 1 will allow users to pay to increase the voter limit
Destination
A production-ready Phase 1 payment system: Stripe-backed service fees enforced at the existing voter-limit check (addElectionRollController.ts:86-91), an "Upgrade Election" button + cart popout on the Add Voters page (validated via prototype) that checks out through a single Stripe Checkout Session, and the Free/Large/Custom tier pricing from the pricing doc treated as final — shipped to production. The checkout data model and flow are built generically around a small in-repo product catalog, so a future add-on product slots in as a new catalog entry rather than a rework of the session/table/endpoint/webhook plumbing — but Phase 1 registers and ships only one catalog entry (voter-limit blocks); no second product type is designed or built here. Phase 2 (donations) and Phase 3 (supporter tier, including custom URL slugs) are separate future efforts.
Notes
- Source of truth: BetterVoting Pricing Model & Design doc — tier pricing (Free ≤100 voters; Large 100–5,000 at $10/200 voters; Custom >5,000 contact-us), tax framing ("program service fees," not tax-deductible), and Stripe-over-NationBuilder already decided there. Treat as settled context, not open questions.
- This map carries execution — unlike default wayfinder scope, tickets here include real production build work (backend + frontend), not just planning/spec, per explicit decision on this map.
- Existing code anchors: voter-limit enforcement at
packages/backend/src/Controllers/Roll/addElectionRollController.ts:86-91; hardcoded override config at packages/shared/src/config/SharedConfig.ts; existing modal convention ConfirmationDialogProvider.tsx / useConfirm() hook (already used in AddElectionRoll.tsx).
- Ticket-type skill notes: a
task ticket needing dashboard/account actions (e.g. Stripe credential retrieval) → call the wizard skill. The prototype ticket → call the prototype skill. The research ticket → call the research skill. Default to grilling + domain-modeling otherwise.
- No Stripe/payment/billing code exists anywhere in the repo today — this is greenfield.
Decisions so far
- Research Stripe Checkout, Products/Prices & webhooks for tiered one-time payments — Stripe Checkout Sessions (not Payment Links/Payment Intents); upgrade-tier delta must be computed application-side (no Stripe primitive for it); confirm via
checkout.session.completed/checkout.session.async_payment_succeeded webhooks; client_reference_id/metadata carry election_id/user_id/tier; receipt disclaimer needs custom_text.after_submit or a custom email. Findings.
- Retrieve Stripe test-mode API keys for Equal Vote's account — retrieved via a rebuilt wizard (
scripts/wizard-stripe-test-keys.sh, local/uncommitted); keys stored in packages/backend/.env as STRIPE_TEST_PUBLISHABLE_KEY / STRIPE_TEST_SECRET_KEY. The wizard's account-verification step had to be rewritten mid-flight — Stripe dropped the fixed top-right "Test mode" toggle in favor of an account-switcher-driven flow (legacy Test/Live toggle or the newer Sandboxes model, both top-left) — worth knowing for any future Stripe-dashboard wizard on this map.
- Design backend data model & Stripe integration flow for Phase 1 payments —
voter_limit is a plain top-level Election column (not derived from cumulative cents — each purchase grants a fixed voter count locked in at its own price, "cart" semantics, so later pricing changes never retroactively affect what a past purchase bought), initialized to the free-tier default and incremented per-purchase through the existing updateElection() versioning (which already gives full history for free); a separate stripeCheckoutSessionsDB table is the Stripe-side bookkeeping and webhook idempotency key; fulfillment lives in a dedicated function (not inside generic updateElection()) with simple pending→paid status that leans on Stripe's own webhook retry for failure recovery; voter_limit is guarded from client tampering in editElectionController.ts alone (no flag added to updateElection()). Its single-product checkout shape (one blocks-only purchase per session) was superseded by the cart redesign below — the data-model/versioning/tamper-guard decisions in this paragraph still stand. Graduated into 5 build tickets: migration & config, resolution hierarchy & response shape, Checkout Session endpoint, webhook & fulfillment, webhook secret provisioning.
- Provision Stripe webhook endpoint & retrieve signing secret — resolved via the Stripe CLI path (
stripe listen --forward-to localhost:$BACKEND_PORT/API/StripeWebhook, subscribed to checkout.session.completed + checkout.session.async_payment_succeeded) rather than a dashboard-registered endpoint, using a wizard (scripts/wizard-stripe-webhook-secret.sh, local/uncommitted) that let the choice be made live rather than assumed; secret stored in packages/backend/.env as STRIPE_WEBHOOK_SECRET. This secret is CLI-session-scoped (not stable across stripe listen restarts) — webhook & fulfillment needs the listener kept running (or re-run via the wizard) for local/E2E testing; a stable dashboard-registered endpoint remains available later for staging/production once there's a public URL.
- Add voter_limit column & stripeCheckoutSessionsDB table (migration + shared pricing config) —
voter_limit (not null, backfilled to GREATEST(100, current head roll count) so no election loses voters it already has) added to electionDB; stripeCheckoutSessionsDB created per spec; pricingConfig (FREE_TIER_LIMIT/BLOCK_SIZE/PRICE_PER_BLOCK_CENTS) added to shared config; new elections default voter_limit server-side in ElectionsDB.createElection (tamper-resistant, same pattern as create_date/head). Along the way, fixed a pre-existing bug in packages/shared's "./config" export map that silently bypassed config/index.ts's barrel re-exports for any consumer. Draft PR — needs a follow-up amendment (product column → jsonb) per the cart redesign below, flagged as a comment on the PR and on this ticket rather than pushed directly.
- Redesign checkout data model & flow for a multi-product cart — the organizer-facing flow moved from a tier-select modal to a cart ("Upgrade Election" button → popout, potentially multiple line items → one Checkout Session). One
stripeCheckoutSessionsDB row is still one checkout session, not one row per line item; product becomes a JSON array snapshotting every line item's Stripe price_data exactly as shown to the user (audit trail of intent, not a normalized reference); voter_count_granted stays its own column, now an aggregate across whichever line items grant voter limit. A small in-repo product-catalog registry (pricing, price_data, validation, and fulfillment effect per type) is the extensibility point for future add-ons — Phase 1 registers exactly one entry, voter-limit blocks; no second type is built. Checkout-session creation and webhook fulfillment both re-derive from this catalog server-side, never from client-sent price/copy. Webhook fulfillment wraps the paid-status flip and every line item's effect in one Postgres transaction — all-or-nothing. Resolution. Downstream tickets rewritten to match: Checkout Session endpoint, webhook & fulfillment, prototype.
Not yet specified
- Exact frontend build-out breakdown beyond the cart prototype (wiring the validated cart UI to the backend's "payment required" response and to the Checkout Session endpoint) — graduates once the UI prototype (#1569) is validated.
- Whether the frontend needs to poll the transaction's paid/pending status after the Stripe-hosted redirect back to the success page (the webhook may not have landed by the time the browser redirects) — not sharp enough to ticket yet; may resolve to "no, redirect is enough" once the actual redirect timing is observed.
Out of scope
- Phase 2 — Donation Add-On (optional donation field, dual Stripe metadata, tax-deductible receipts, monthly commitment) — ruled out of this map's scope; a future map once Phase 1 is live and usage patterns are visible.
- Phase 3 — Supporter Tier ($50 one-time → custom URL slugs + prompt exemption), including a custom-slug cart add-on — ruled out; depends on custom URL slugs, an unbuilt prerequisite feature (confirmed absent from the codebase as of the cart redesign). The Phase 1 product catalog is designed to admit this as a future entry without a rework, but no second entry is built now.
- NationBuilder integration — superseded by the direct-Stripe decision in the pricing doc.
- Moving
sharedConfig election overrides into a backend service — Phase 1 reads the existing hardcoded config file as-is; migrating overrides to a backend-managed service is a future map once there's a concrete driver (e.g. an admin UI).
Human Note
This is a subtask of #1132 , and is managed via wayfinder
Phase 1 will allow users to pay to increase the voter limit
Destination
A production-ready Phase 1 payment system: Stripe-backed service fees enforced at the existing voter-limit check (
addElectionRollController.ts:86-91), an "Upgrade Election" button + cart popout on the Add Voters page (validated via prototype) that checks out through a single Stripe Checkout Session, and the Free/Large/Custom tier pricing from the pricing doc treated as final — shipped to production. The checkout data model and flow are built generically around a small in-repo product catalog, so a future add-on product slots in as a new catalog entry rather than a rework of the session/table/endpoint/webhook plumbing — but Phase 1 registers and ships only one catalog entry (voter-limit blocks); no second product type is designed or built here. Phase 2 (donations) and Phase 3 (supporter tier, including custom URL slugs) are separate future efforts.Notes
packages/backend/src/Controllers/Roll/addElectionRollController.ts:86-91; hardcoded override config atpackages/shared/src/config/SharedConfig.ts; existing modal conventionConfirmationDialogProvider.tsx/useConfirm()hook (already used inAddElectionRoll.tsx).taskticket needing dashboard/account actions (e.g. Stripe credential retrieval) → call thewizardskill. Theprototypeticket → call theprototypeskill. Theresearchticket → call theresearchskill. Default togrilling+domain-modelingotherwise.Decisions so far
checkout.session.completed/checkout.session.async_payment_succeededwebhooks;client_reference_id/metadatacarryelection_id/user_id/tier; receipt disclaimer needscustom_text.after_submitor a custom email. Findings.scripts/wizard-stripe-test-keys.sh, local/uncommitted); keys stored inpackages/backend/.envasSTRIPE_TEST_PUBLISHABLE_KEY/STRIPE_TEST_SECRET_KEY. The wizard's account-verification step had to be rewritten mid-flight — Stripe dropped the fixed top-right "Test mode" toggle in favor of an account-switcher-driven flow (legacy Test/Live toggle or the newer Sandboxes model, both top-left) — worth knowing for any future Stripe-dashboard wizard on this map.voter_limitis a plain top-levelElectioncolumn (not derived from cumulative cents — each purchase grants a fixed voter count locked in at its own price, "cart" semantics, so later pricing changes never retroactively affect what a past purchase bought), initialized to the free-tier default and incremented per-purchase through the existingupdateElection()versioning (which already gives full history for free); a separatestripeCheckoutSessionsDBtable is the Stripe-side bookkeeping and webhook idempotency key; fulfillment lives in a dedicated function (not inside genericupdateElection()) with simplepending→paidstatus that leans on Stripe's own webhook retry for failure recovery;voter_limitis guarded from client tampering ineditElectionController.tsalone (no flag added toupdateElection()). Its single-product checkout shape (one blocks-only purchase per session) was superseded by the cart redesign below — the data-model/versioning/tamper-guard decisions in this paragraph still stand. Graduated into 5 build tickets: migration & config, resolution hierarchy & response shape, Checkout Session endpoint, webhook & fulfillment, webhook secret provisioning.stripe listen --forward-to localhost:$BACKEND_PORT/API/StripeWebhook, subscribed tocheckout.session.completed+checkout.session.async_payment_succeeded) rather than a dashboard-registered endpoint, using a wizard (scripts/wizard-stripe-webhook-secret.sh, local/uncommitted) that let the choice be made live rather than assumed; secret stored inpackages/backend/.envasSTRIPE_WEBHOOK_SECRET. This secret is CLI-session-scoped (not stable acrossstripe listenrestarts) — webhook & fulfillment needs the listener kept running (or re-run via the wizard) for local/E2E testing; a stable dashboard-registered endpoint remains available later for staging/production once there's a public URL.voter_limit(not null, backfilled toGREATEST(100, current head roll count)so no election loses voters it already has) added toelectionDB;stripeCheckoutSessionsDBcreated per spec;pricingConfig(FREE_TIER_LIMIT/BLOCK_SIZE/PRICE_PER_BLOCK_CENTS) added to shared config; new elections defaultvoter_limitserver-side inElectionsDB.createElection(tamper-resistant, same pattern ascreate_date/head). Along the way, fixed a pre-existing bug inpackages/shared's"./config"export map that silently bypassedconfig/index.ts's barrel re-exports for any consumer. Draft PR — needs a follow-up amendment (productcolumn →jsonb) per the cart redesign below, flagged as a comment on the PR and on this ticket rather than pushed directly.stripeCheckoutSessionsDBrow is still one checkout session, not one row per line item;productbecomes a JSON array snapshotting every line item's Stripeprice_dataexactly as shown to the user (audit trail of intent, not a normalized reference);voter_count_grantedstays its own column, now an aggregate across whichever line items grant voter limit. A small in-repo product-catalog registry (pricing,price_data, validation, and fulfillment effect per type) is the extensibility point for future add-ons — Phase 1 registers exactly one entry, voter-limit blocks; no second type is built. Checkout-session creation and webhook fulfillment both re-derive from this catalog server-side, never from client-sent price/copy. Webhook fulfillment wraps the paid-status flip and every line item's effect in one Postgres transaction — all-or-nothing. Resolution. Downstream tickets rewritten to match: Checkout Session endpoint, webhook & fulfillment, prototype.Not yet specified
Out of scope
sharedConfigelection overrides into a backend service — Phase 1 reads the existing hardcoded config file as-is; migrating overrides to a backend-managed service is a future map once there's a concrete driver (e.g. an admin UI).