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 and re-designed for a cart by Redesign checkout data model & flow for a multi-product cart (see that ticket's resolution comment for the full rationale). Blocked by the migration/config ticket (stripeCheckoutSessionsDB table, pricing constants) — and by the follow-up schema amendment flagged on PR #1589 / #1584 (product column → jsonb).
Build the Checkout Session creation endpoint, e.g. POST /API/Elections/:id/CheckoutSession (adjust to match this repo's existing route conventions in elections.routes.ts/roll.routes.ts).
Product catalog: a small in-repo registry (shared config or a dedicated module), one entry per product type, each knowing how to price itself, build its own Stripe price_data (name/description/image), validate a requested quantity, and — used by the webhook ticket — apply its own fulfillment effect. Phase 1 registers exactly one entry: voter-limit blocks (BLOCK_SIZE voters per unit, PRICE_PER_BLOCK_CENTS each, capped so voter_limit can't exceed 5,000 — the Custom tier stays contact-us only, no Checkout Session path for it). This registry is the extensibility point for future add-ons (e.g. a custom-slug product) — building a second entry is explicitly out of scope for this ticket and this map; only the one-entry shape needs to exist.
Request — the frontend sends the cart it displayed, as a list of items, not raw prices:
{ "items": [ { "type": "voter_limit_block", "quantity": 3 } ] }
Validation, per item, in order:
- Reject any
type not present in the Phase-1 catalog (i.e. anything but voter_limit_block) — there's nothing else to accept yet, so this is a flat reject, not a partial-fulfillment case.
- For
voter_limit_block: recompute new_voter_limit = election.voter_limit + quantity * BLOCK_SIZE; reject if > 5000.
On success: build one Stripe price_data line item per cart item from the catalog (never client-supplied price/copy — the client's request only carries type + quantity), compute the total (amount_cents) and the aggregate voter_count_granted (sum across any voter_limit_block items) from the backend's own catalog numbers, assemble the full product JSON blob (an exact snapshot of every line item's price_data + type-specific payload, e.g. { type: 'voter_limit_block', blocks: 3, ...price_data }), insert one pending row into stripeCheckoutSessionsDB, then create the Stripe Checkout Session (mode: 'payment', hosted page, line_items built from the catalog price_data per item, submit_type: 'pay' — never 'donate', client_reference_id = election_id, metadata = {election_id, user_id, checkout_session_row_id}, custom_text.after_submit carrying the "this is a program service fee, not tax-deductible" disclaimer).
Both the reactive trigger (organizer blocked while adding voters) and the proactive "Upgrade Election" button/cart should hit this same endpoint/contract — no separate code paths.
Response: the Stripe-hosted Checkout URL for the frontend to redirect to.
Requires STRIPE_SECRET_KEY (test-mode value already retrieved and stored per the "Retrieve Stripe test-mode API keys" 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 and re-designed for a cart by Redesign checkout data model & flow for a multi-product cart (see that ticket's resolution comment for the full rationale). Blocked by the migration/config ticket (
stripeCheckoutSessionsDBtable, pricing constants) — and by the follow-up schema amendment flagged on PR #1589 / #1584 (productcolumn →jsonb).Build the Checkout Session creation endpoint, e.g.
POST /API/Elections/:id/CheckoutSession(adjust to match this repo's existing route conventions inelections.routes.ts/roll.routes.ts).Product catalog: a small in-repo registry (shared config or a dedicated module), one entry per product type, each knowing how to price itself, build its own Stripe
price_data(name/description/image), validate a requested quantity, and — used by the webhook ticket — apply its own fulfillment effect. Phase 1 registers exactly one entry: voter-limit blocks (BLOCK_SIZEvoters per unit,PRICE_PER_BLOCK_CENTSeach, capped sovoter_limitcan't exceed 5,000 — the Custom tier stays contact-us only, no Checkout Session path for it). This registry is the extensibility point for future add-ons (e.g. a custom-slug product) — building a second entry is explicitly out of scope for this ticket and this map; only the one-entry shape needs to exist.Request — the frontend sends the cart it displayed, as a list of items, not raw prices:
{ "items": [ { "type": "voter_limit_block", "quantity": 3 } ] }Validation, per item, in order:
typenot present in the Phase-1 catalog (i.e. anything butvoter_limit_block) — there's nothing else to accept yet, so this is a flat reject, not a partial-fulfillment case.voter_limit_block: recomputenew_voter_limit = election.voter_limit + quantity * BLOCK_SIZE; reject if> 5000.On success: build one Stripe
price_dataline item per cart item from the catalog (never client-supplied price/copy — the client's request only carriestype+quantity), compute the total (amount_cents) and the aggregatevoter_count_granted(sum across anyvoter_limit_blockitems) from the backend's own catalog numbers, assemble the fullproductJSON blob (an exact snapshot of every line item'sprice_data+ type-specific payload, e.g.{ type: 'voter_limit_block', blocks: 3, ...price_data }), insert onependingrow intostripeCheckoutSessionsDB, then create the Stripe Checkout Session (mode: 'payment', hosted page,line_itemsbuilt from the catalogprice_dataper item,submit_type: 'pay'— never'donate',client_reference_id = election_id,metadata = {election_id, user_id, checkout_session_row_id},custom_text.after_submitcarrying the "this is a program service fee, not tax-deductible" disclaimer).Both the reactive trigger (organizer blocked while adding voters) and the proactive "Upgrade Election" button/cart should hit this same endpoint/contract — no separate code paths.
Response: the Stripe-hosted Checkout URL for the frontend to redirect to.
Requires
STRIPE_SECRET_KEY(test-mode value already retrieved and stored per the "Retrieve Stripe test-mode API keys" ticket).