From a2aa2e89be11b188a4d9fe0250794d9a803fe964 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Mon, 24 Aug 2026 13:55:45 -0700 Subject: [PATCH 1/4] Add voter limit column, and stripe checkout session db --- .../DevElections/elections/emailtracking.ts | 1 + .../DevElections/elections/starprordering.ts | 1 + .../src/DevElections/elections/tiechecks.ts | 1 + .../src/DevElections/elections/wizardstar.ts | 1 + .../src/DevElections/elections/writeins.ts | 1 + ...6_08_24_voter_limit_and_stripe_sessions.ts | 50 +++++++++++++++++ packages/backend/src/Models/Database.ts | 4 +- packages/backend/src/Models/Elections.ts | 5 +- .../src/Models/StripeCheckoutSessions.ts | 56 +++++++++++++++++++ .../__mocks__/StripeCheckoutSessions.ts | 31 ++++++++++ packages/backend/src/test/database_sandbox.ts | 1 + packages/shared/package.json | 4 +- packages/shared/src/config/PricingConfig.ts | 5 ++ packages/shared/src/config/index.ts | 1 + packages/shared/src/domain_model/Election.ts | 4 ++ .../src/domain_model/StripeCheckoutSession.ts | 17 ++++++ 16 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts create mode 100644 packages/backend/src/Models/StripeCheckoutSessions.ts create mode 100644 packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts create mode 100644 packages/shared/src/config/PricingConfig.ts create mode 100644 packages/shared/src/domain_model/StripeCheckoutSession.ts diff --git a/packages/backend/src/DevElections/elections/emailtracking.ts b/packages/backend/src/DevElections/elections/emailtracking.ts index 010195db5..5c07146cc 100644 --- a/packages/backend/src/DevElections/elections/emailtracking.ts +++ b/packages/backend/src/DevElections/elections/emailtracking.ts @@ -48,6 +48,7 @@ const election: Election = { update_date: Date.now().toString(), head: true, ballot_source: 'live_election', + voter_limit: 100, }; // Only Alice and Bob have voted diff --git a/packages/backend/src/DevElections/elections/starprordering.ts b/packages/backend/src/DevElections/elections/starprordering.ts index ff685a2e2..cabd59314 100644 --- a/packages/backend/src/DevElections/elections/starprordering.ts +++ b/packages/backend/src/DevElections/elections/starprordering.ts @@ -48,6 +48,7 @@ const election: Election = { update_date: Date.now().toString(), head: true, ballot_source: 'live_election', + voter_limit: 100, }; // 10 ballots — scores ordered as [Anchovy, Brussels, Carrot, Durian, Eggplant]. diff --git a/packages/backend/src/DevElections/elections/tiechecks.ts b/packages/backend/src/DevElections/elections/tiechecks.ts index d232f2a10..b296dca69 100644 --- a/packages/backend/src/DevElections/elections/tiechecks.ts +++ b/packages/backend/src/DevElections/elections/tiechecks.ts @@ -43,6 +43,7 @@ const election: Election = { update_date: Date.now().toString(), head: true, ballot_source: 'live_election', + voter_limit: 100, }; // 6 ballots — scores ordered as [Watermelon, Green Apple, Blue Raspberry, Grape, Cherry] diff --git a/packages/backend/src/DevElections/elections/wizardstar.ts b/packages/backend/src/DevElections/elections/wizardstar.ts index d8cf3f0bc..b374b2539 100644 --- a/packages/backend/src/DevElections/elections/wizardstar.ts +++ b/packages/backend/src/DevElections/elections/wizardstar.ts @@ -42,6 +42,7 @@ const election: Election = { update_date: Date.now().toString(), head: true, ballot_source: 'live_election', + voter_limit: 100, }; // 6 ballots with distinct voting patterns for STAR (scores 0-5) diff --git a/packages/backend/src/DevElections/elections/writeins.ts b/packages/backend/src/DevElections/elections/writeins.ts index 3d9a9d1fc..b3133ff4f 100644 --- a/packages/backend/src/DevElections/elections/writeins.ts +++ b/packages/backend/src/DevElections/elections/writeins.ts @@ -57,6 +57,7 @@ const election: Election = { update_date: Date.now().toString(), head: true, ballot_source: 'live_election', + voter_limit: 100, }; function makeBallots(): Ballot[] { diff --git a/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts b/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts new file mode 100644 index 000000000..633096696 --- /dev/null +++ b/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts @@ -0,0 +1,50 @@ +import { Kysely, sql } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema.alterTable('electionDB') + .addColumn('voter_limit', 'integer') + .execute() + + // Backfill: no election that already has more than 100 voters on its roll + // today becomes retroactively unable to keep its current voters. + await sql` + UPDATE "electionDB" e + SET voter_limit = GREATEST(100, ( + SELECT COUNT(*)::integer + FROM "electionRollDB" r + WHERE r.election_id = e.election_id AND r.head = true + )) + `.execute(db) + + await db.schema.alterTable('electionDB') + .alterColumn('voter_limit', (col) => col.setNotNull()) + .execute() + + await db.schema + .createTable('stripeCheckoutSessionsDB') + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('election_id', 'varchar', (col) => col.notNull()) + .addColumn('user_id', 'varchar', (col) => col.notNull()) + .addColumn('product', 'varchar', (col) => col.notNull()) + .addColumn('amount_cents', 'integer', (col) => col.notNull()) + .addColumn('voter_count_granted', 'integer') + .addColumn('stripe_checkout_session_id', 'varchar', (col) => col.notNull().unique()) + .addColumn('stripe_customer_id', 'varchar') + .addColumn('status', 'varchar', (col) => col.notNull()) + .addColumn('created_date', 'timestamptz', (col) => col.notNull()) + .execute() + + await db.schema + .createIndex('idx_stripe_checkout_sessions_election_id') + .on('stripeCheckoutSessionsDB') + .column('election_id') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('stripeCheckoutSessionsDB').execute() + + await db.schema.alterTable('electionDB') + .dropColumn('voter_limit') + .execute() +} diff --git a/packages/backend/src/Models/Database.ts b/packages/backend/src/Models/Database.ts index 9c1737060..56eb6238c 100644 --- a/packages/backend/src/Models/Database.ts +++ b/packages/backend/src/Models/Database.ts @@ -2,10 +2,12 @@ import { Ballot } from "@equal-vote/star-vote-shared/domain_model/Ballot"; import { Election } from "@equal-vote/star-vote-shared/domain_model/Election"; import { ElectionRoll } from "@equal-vote/star-vote-shared/domain_model/ElectionRoll"; import { EmailEvent } from "@equal-vote/star-vote-shared/domain_model/EmailEvent"; +import { StripeCheckoutSession } from "@equal-vote/star-vote-shared/domain_model/StripeCheckoutSession"; export interface Database { electionDB: Election, electionRollDB: ElectionRoll, ballotDB: Ballot, - emailEventsDB: EmailEvent + emailEventsDB: EmailEvent, + stripeCheckoutSessionsDB: StripeCheckoutSession } \ No newline at end of file diff --git a/packages/backend/src/Models/Elections.ts b/packages/backend/src/Models/Elections.ts index 05da46912..22955910a 100644 --- a/packages/backend/src/Models/Elections.ts +++ b/packages/backend/src/Models/Elections.ts @@ -4,7 +4,7 @@ import { ILoggingContext } from '../Services/Logging/ILogger'; import Logger from '../Services/Logging/Logger'; import { Kysely, sql } from 'kysely' import { Election, electionValidation } from '@equal-vote/star-vote-shared/domain_model/Election'; -import { sharedConfig } from '@equal-vote/star-vote-shared/config'; +import { sharedConfig, pricingConfig } from '@equal-vote/star-vote-shared/config'; import { IElectionStore } from './IElectionStore'; import { Conflict, InternalServerError } from '@curveball/http-errors'; import { BadRequest } from "@curveball/http-errors"; @@ -45,9 +45,10 @@ export default class ElectionsDB implements IElectionStore { createElection(election: Election, ctx: ILoggingContext, reason: string): Promise { Logger.debug(ctx, `${tableName}.createElection`, election); - election.update_date = Date.now().toString()// Use now() because it doesn't change with time zone + election.update_date = Date.now().toString()// Use now() because it doesn't change with time zone election.head = true election.create_date = new Date().toISOString() + election.voter_limit = pricingConfig.FREE_TIER_LIMIT const newElection = this._postgresClient .insertInto(tableName) diff --git a/packages/backend/src/Models/StripeCheckoutSessions.ts b/packages/backend/src/Models/StripeCheckoutSessions.ts new file mode 100644 index 000000000..0942980d5 --- /dev/null +++ b/packages/backend/src/Models/StripeCheckoutSessions.ts @@ -0,0 +1,56 @@ +import { ILoggingContext } from '../Services/Logging/ILogger'; +import Logger from '../Services/Logging/Logger'; +import { Kysely } from 'kysely' +import { Database } from './Database'; +import { StripeCheckoutSession } from '@equal-vote/star-vote-shared/domain_model/StripeCheckoutSession'; + +const tableName = 'stripeCheckoutSessionsDB'; + +export default class StripeCheckoutSessionsDB { + + _postgresClient; + + constructor(postgresClient: Kysely) { + this._postgresClient = postgresClient; + } + + async insert(session: Omit, ctx: ILoggingContext): Promise { + Logger.debug(ctx, `${tableName}.insert election_id=${session.election_id} stripe_checkout_session_id=${session.stripe_checkout_session_id}`); + await this._postgresClient + .insertInto(tableName) + .values(session) + .execute(); + } + + async getByStripeSessionId(stripe_checkout_session_id: string, ctx: ILoggingContext): Promise { + Logger.debug(ctx, `${tableName}.getByStripeSessionId`); + const result = await this._postgresClient + .selectFrom(tableName) + .where('stripe_checkout_session_id', '=', stripe_checkout_session_id) + .selectAll() + .executeTakeFirst(); + return result ?? null; + } + + async markPaid(stripe_checkout_session_id: string, ctx: ILoggingContext): Promise { + Logger.debug(ctx, `${tableName}.markPaid stripe_checkout_session_id=${stripe_checkout_session_id}`); + await this._postgresClient + .updateTable(tableName) + .set({ status: 'paid' }) + .where('stripe_checkout_session_id', '=', stripe_checkout_session_id) + .execute(); + } + + // Audit/support total only — voter_limit itself is authoritative on the election row. + async sumVoterLimitPurchases(election_id: string, ctx: ILoggingContext): Promise { + Logger.debug(ctx, `${tableName}.sumVoterLimitPurchases election_id=${election_id}`); + const result = await this._postgresClient + .selectFrom(tableName) + .select((eb) => eb.fn.sum('voter_count_granted').as('total')) + .where('election_id', '=', election_id) + .where('product', '=', 'voter_limit') + .where('status', '=', 'paid') + .executeTakeFirst(); + return Number(result?.total ?? 0); + } +} diff --git a/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts b/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts new file mode 100644 index 000000000..540894d9d --- /dev/null +++ b/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts @@ -0,0 +1,31 @@ +import { StripeCheckoutSession } from '@equal-vote/star-vote-shared/domain_model/StripeCheckoutSession'; +import { ILoggingContext } from '../../Services/Logging/ILogger'; +import Logger from '../../Services/Logging/Logger'; + +export default class StripeCheckoutSessionsDB { + + _sessions: StripeCheckoutSession[] = []; + _nextId = 1; + + async insert(session: Omit, ctx: ILoggingContext): Promise { + Logger.debug(ctx, `MockStripeCheckoutSessions insert stripe_checkout_session_id=${session.stripe_checkout_session_id}`); + this._sessions.push({ ...session, id: this._nextId++ }); + } + + async getByStripeSessionId(stripe_checkout_session_id: string, ctx: ILoggingContext): Promise { + return this._sessions.find(s => s.stripe_checkout_session_id === stripe_checkout_session_id) ?? null; + } + + async markPaid(stripe_checkout_session_id: string, ctx: ILoggingContext): Promise { + const session = this._sessions.find(s => s.stripe_checkout_session_id === stripe_checkout_session_id); + if (session) { + session.status = 'paid'; + } + } + + async sumVoterLimitPurchases(election_id: string, ctx: ILoggingContext): Promise { + return this._sessions + .filter(s => s.election_id === election_id && s.product === 'voter_limit' && s.status === 'paid') + .reduce((sum, s) => sum + (s.voter_count_granted ?? 0), 0); + } +} diff --git a/packages/backend/src/test/database_sandbox.ts b/packages/backend/src/test/database_sandbox.ts index 140daa967..7fc9ad515 100644 --- a/packages/backend/src/test/database_sandbox.ts +++ b/packages/backend/src/test/database_sandbox.ts @@ -25,6 +25,7 @@ function buildElection(i: string, update_date: string, head: boolean): Election update_date: update_date, head: head, ballot_source: 'live_election', + voter_limit: 100, } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 9a727eba6..0502209c0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -28,8 +28,8 @@ "default": "./dist/index.js" }, "./config": { - "types": "./dist/types/config/SharedConfig.d.ts", - "default": "./dist/config/SharedConfig.js" + "types": "./dist/types/config/index.d.ts", + "default": "./dist/config/index.js" }, "./domain_model": { "types": "./dist/types/domain_model/index.d.ts", diff --git a/packages/shared/src/config/PricingConfig.ts b/packages/shared/src/config/PricingConfig.ts new file mode 100644 index 000000000..bea3a3bc2 --- /dev/null +++ b/packages/shared/src/config/PricingConfig.ts @@ -0,0 +1,5 @@ +export const pricingConfig = { + FREE_TIER_LIMIT: 100, + BLOCK_SIZE: 200, + PRICE_PER_BLOCK_CENTS: 1000, +}; diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index 22b434a74..4d5bdf3c9 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -1 +1,2 @@ export * from "./SharedConfig"; +export * from "./PricingConfig"; diff --git a/packages/shared/src/domain_model/Election.ts b/packages/shared/src/domain_model/Election.ts index 8ce227687..5e75ebbac 100644 --- a/packages/shared/src/domain_model/Election.ts +++ b/packages/shared/src/domain_model/Election.ts @@ -30,6 +30,7 @@ export interface Election { head: boolean;// Head version of this object ballot_source: 'live_election' | 'prior_election'; public_archive_id?: string; + voter_limit: number; // max voters allowed on the roll, paid tier increments raise this } type Omit = Pick> export type PartialBy = Omit & Partial> @@ -163,6 +164,9 @@ export function electionValidation(obj:Election): string | null { if (obj.head && typeof obj.head !== 'boolean'){ return "Invalid Head"; } + if (obj.voter_limit !== undefined && (typeof obj.voter_limit !== 'number' || !Number.isInteger(obj.voter_limit) || obj.voter_limit < 0)){ + return "Invalid Voter Limit"; + } //TODO... etc return null; diff --git a/packages/shared/src/domain_model/StripeCheckoutSession.ts b/packages/shared/src/domain_model/StripeCheckoutSession.ts new file mode 100644 index 000000000..f284e7971 --- /dev/null +++ b/packages/shared/src/domain_model/StripeCheckoutSession.ts @@ -0,0 +1,17 @@ +import { Uid } from "./Uid"; + +export type StripeCheckoutSessionProduct = 'voter_limit'; +export type StripeCheckoutSessionStatus = 'pending' | 'paid'; + +export interface StripeCheckoutSession { + id?: number; + election_id: Uid; + user_id: Uid; + product: StripeCheckoutSessionProduct; + amount_cents: number; + voter_count_granted?: number; + stripe_checkout_session_id: string; + stripe_customer_id?: string; + status: StripeCheckoutSessionStatus; + created_date: string; +} From 49c62c81f204c7e6d639284367207271e1bd49b6 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 25 Aug 2026 14:38:50 -0700 Subject: [PATCH 2/4] Make stripeCheckoutSessionsDB.product a jsonb cart of line items Per #1590 the checkout flow moved to a cart model: one Checkout Session can carry multiple line items, so `product` now stores a JSON array of line items (each a full Stripe price_data snapshot tagged with its internal product type) instead of a single varchar product type. voter_count_granted stays the authoritative aggregate on the row, so sumVoterLimitPurchases no longer filters by product. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012e7hk5frznvBj8kbB3uu4M --- .../2026_08_24_voter_limit_and_stripe_sessions.ts | 2 +- .../backend/src/Models/StripeCheckoutSessions.ts | 3 ++- .../src/Models/__mocks__/StripeCheckoutSessions.ts | 2 +- .../shared/src/domain_model/StripeCheckoutSession.ts | 12 +++++++++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts b/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts index 633096696..0922ed050 100644 --- a/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts +++ b/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts @@ -25,7 +25,7 @@ export async function up(db: Kysely): Promise { .addColumn('id', 'serial', (col) => col.primaryKey()) .addColumn('election_id', 'varchar', (col) => col.notNull()) .addColumn('user_id', 'varchar', (col) => col.notNull()) - .addColumn('product', 'varchar', (col) => col.notNull()) + .addColumn('product', 'jsonb', (col) => col.notNull()) .addColumn('amount_cents', 'integer', (col) => col.notNull()) .addColumn('voter_count_granted', 'integer') .addColumn('stripe_checkout_session_id', 'varchar', (col) => col.notNull().unique()) diff --git a/packages/backend/src/Models/StripeCheckoutSessions.ts b/packages/backend/src/Models/StripeCheckoutSessions.ts index 0942980d5..bd4c9e808 100644 --- a/packages/backend/src/Models/StripeCheckoutSessions.ts +++ b/packages/backend/src/Models/StripeCheckoutSessions.ts @@ -42,13 +42,14 @@ export default class StripeCheckoutSessionsDB { } // Audit/support total only — voter_limit itself is authoritative on the election row. + // voter_count_granted is already the aggregate across any voter_limit line items in + // the row (set at insert time), so no per-product filtering is needed here. async sumVoterLimitPurchases(election_id: string, ctx: ILoggingContext): Promise { Logger.debug(ctx, `${tableName}.sumVoterLimitPurchases election_id=${election_id}`); const result = await this._postgresClient .selectFrom(tableName) .select((eb) => eb.fn.sum('voter_count_granted').as('total')) .where('election_id', '=', election_id) - .where('product', '=', 'voter_limit') .where('status', '=', 'paid') .executeTakeFirst(); return Number(result?.total ?? 0); diff --git a/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts b/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts index 540894d9d..c58d2dd5b 100644 --- a/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts +++ b/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts @@ -25,7 +25,7 @@ export default class StripeCheckoutSessionsDB { async sumVoterLimitPurchases(election_id: string, ctx: ILoggingContext): Promise { return this._sessions - .filter(s => s.election_id === election_id && s.product === 'voter_limit' && s.status === 'paid') + .filter(s => s.election_id === election_id && s.status === 'paid') .reduce((sum, s) => sum + (s.voter_count_granted ?? 0), 0); } } diff --git a/packages/shared/src/domain_model/StripeCheckoutSession.ts b/packages/shared/src/domain_model/StripeCheckoutSession.ts index f284e7971..686fb8834 100644 --- a/packages/shared/src/domain_model/StripeCheckoutSession.ts +++ b/packages/shared/src/domain_model/StripeCheckoutSession.ts @@ -3,11 +3,21 @@ import { Uid } from "./Uid"; export type StripeCheckoutSessionProduct = 'voter_limit'; export type StripeCheckoutSessionStatus = 'pending' | 'paid'; +// A cart line item as submitted to Stripe Checkout Session creation — a full +// snapshot of the Stripe price_data for that item, tagged with the internal +// product type it corresponds to (Phase 1 only ships 'voter_limit', but a +// single Checkout Session can carry multiple line items/products). +export interface StripeCheckoutSessionLineItem { + product: StripeCheckoutSessionProduct; + price_data: unknown; + quantity: number; +} + export interface StripeCheckoutSession { id?: number; election_id: Uid; user_id: Uid; - product: StripeCheckoutSessionProduct; + product: StripeCheckoutSessionLineItem[]; amount_cents: number; voter_count_granted?: number; stripe_checkout_session_id: string; From f3a938ad94c9c4ce766a8a2edebc8b14df3227ae Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 25 Aug 2026 14:46:51 -0700 Subject: [PATCH 3/4] Modify stripeCheckoutSessionsDB table structure Replaced 'product' and 'amount_cents' columns with 'line_items' column in the stripeCheckoutSessionsDB table. --- .../Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts b/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts index 0922ed050..dc3c50f75 100644 --- a/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts +++ b/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts @@ -25,8 +25,7 @@ export async function up(db: Kysely): Promise { .addColumn('id', 'serial', (col) => col.primaryKey()) .addColumn('election_id', 'varchar', (col) => col.notNull()) .addColumn('user_id', 'varchar', (col) => col.notNull()) - .addColumn('product', 'jsonb', (col) => col.notNull()) - .addColumn('amount_cents', 'integer', (col) => col.notNull()) + .addColumn('line_items', 'jsonb', (col) => col.notNull()) .addColumn('voter_count_granted', 'integer') .addColumn('stripe_checkout_session_id', 'varchar', (col) => col.notNull().unique()) .addColumn('stripe_customer_id', 'varchar') From f6cb07019ee2f9a4492cdb6069a2737320f5dd6e Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Wed, 26 Aug 2026 00:00:48 -0700 Subject: [PATCH 4/4] Apply rename to domain_model --- packages/shared/src/domain_model/StripeCheckoutSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/domain_model/StripeCheckoutSession.ts b/packages/shared/src/domain_model/StripeCheckoutSession.ts index 686fb8834..08ecd945a 100644 --- a/packages/shared/src/domain_model/StripeCheckoutSession.ts +++ b/packages/shared/src/domain_model/StripeCheckoutSession.ts @@ -17,7 +17,7 @@ export interface StripeCheckoutSession { id?: number; election_id: Uid; user_id: Uid; - product: StripeCheckoutSessionLineItem[]; + line_items: StripeCheckoutSessionLineItem[]; amount_cents: number; voter_count_granted?: number; stripe_checkout_session_id: string;