Question / Task
Part of the Phase 1 Payment System: Design & Build Map, graduated from Design backend data model & Stripe integration flow for Phase 1 payments (see that ticket's resolution comment for full design rationale).
Add the foundational schema and shared pricing constants everything else in this map builds on.
Migration (follow packages/backend/src/Migrations/2026_03_19_email_events.ts's style):
- Add
voter_limit (integer, not null) to electionDB. Backfill existing rows: voter_limit = GREATEST(100, current roll count for that election) — so no election that already has more than 100 voters on its roll today becomes retroactively unable to keep its current voters (it just can't add more without paying, same as any other election over the free limit). New elections get voter_limit = FREE_TIER_LIMIT (100) at creation.
- Add
voter_limit to the Election interface (packages/shared/src/domain_model/Election.ts) and to electionValidation().
- Create
stripeCheckoutSessionsDB: id (serial PK), election_id (varchar, not null), user_id (varchar, not null), product (varchar, not null — 'voter_limit' for now), amount_cents (integer, not null), voter_count_granted (integer, nullable), stripe_checkout_session_id (varchar, not null, unique — the webhook idempotency key), stripe_customer_id (varchar), status (varchar, not null, 'pending' | 'paid'), created_date (timestamptz, not null). Index on election_id.
- Model class
StripeCheckoutSessionsDB in packages/backend/src/Models/, following EmailEvents.ts's thin-wrapper style: insert(), getByStripeSessionId(), markPaid(), sumVoterLimitPurchases(election_id) (sums voter_count_granted where product='voter_limit' AND status='paid' — useful for audit/support, not for computing voter_limit itself, which is authoritative on the election row).
Shared pricing config (packages/shared, alongside SharedConfig.ts or a new small config module): FREE_TIER_LIMIT = 100, BLOCK_SIZE = 200, PRICE_PER_BLOCK_CENTS = 1000.
No Stripe dashboard Product/Price objects need to be created — pricing uses dynamic price_data per Checkout Session (confirmed in the closed research ticket).
Question / Task
Part of the Phase 1 Payment System: Design & Build Map, graduated from Design backend data model & Stripe integration flow for Phase 1 payments (see that ticket's resolution comment for full design rationale).
Add the foundational schema and shared pricing constants everything else in this map builds on.
Migration (follow
packages/backend/src/Migrations/2026_03_19_email_events.ts's style):voter_limit(integer, not null) toelectionDB. Backfill existing rows:voter_limit = GREATEST(100, current roll count for that election)— so no election that already has more than 100 voters on its roll today becomes retroactively unable to keep its current voters (it just can't add more without paying, same as any other election over the free limit). New elections getvoter_limit = FREE_TIER_LIMIT(100) at creation.voter_limitto theElectioninterface (packages/shared/src/domain_model/Election.ts) and toelectionValidation().stripeCheckoutSessionsDB:id(serial PK),election_id(varchar, not null),user_id(varchar, not null),product(varchar, not null —'voter_limit'for now),amount_cents(integer, not null),voter_count_granted(integer, nullable),stripe_checkout_session_id(varchar, not null, unique — the webhook idempotency key),stripe_customer_id(varchar),status(varchar, not null,'pending'|'paid'),created_date(timestamptz, not null). Index onelection_id.StripeCheckoutSessionsDBinpackages/backend/src/Models/, followingEmailEvents.ts's thin-wrapper style:insert(),getByStripeSessionId(),markPaid(),sumVoterLimitPurchases(election_id)(sumsvoter_count_grantedwhereproduct='voter_limit' AND status='paid'— useful for audit/support, not for computingvoter_limititself, which is authoritative on the election row).Shared pricing config (
packages/shared, alongsideSharedConfig.tsor a new small config module):FREE_TIER_LIMIT = 100,BLOCK_SIZE = 200,PRICE_PER_BLOCK_CENTS = 1000.No Stripe dashboard Product/Price objects need to be created — pricing uses dynamic
price_dataper Checkout Session (confirmed in the closed research ticket).