Skip to content

Phase 1 Payment System: Design & Build Map #1566

Description

@ArendPeter

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 paymentsvoter_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 pendingpaid 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 PRneeds 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions