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/7] 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/7] 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/7] 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/7] 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; From dcf5cd2a386294f25feb20e5d381a52fcacf84cd Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Mon, 31 Aug 2026 20:28:18 +0000 Subject: [PATCH 5/7] RALPH: Fix voter-limit resolution hierarchy, payment-required response & lock down voter_limit (issue #1585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: Wire voter-limit resolution hierarchy, payment-required response & lock down voter_limit PRD: Phase 1 Payment System (#1566), graduated from #1568 Key decisions: - Voter limit now falls back to election.voter_limit (not sharedConfig.FREE_TIER_PRIVATE_VOTER_LIMIT) — override dict still wins when present, matching the issue spec - Limit-exceeded response is 402 with structured body (code, currentVoterLimit, requestedVoterCount, blockSize, pricePerBlockCents) — sent directly via res.json(), bypassing errorCatch middleware - editElectionController overwrites inputElection.voter_limit with req.election.voter_limit before calling updateElection(), discarding any client-submitted value - useFetch suppresses the generic snackbar for PAYMENT_REQUIRED and exposes latestErrorResponse ref so the caller can read the raw error body synchronously after makeRequest returns false - AddElectionRoll.tsx has a stub branch for code === 'PAYMENT_REQUIRED' (modal is separate ticket) - Mock ElectionsDB now sets voter_limit = pricingConfig.FREE_TIER_LIMIT in createElection(), matching the real implementation Files changed: - packages/backend/src/Controllers/Roll/addElectionRollController.ts - packages/backend/src/Controllers/Election/editElectionController.ts - packages/backend/src/Models/__mocks__/Elections.ts - packages/frontend/src/hooks/useFetch.ts - packages/frontend/src/components/Election/Admin/AddElectionRoll.tsx - packages/backend/src/test/voterLimitRoll.test.ts (new, 5 tests) Blockers/notes: - Branch is based on voter-limit-migration-1584 (PR #1589) which must merge first - Payment modal (opened on PAYMENT_REQUIRED) is separate frontend work tracked elsewhere Co-Authored-By: Claude Sonnet 4.6 --- .../Election/editElectionController.ts | 3 + .../Roll/addElectionRollController.ts | 21 +++-- .../backend/src/Models/__mocks__/Elections.ts | 2 + .../backend/src/test/voterLimitRoll.test.ts | 94 +++++++++++++++++++ .../Election/Admin/AddElectionRoll.tsx | 7 +- packages/frontend/src/hooks/useFetch.ts | 23 +++-- 6 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 packages/backend/src/test/voterLimitRoll.test.ts diff --git a/packages/backend/src/Controllers/Election/editElectionController.ts b/packages/backend/src/Controllers/Election/editElectionController.ts index 49c3c469a..28d1826d4 100644 --- a/packages/backend/src/Controllers/Election/editElectionController.ts +++ b/packages/backend/src/Controllers/Election/editElectionController.ts @@ -33,6 +33,9 @@ const editElection = async (req: IElectionRequest, res: Response, next: NextFunc Logger.debug(req, `election ID = ${inputElection}`); var failMsg = `Failed to update election`; + // voter_limit can only be changed via the Stripe webhook fulfillment path, never by the client + inputElection.voter_limit = req.election.voter_limit; + const expected_update_date = expectUpdateDate(req); const updatedElection = await ElectionsModel.updateElection(inputElection, req, `User editing draft Election`, expected_update_date); if (!updatedElection) { diff --git a/packages/backend/src/Controllers/Roll/addElectionRollController.ts b/packages/backend/src/Controllers/Roll/addElectionRollController.ts index 3876aa874..2ec84b86d 100644 --- a/packages/backend/src/Controllers/Roll/addElectionRollController.ts +++ b/packages/backend/src/Controllers/Roll/addElectionRollController.ts @@ -6,7 +6,7 @@ import { expectPermission } from "../controllerUtils"; import { BadRequest } from "@curveball/http-errors"; import { IElectionRequest } from "../../IRequest"; import { Response, NextFunction } from 'express'; -import { sharedConfig } from "@equal-vote/star-vote-shared/config"; +import { sharedConfig, pricingConfig } from "@equal-vote/star-vote-shared/config"; import { makeUniqueID, ID_LENGTHS, ID_PREFIXES } from "@equal-vote/star-vote-shared/utils/makeID"; interface ElectionRollInput { @@ -83,11 +83,20 @@ const addElectionRoll = async (req: IElectionRequest & { body: { electionRoll: E throw new BadRequest(`Some submitted voters already exist (${duplicateRolls.length} duplicates found)`) } - // Check for roll limit - let overrides = sharedConfig.ELECTION_VOTER_LIMIT_OVERRIDES as { [key: string]: number}; - let voterLimit = overrides[req.election.election_id] ?? sharedConfig.FREE_TIER_PRIVATE_VOTER_LIMIT; - if(req.election.settings.voter_access == 'closed' && existingRolls.length + req.body.electionRoll.length > voterLimit){ - throw new BadRequest(`Request Denied: this election is limited to ${voterLimit} voters`); + // Check for roll limit — overrides take priority, else fall back to per-election voter_limit + const overrides = sharedConfig.ELECTION_VOTER_LIMIT_OVERRIDES as { [key: string]: number }; + const voterLimit = overrides[req.election.election_id] ?? req.election.voter_limit; + const requestedVoterCount = existingRolls.length + req.body.electionRoll.length; + if (req.election.settings.voter_access == 'closed' && requestedVoterCount > voterLimit) { + res.status(402).json({ + error: `Request Denied: this election is limited to ${voterLimit} voters`, + code: 'PAYMENT_REQUIRED', + currentVoterLimit: voterLimit, + requestedVoterCount, + blockSize: pricingConfig.BLOCK_SIZE, + pricePerBlockCents: pricingConfig.PRICE_PER_BLOCK_CENTS, + }); + return; } } diff --git a/packages/backend/src/Models/__mocks__/Elections.ts b/packages/backend/src/Models/__mocks__/Elections.ts index 3ed7bf6a9..682e816c0 100644 --- a/packages/backend/src/Models/__mocks__/Elections.ts +++ b/packages/backend/src/Models/__mocks__/Elections.ts @@ -4,6 +4,7 @@ import { ILoggingContext } from '../../Services/Logging/ILogger'; import Logger from '../../Services/Logging/Logger'; import { IElectionStore } from '../IElectionStore'; import { Conflict } from '@curveball/http-errors'; +import { pricingConfig } from '@equal-vote/star-vote-shared/config'; export default class ElectionsDB implements IElectionStore { @@ -16,6 +17,7 @@ export default class ElectionsDB implements IElectionStore { Logger.debug(ctx, "Election Mock Creates Election: ", election); var copy = JSON.parse(JSON.stringify(election)); copy.update_date = Date.now().toString(); + copy.voter_limit = pricingConfig.FREE_TIER_LIMIT; this.elections.push(copy); var res = JSON.parse(JSON.stringify(copy)); return Promise.resolve(res); diff --git a/packages/backend/src/test/voterLimitRoll.test.ts b/packages/backend/src/test/voterLimitRoll.test.ts new file mode 100644 index 000000000..70165eaa6 --- /dev/null +++ b/packages/backend/src/test/voterLimitRoll.test.ts @@ -0,0 +1,94 @@ +require('dotenv').config(); +import { TestHelper } from './TestHelper'; +import testInputs from './testInputs'; +import ServiceLocator from '../ServiceLocator'; +import { pricingConfig } from '@equal-vote/star-vote-shared/config'; + +const th = new TestHelper(); + +afterEach(() => { + jest.clearAllMocks(); + th.afterEach(); +}); + +const setupClosedElectionWithLimit = async (voterLimit: number) => { + const response = await th.createElection(testInputs.IDRollElection, testInputs.user1token); + expect(response.statusCode).toBe(200); + const ID = response.election.election_id; + // Directly patch voter_limit on the mock to test low-limit behavior + const mockDb = ServiceLocator.electionsDb() as any; + const election = mockDb.elections.find((e: any) => e.election_id === ID); + election.voter_limit = voterLimit; + return ID; +}; + +describe("Voter Limit Roll", () => { + + describe("Voter limit resolution uses election.voter_limit", () => { + test("Adding voters within the limit succeeds", async () => { + const ID = await setupClosedElectionWithLimit(3); + const rolls = [{ voter_id: 'voter1' }, { voter_id: 'voter2' }]; + const response = await th.submitElectionRoll(ID, rolls, testInputs.user1token); + expect(response.statusCode).toBe(200); + th.testComplete(); + }); + + test("Exceeding election.voter_limit returns 402 (not 400)", async () => { + const ID = await setupClosedElectionWithLimit(2); + // Fill the limit + await th.submitElectionRoll(ID, [{ voter_id: 'voter1' }, { voter_id: 'voter2' }], testInputs.user1token); + // One more should be blocked + const response = await th.submitElectionRoll(ID, [{ voter_id: 'voter3' }], testInputs.user1token); + expect(response.statusCode).toBe(402); + th.testComplete(); + }); + }); + + describe("PAYMENT_REQUIRED response shape", () => { + test("Response body includes required fields with correct values", async () => { + const limit = 2; + const ID = await setupClosedElectionWithLimit(limit); + await th.submitElectionRoll(ID, [{ voter_id: 'v1' }, { voter_id: 'v2' }], testInputs.user1token); + const response = await th.submitElectionRoll(ID, [{ voter_id: 'v3' }, { voter_id: 'v4' }], testInputs.user1token); + + expect(response.statusCode).toBe(402); + expect(response.body.code).toBe('PAYMENT_REQUIRED'); + expect(response.body.error).toContain(`limited to ${limit} voters`); + expect(response.body.currentVoterLimit).toBe(limit); + expect(response.body.requestedVoterCount).toBe(4); // 2 existing + 2 new + expect(response.body.blockSize).toBe(pricingConfig.BLOCK_SIZE); + expect(response.body.pricePerBlockCents).toBe(pricingConfig.PRICE_PER_BLOCK_CENTS); + th.testComplete(); + }); + }); + + describe("createElection sets voter_limit from pricingConfig", () => { + test("New election gets voter_limit = FREE_TIER_LIMIT", async () => { + const response = await th.createElection(testInputs.IDRollElection, testInputs.user1token); + expect(response.statusCode).toBe(200); + expect(response.election.voter_limit).toBe(pricingConfig.FREE_TIER_LIMIT); + th.testComplete(); + }); + }); +}); + +describe("Edit election does not change voter_limit", () => { + test("Client-submitted voter_limit is discarded on edit", async () => { + const createRes = await th.createElection(testInputs.Election1, testInputs.user1token); + expect(createRes.statusCode).toBe(200); + const ID = createRes.election.election_id; + const originalLimit = createRes.election.voter_limit; + + // Try to submit an edit with a different voter_limit + const electionWithChangedLimit = { + ...testInputs.Election1, + election_id: ID, + voter_limit: 99999, + }; + const editRes = await th.editElection(electionWithChangedLimit, testInputs.user1token); + expect(editRes.statusCode).toBe(200); + // voter_limit should NOT have changed + expect(editRes.election.voter_limit).toBe(originalLimit); + th.testComplete(); + }); +}); diff --git a/packages/frontend/src/components/Election/Admin/AddElectionRoll.tsx b/packages/frontend/src/components/Election/Admin/AddElectionRoll.tsx index 65e2f3f8d..efa1b941c 100644 --- a/packages/frontend/src/components/Election/Admin/AddElectionRoll.tsx +++ b/packages/frontend/src/components/Election/Admin/AddElectionRoll.tsx @@ -41,9 +41,14 @@ const AddElectionRoll = ({ onClose }: { onClose: () => void }) => { if(enablePrecinct) allowedColumns.push('precinct') const submitRolls = async (rolls) => { - const newRolls = await postRoll.makeRequest({ electionRoll: rolls }) if (!newRolls) { + if (postRoll.latestErrorResponse.current?.code === 'PAYMENT_REQUIRED') { + // TODO: open the payment/cart modal (separate frontend ticket) + // postRoll.latestErrorResponse.current contains currentVoterLimit, + // requestedVoterCount, blockSize, pricePerBlockCents + return; + } throw Error("Error submitting rolls"); } onClose() diff --git a/packages/frontend/src/hooks/useFetch.ts b/packages/frontend/src/hooks/useFetch.ts index e18697d56..34e08e551 100644 --- a/packages/frontend/src/hooks/useFetch.ts +++ b/packages/frontend/src/hooks/useFetch.ts @@ -1,16 +1,17 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import useSnackbar from "../components/SnackbarContext"; -// Example usage +// Example usage // Requst type: MyRequest // Response type: ApiResponse // MyRequestHook = useFetch(url, 'get') // Where -// MyRequestHoot type = +// MyRequestHoot type = // { // data: ApiResponse | null, null by default until successful response -// isPending: boolean, true if waiting for request -// error: any | null, null by default until request error +// isPending: boolean, true if waiting for request +// error: any | null, null by default until request error +// latestErrorResponse: raw parsed JSON from the most recent failed response (synchronously readable) // makeRequest: (MyRequest) => Promise, if request errors response with false // } const useFetch = (url: string, method: 'get' | 'post' | 'put' | 'delete', successMessage: string | null = null) => { @@ -18,6 +19,8 @@ const useFetch = (url: string, method: 'get' | 'post' | 'put' const [error, setError] = useState(null) const [data, setData] = useState(null) const { setSnack } = useSnackbar() + // Ref so callers can read the raw error body synchronously right after makeRequest returns false + const latestErrorResponse = useRef | null>(null); const makeRequest = async (data?: Message) => { const options: RequestInit = { @@ -29,6 +32,7 @@ const useFetch = (url: string, method: 'get' | 'post' | 'put' body: JSON.stringify(data), }; setIsPending(true); + latestErrorResponse.current = null; try { const res = await fetch(url, options); const contentType = res.headers.get('content-type'); @@ -37,6 +41,13 @@ const useFetch = (url: string, method: 'get' | 'post' | 'put' data = await res.json(); } if (!res.ok) { + latestErrorResponse.current = data ?? null; + // PAYMENT_REQUIRED is handled by the caller — don't show the generic snackbar + if (data?.code === 'PAYMENT_REQUIRED') { + setIsPending(false); + setError('PAYMENT_REQUIRED'); + return false; + } const errorMsg = data?.error ? `: ${data.error}` : ''; throw Error(`Error making request: ${res.status.toString()}${errorMsg}`); } @@ -64,7 +75,7 @@ const useFetch = (url: string, method: 'get' | 'post' | 'put' return false; } } - return { data, isPending, error, makeRequest }; + return { data, isPending, error, latestErrorResponse, makeRequest }; }; export default useFetch; From c32564b6bff257d9720f68a2ac8568c86512f358 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Mon, 31 Aug 2026 20:29:19 +0000 Subject: [PATCH 6/7] =?UTF-8?q?RALPH:=20Build=20Checkout=20Session=20creat?= =?UTF-8?q?ion=20endpoint=20(cart=20validation=20+=20Stripe=20session)=20?= =?UTF-8?q?=E2=80=94=20issue=20#1586?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: POST /API/Election/:id/CheckoutSession Key decisions: - Product catalog in Services/Stripe/catalog.ts: pure functions (validateCart, buildLineItems, computeTotals) — one entry (voter_limit_block), easily testable - StripeService wrapper + __mocks__ counterpart wired into ServiceLocator - Placeholder UUID written to DB first to get row ID for Stripe metadata; real session ID swapped in post-creation via new updateStripeSessionId() - Max voter_limit capped at 5000; 'pending' row inserted before Stripe call - Shared domain model updated: StripeCheckoutSessionProduct → 'voter_limit_block'; StripeCheckoutSessionLineItem.product renamed to .type (matches spec JSON) Files changed: - packages/backend/src/Services/Stripe/catalog.ts (new) - packages/backend/src/Services/Stripe/StripeService.ts (new) - packages/backend/src/Services/Stripe/__mocks__/StripeService.ts (new) - packages/backend/src/Controllers/Election/createCheckoutSessionController.ts (new) - packages/backend/src/test/checkoutSession.test.ts (new — 9 tests, all pass) - packages/backend/src/Models/StripeCheckoutSessions.ts (add updateStripeSessionId) - packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts (same) - packages/backend/src/ServiceLocator.ts (add stripeCheckoutSessionsDb, stripeService) - packages/backend/src/__mocks__/ServiceLocator.ts (same) - packages/backend/src/Controllers/Election/index.ts (re-export) - packages/backend/src/Routes/elections.routes.ts (add route) - packages/shared/src/domain_model/StripeCheckoutSession.ts (type fix) - packages/backend/package.json + package-lock.json (add stripe dependency) Builds on voter-limit-migration-1584 branch (merged in); requires STRIPE_SECRET_KEY. All 223 backend tests pass; tsc clean. Blockers: STRIPE_SECRET_KEY must be configured for production use. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 48 +++--- packages/backend/package.json | 1 + .../createCheckoutSessionController.ts | 81 ++++++++++ .../backend/src/Controllers/Election/index.ts | 1 + .../src/Models/StripeCheckoutSessions.ts | 9 ++ .../__mocks__/StripeCheckoutSessions.ts | 7 + .../backend/src/Routes/elections.routes.ts | 50 ++++++ packages/backend/src/ServiceLocator.ts | 26 +++- .../src/Services/Stripe/StripeService.ts | 65 ++++++++ .../Stripe/__mocks__/StripeService.ts | 19 +++ .../backend/src/Services/Stripe/catalog.ts | 101 +++++++++++++ .../backend/src/__mocks__/ServiceLocator.ts | 21 ++- .../backend/src/test/checkoutSession.test.ts | 143 ++++++++++++++++++ .../src/domain_model/StripeCheckoutSession.ts | 13 +- 14 files changed, 546 insertions(+), 39 deletions(-) create mode 100644 packages/backend/src/Controllers/Election/createCheckoutSessionController.ts create mode 100644 packages/backend/src/Services/Stripe/StripeService.ts create mode 100644 packages/backend/src/Services/Stripe/__mocks__/StripeService.ts create mode 100644 packages/backend/src/Services/Stripe/catalog.ts create mode 100644 packages/backend/src/test/checkoutSession.test.ts diff --git a/package-lock.json b/package-lock.json index b1e9410ca..f1c17e8f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4004,9 +4004,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4021,9 +4018,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4038,9 +4032,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4055,9 +4046,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4072,9 +4060,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4089,9 +4074,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4106,9 +4088,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4123,9 +4102,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4140,9 +4116,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4157,9 +4130,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -12500,6 +12470,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.6.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.6.0.tgz", + "integrity": "sha512-Ezk+WYEEwX2DLxFrKfb6coznlCMZvCaCYMNPXNHFSZkBIEzF5Hgm7RLb38q/0h9Ol5jsUJGJRorvhzAvckgfhw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.3.0", "funding": [ @@ -13840,6 +13827,7 @@ "qs": "^6.15.2", "sanitize-html": "^2.17.7", "socket.io": "^4.7.5", + "stripe": "^22.6.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, diff --git a/packages/backend/package.json b/packages/backend/package.json index a0657d670..48c672ac9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -42,6 +42,7 @@ "qs": "^6.15.2", "sanitize-html": "^2.17.7", "socket.io": "^4.7.5", + "stripe": "^22.6.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, diff --git a/packages/backend/src/Controllers/Election/createCheckoutSessionController.ts b/packages/backend/src/Controllers/Election/createCheckoutSessionController.ts new file mode 100644 index 000000000..90958c210 --- /dev/null +++ b/packages/backend/src/Controllers/Election/createCheckoutSessionController.ts @@ -0,0 +1,81 @@ +import { randomUUID } from 'crypto'; +import ServiceLocator from '../../ServiceLocator'; +import Logger from '../../Services/Logging/Logger'; +import { IElectionRequest } from '../../IRequest'; +import { Response, NextFunction } from 'express'; +import { BadRequest, Unauthorized } from '@curveball/http-errors'; +import { permissions } from '@equal-vote/star-vote-shared/domain_model/permissions'; +import { expectPermission } from '../controllerUtils'; +import { CartItem, validateCart, buildLineItems, computeTotals } from '../../Services/Stripe/catalog'; + +const className = 'createCheckoutSessionController'; + +const createCheckoutSession = async (req: IElectionRequest, res: Response, next: NextFunction) => { + Logger.info(req, `${className} election_id=${req.election.election_id}`); + + expectPermission(req.user_auth.roles, permissions.canEditElection); + + const userId: string = req.user?.sub; + if (!userId) { + throw new Unauthorized('User must be authenticated'); + } + + const items: CartItem[] = req.body?.items; + if (!Array.isArray(items) || items.length === 0) { + throw new BadRequest('Request body must include a non-empty items array'); + } + + const validationErr = validateCart(items, req.election); + if (validationErr) { + throw new BadRequest(validationErr.message); + } + + const lineItems = buildLineItems(items); + const { amount_cents, voter_count_granted } = computeTotals(items); + + const stripeCheckoutSessionsDb = ServiceLocator.stripeCheckoutSessionsDb(); + const stripeService = ServiceLocator.stripeService(); + + const electionId = req.election.election_id; + const frontendUrl = process.env.FRONTEND_URL || req.election.frontend_url || 'https://bettervoting.com'; + + // Use a UUID placeholder so we can insert the row before creating the Stripe + // session, then swap in the real session ID once Stripe responds. + const placeholder = `pending_${randomUUID()}`; + + await stripeCheckoutSessionsDb.insert( + { + election_id: electionId, + user_id: userId, + line_items: lineItems, + amount_cents, + voter_count_granted, + stripe_checkout_session_id: placeholder, + status: 'pending', + created_date: new Date().toISOString(), + }, + req, + ); + + const inserted = await stripeCheckoutSessionsDb.getByStripeSessionId(placeholder, req); + const rowId = inserted?.id ?? 0; + + const { sessionId, url } = await stripeService.createCheckoutSession( + { + lineItems, + electionId, + userId, + checkoutSessionRowId: rowId, + successUrl: `${frontendUrl}/${electionId}/admin/voters?payment=success`, + cancelUrl: `${frontendUrl}/${electionId}/admin/voters?payment=cancelled`, + }, + req, + ); + + await stripeCheckoutSessionsDb.updateStripeSessionId(placeholder, sessionId, req); + + Logger.info(req, `${className} created Stripe session ${sessionId} for election ${electionId}`); + res.json({ url }); +}; + +export { createCheckoutSession }; diff --git a/packages/backend/src/Controllers/Election/index.ts b/packages/backend/src/Controllers/Election/index.ts index 013ac95df..29e415353 100644 --- a/packages/backend/src/Controllers/Election/index.ts +++ b/packages/backend/src/Controllers/Election/index.ts @@ -15,3 +15,4 @@ export * from './setPublicResultsController'; export * from './sendEmailController'; export * from './claimElectionController'; export * from './setWriteInResultsController' +export * from './createCheckoutSessionController' diff --git a/packages/backend/src/Models/StripeCheckoutSessions.ts b/packages/backend/src/Models/StripeCheckoutSessions.ts index bd4c9e808..5c2ca4060 100644 --- a/packages/backend/src/Models/StripeCheckoutSessions.ts +++ b/packages/backend/src/Models/StripeCheckoutSessions.ts @@ -32,6 +32,15 @@ export default class StripeCheckoutSessionsDB { return result ?? null; } + async updateStripeSessionId(placeholder_id: string, real_session_id: string, ctx: ILoggingContext): Promise { + Logger.debug(ctx, `${tableName}.updateStripeSessionId`); + await this._postgresClient + .updateTable(tableName) + .set({ stripe_checkout_session_id: real_session_id }) + .where('stripe_checkout_session_id', '=', placeholder_id) + .execute(); + } + 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 diff --git a/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts b/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts index c58d2dd5b..72a85eaa2 100644 --- a/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts +++ b/packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts @@ -16,6 +16,13 @@ export default class StripeCheckoutSessionsDB { return this._sessions.find(s => s.stripe_checkout_session_id === stripe_checkout_session_id) ?? null; } + async updateStripeSessionId(placeholder_id: string, real_session_id: string, ctx: ILoggingContext): Promise { + const session = this._sessions.find(s => s.stripe_checkout_session_id === placeholder_id); + if (session) { + session.stripe_checkout_session_id = real_session_id; + } + } + 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) { diff --git a/packages/backend/src/Routes/elections.routes.ts b/packages/backend/src/Routes/elections.routes.ts index 2853b7230..27d8599e3 100644 --- a/packages/backend/src/Routes/elections.routes.ts +++ b/packages/backend/src/Routes/elections.routes.ts @@ -26,6 +26,7 @@ import { queryElections, claimElection, setWriteInResults, + createCheckoutSession, } from '../Controllers/Election'; import {upload, uploadImageController} from '../Controllers/uploadImageController'; import asyncHandler from 'express-async-handler'; @@ -872,6 +873,55 @@ electionsRouter.post('/images',upload.single("file"), asyncHandler(uploadImageCo electionsRouter.post('/Election/:id/setWriteInResults',asyncHandler(setWriteInResults)) +/** + * @swagger + * /Election/{id}/CheckoutSession: + * post: + * summary: Create a Stripe Checkout Session for purchasing voter limit blocks + * tags: [Elections] + * security: + * - ApiKeyAuth: [] + * parameters: + * - in: path + * name: id + * schema: + * type: string + * required: true + * description: The election ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * items: + * type: array + * items: + * type: object + * properties: + * type: + * type: string + * enum: [voter_limit_block] + * quantity: + * type: number + * responses: + * 200: + * description: Stripe hosted checkout URL + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * 400: + * description: Invalid cart (unknown type or voter limit would exceed 5000) + * 401: + * description: Not authorized + */ +electionsRouter.post('/Election/:id/CheckoutSession', asyncHandler(createCheckoutSession)) + electionsRouter.param('id', asyncHandler(getElectionByID)) electionsRouter.param('id', asyncHandler(electionSpecificAuth)) electionsRouter.param('id', asyncHandler(electionPostAuthMiddleware)) diff --git a/packages/backend/src/ServiceLocator.ts b/packages/backend/src/ServiceLocator.ts index e86534c18..6ac10af94 100644 --- a/packages/backend/src/ServiceLocator.ts +++ b/packages/backend/src/ServiceLocator.ts @@ -3,9 +3,11 @@ import BallotsDB from "./Models/Ballots"; import ElectionsDB from "./Models/Elections"; import ElectionRollDB from "./Models/ElectionRolls"; import EmailEventsDB from "./Models/EmailEvents"; +import StripeCheckoutSessionsDB from "./Models/StripeCheckoutSessions"; import CastVoteStore from "./Models/CastVoteStore"; import EmailService from "./Services/Email/EmailService"; import BlobService from "./Services/Blob/BlobService"; +import StripeService from "./Services/Stripe/StripeService"; import { IBallotStore } from "./Models/IBallotStore"; import { IEventQueue } from "./Services/EventQueue/IEventQueue"; import PGBossEventQueue from "./Services/EventQueue/PGBossEventQueue"; @@ -26,9 +28,11 @@ var _ballotsDb: IBallotStore; var _electionsDb: ElectionsDB; var _electionRollDb: ElectionRollDB; var _emailEventsDb: EmailEventsDB; +var _stripeCheckoutSessionsDb: StripeCheckoutSessionsDB; var _castVoteStore: CastVoteStore; var _emailService: EmailService var _blobService: BlobService +var _stripeService: StripeService; var _eventQueue: IEventQueue; var _accountService: AccountService; var _globalData: GlobalData; @@ -135,6 +139,13 @@ function emailEventsDb(): EmailEventsDB { return _emailEventsDb; } +function stripeCheckoutSessionsDb(): StripeCheckoutSessionsDB { + if (_stripeCheckoutSessionsDb == null) { + _stripeCheckoutSessionsDb = new StripeCheckoutSessionsDB(database()); + } + return _stripeCheckoutSessionsDb; +} + function castVoteStore(): CastVoteStore { if (_castVoteStore == null) { @@ -164,6 +175,19 @@ function blobService(): BlobService { return _blobService ; } +function stripeService(): StripeService { + if (_stripeService == null) { + if (process.env.STRIPE_SECRET_KEY) { + _stripeService = new StripeService(); + } else { + Logger.info({}, 'STRIPE_SECRET_KEY is not set. Using mock StripeService.'); + const MockStripeService = require("./Services/Stripe/__mocks__/StripeService").default; + _stripeService = new MockStripeService(); + } + } + return _stripeService; +} + function accountService(): AccountService { if (_accountService == null) { _accountService = new AccountService(); @@ -178,4 +202,4 @@ function globalData(): GlobalData { return _globalData; } -export default { ballotsDb, electionsDb, electionRollDb, emailEventsDb, emailService, accountService, castVoteStore, globalData, eventQueue, database, blobService }; +export default { ballotsDb, electionsDb, electionRollDb, emailEventsDb, stripeCheckoutSessionsDb, emailService, accountService, castVoteStore, globalData, eventQueue, database, blobService, stripeService }; diff --git a/packages/backend/src/Services/Stripe/StripeService.ts b/packages/backend/src/Services/Stripe/StripeService.ts new file mode 100644 index 000000000..394ea4d1b --- /dev/null +++ b/packages/backend/src/Services/Stripe/StripeService.ts @@ -0,0 +1,65 @@ +import Stripe from 'stripe'; +import { CatalogLineItem } from './catalog'; +import Logger from '../Logging/Logger'; +import { ILoggingContext } from '../Logging/ILogger'; + +export interface CheckoutSessionParams { + lineItems: CatalogLineItem[]; + electionId: string; + userId: string; + checkoutSessionRowId: number; + successUrl: string; + cancelUrl: string; +} + +export interface CheckoutSessionResult { + sessionId: string; + url: string; +} + +export default class StripeService { + private _client: Stripe; + + constructor() { + const key = process.env.STRIPE_SECRET_KEY; + if (!key) { + throw new Error('STRIPE_SECRET_KEY is not configured'); + } + this._client = new Stripe(key); + } + + async createCheckoutSession( + params: CheckoutSessionParams, + ctx: ILoggingContext, + ): Promise { + Logger.debug(ctx, `StripeService.createCheckoutSession election_id=${params.electionId}`); + + const session = await this._client.checkout.sessions.create({ + mode: 'payment', + submit_type: 'pay', + client_reference_id: params.electionId, + metadata: { + election_id: params.electionId, + user_id: params.userId, + checkout_session_row_id: String(params.checkoutSessionRowId), + }, + line_items: params.lineItems.map(item => ({ + price_data: item.price_data, + quantity: item.quantity, + })), + custom_text: { + after_submit: { + message: 'This is a program service fee paid to Equal Vote. It is not a charitable donation and is not tax-deductible.', + }, + }, + success_url: params.successUrl, + cancel_url: params.cancelUrl, + }); + + if (!session.url) { + throw new Error('Stripe did not return a checkout URL'); + } + + return { sessionId: session.id, url: session.url }; + } +} diff --git a/packages/backend/src/Services/Stripe/__mocks__/StripeService.ts b/packages/backend/src/Services/Stripe/__mocks__/StripeService.ts new file mode 100644 index 000000000..0425c46cc --- /dev/null +++ b/packages/backend/src/Services/Stripe/__mocks__/StripeService.ts @@ -0,0 +1,19 @@ +import { CheckoutSessionParams, CheckoutSessionResult } from '../StripeService'; +import { ILoggingContext } from '../../Logging/ILogger'; +import Logger from '../../Logging/Logger'; + +export default class StripeService { + public _sessions: Array = []; + private _nextId = 1; + + async createCheckoutSession( + params: CheckoutSessionParams, + ctx: ILoggingContext, + ): Promise { + Logger.debug(ctx, `MockStripeService.createCheckoutSession election_id=${params.electionId}`); + const sessionId = `cs_test_mock_${this._nextId++}`; + const url = `https://checkout.stripe.com/pay/${sessionId}`; + this._sessions.push({ ...params, sessionId, url }); + return { sessionId, url }; + } +} diff --git a/packages/backend/src/Services/Stripe/catalog.ts b/packages/backend/src/Services/Stripe/catalog.ts new file mode 100644 index 000000000..065d8fed8 --- /dev/null +++ b/packages/backend/src/Services/Stripe/catalog.ts @@ -0,0 +1,101 @@ +import { pricingConfig } from '@equal-vote/star-vote-shared/config'; +import { Election } from '@equal-vote/star-vote-shared/domain_model/Election'; + +export type CartItemType = 'voter_limit_block'; + +export interface CartItem { + type: CartItemType; + quantity: number; +} + +export interface StripePriceData { + currency: string; + unit_amount: number; + product_data: { + name: string; + description: string; + }; +} + +export interface CatalogLineItem { + type: CartItemType; + blocks: number; + price_data: StripePriceData; + quantity: number; +} + +export interface ValidationError { + type: string; + message: string; +} + +const MAX_VOTER_LIMIT = 5000; + +const voterLimitBlockEntry = { + buildPriceData(quantity: number): StripePriceData { + const voters = quantity * pricingConfig.BLOCK_SIZE; + return { + currency: 'usd', + unit_amount: pricingConfig.PRICE_PER_BLOCK_CENTS, + product_data: { + name: `${pricingConfig.BLOCK_SIZE} Additional Voters`, + description: `Increases your election's voter limit by ${voters} (${quantity} block${quantity !== 1 ? 's' : ''} of ${pricingConfig.BLOCK_SIZE})`, + }, + }; + }, + + validateItem(item: CartItem, election: Election): string | null { + const newLimit = (election.voter_limit ?? pricingConfig.FREE_TIER_LIMIT) + item.quantity * pricingConfig.BLOCK_SIZE; + if (newLimit > MAX_VOTER_LIMIT) { + return `Adding ${item.quantity} block(s) would set voter_limit to ${newLimit}, exceeding the maximum of ${MAX_VOTER_LIMIT}`; + } + return null; + }, + + voterCountGranted(quantity: number): number { + return quantity * pricingConfig.BLOCK_SIZE; + }, +}; + +const CATALOG: Record = { + voter_limit_block: voterLimitBlockEntry, +}; + +export const KNOWN_PRODUCT_TYPES = Object.keys(CATALOG) as CartItemType[]; + +export function validateCart(items: CartItem[], election: Election): ValidationError | null { + for (const item of items) { + if (!CATALOG[item.type]) { + return { type: item.type, message: `Unknown product type: ${item.type}` }; + } + const entry = CATALOG[item.type]; + const err = entry.validateItem(item, election); + if (err) { + return { type: item.type, message: err }; + } + } + return null; +} + +export function buildLineItems(items: CartItem[]): CatalogLineItem[] { + return items.map(item => { + const entry = CATALOG[item.type]; + return { + type: item.type, + blocks: item.quantity, + price_data: entry.buildPriceData(item.quantity), + quantity: item.quantity, + }; + }); +} + +export function computeTotals(items: CartItem[]): { amount_cents: number; voter_count_granted: number } { + let amount_cents = 0; + let voter_count_granted = 0; + for (const item of items) { + const entry = CATALOG[item.type]; + amount_cents += pricingConfig.PRICE_PER_BLOCK_CENTS * item.quantity; + voter_count_granted += entry.voterCountGranted(item.quantity); + } + return { amount_cents, voter_count_granted }; +} diff --git a/packages/backend/src/__mocks__/ServiceLocator.ts b/packages/backend/src/__mocks__/ServiceLocator.ts index 92d143e5d..d9982d97d 100644 --- a/packages/backend/src/__mocks__/ServiceLocator.ts +++ b/packages/backend/src/__mocks__/ServiceLocator.ts @@ -2,8 +2,10 @@ import BallotsDB from "../Models/__mocks__/Ballots"; import ElectionsDB from "../Models/__mocks__/Elections"; import ElectionRollDB from "../Models/__mocks__/ElectionRolls"; import EmailEventsDB from "../Models/__mocks__/EmailEvents"; +import StripeCheckoutSessionsDB from "../Models/__mocks__/StripeCheckoutSessions"; import EmailService from "../Services/Email/__mocks__/EmailService"; import BlobService from "../Services/Blob/__mocks__/BlobService"; +import StripeService from "../Services/Stripe/__mocks__/StripeService"; import CastVoteStore from "../Models/__mocks__/CastVoteStore"; import { IBallotStore } from "../Models/IBallotStore"; import { IElectionRollStore } from "../Models/IElectionRollStore"; @@ -15,11 +17,12 @@ var _ballotsDb:IBallotStore; var _electionsDb:ElectionsDB; var _electionRollDb:IElectionRollStore; var _emailEventsDb:EmailEventsDB; +var _stripeCheckoutSessionsDb:StripeCheckoutSessionsDB; var _emailService:EmailService; var _blobService:BlobService; +var _stripeService:StripeService; var _castVoteStore:CastVoteStore; var _eventQueue:MockEventQueue; -var _castVoteStore:CastVoteStore;; var _accountService:AccountService; var _globalData:GlobalData; @@ -51,6 +54,13 @@ function emailEventsDb():EmailEventsDB { return _emailEventsDb; } +function stripeCheckoutSessionsDb():StripeCheckoutSessionsDB { + if (_stripeCheckoutSessionsDb == null){ + _stripeCheckoutSessionsDb = new StripeCheckoutSessionsDB(); + } + return _stripeCheckoutSessionsDb; +} + function emailService():EmailService { if (_emailService == null){ _emailService = new EmailService(); @@ -65,6 +75,13 @@ function blobService():BlobService { return _blobService; } +function stripeService():StripeService { + if (_stripeService == null) { + _stripeService = new StripeService(); + } + return _stripeService; +} + function castVoteStore():CastVoteStore { if (_castVoteStore == null){ _castVoteStore = new CastVoteStore(ballotsDb(), electionRollDb()); @@ -94,4 +111,4 @@ function globalData():GlobalData { return _globalData; } -export default { ballotsDb, electionsDb, electionRollDb, emailEventsDb, emailService, blobService, castVoteStore, accountService, globalData, eventQueue }; +export default { ballotsDb, electionsDb, electionRollDb, emailEventsDb, stripeCheckoutSessionsDb, emailService, blobService, stripeService, castVoteStore, accountService, globalData, eventQueue }; diff --git a/packages/backend/src/test/checkoutSession.test.ts b/packages/backend/src/test/checkoutSession.test.ts new file mode 100644 index 000000000..08f58652d --- /dev/null +++ b/packages/backend/src/test/checkoutSession.test.ts @@ -0,0 +1,143 @@ +require("dotenv").config(); +import { TestHelper } from "./TestHelper"; +import testInputs from "./testInputs"; +import { validateCart, buildLineItems, computeTotals } from "../Services/Stripe/catalog"; +import { Election } from "@equal-vote/star-vote-shared/domain_model/Election"; +import { pricingConfig } from "@equal-vote/star-vote-shared/config"; + +// ─── Unit tests for catalog pure functions ──────────────────────────────────── + +const baseElection = (voterLimit: number): Election => ({ + election_id: "test", + title: "test", + state: "draft", + frontend_url: "", + owner_id: "owner", + races: [], + settings: { voter_access: "open", voter_authentication: { ip_address: true } }, + voter_limit: voterLimit, +} as unknown as Election); + +describe("catalog – validateCart", () => { + test("rejects unknown product type", () => { + const err = validateCart([{ type: "unknown_product" as any, quantity: 1 }], baseElection(100)); + expect(err).not.toBeNull(); + expect(err!.type).toBe("unknown_product"); + }); + + test("rejects voter_limit_block when new limit would exceed 5000", () => { + const election = baseElection(4900); // 4900 + 1*200 = 5100 > 5000 + const err = validateCart([{ type: "voter_limit_block", quantity: 1 }], election); + expect(err).not.toBeNull(); + }); + + test("accepts voter_limit_block that brings limit to exactly 5000", () => { + const election = baseElection(4800); // 4800 + 1*200 = 5000 exactly + const err = validateCart([{ type: "voter_limit_block", quantity: 1 }], election); + expect(err).toBeNull(); + }); + + test("accepts a valid multi-block purchase", () => { + const election = baseElection(100); + const err = validateCart([{ type: "voter_limit_block", quantity: 3 }], election); + expect(err).toBeNull(); + }); +}); + +describe("catalog – buildLineItems", () => { + test("returns one line item per cart item with price_data", () => { + const items = buildLineItems([{ type: "voter_limit_block", quantity: 2 }]); + expect(items).toHaveLength(1); + expect(items[0].type).toBe("voter_limit_block"); + expect(items[0].quantity).toBe(2); + expect(items[0].price_data.unit_amount).toBe(pricingConfig.PRICE_PER_BLOCK_CENTS); + expect(items[0].price_data.currency).toBe("usd"); + }); +}); + +describe("catalog – computeTotals", () => { + test("computes amount_cents and voter_count_granted correctly", () => { + const totals = computeTotals([{ type: "voter_limit_block", quantity: 3 }]); + expect(totals.amount_cents).toBe(3 * pricingConfig.PRICE_PER_BLOCK_CENTS); + expect(totals.voter_count_granted).toBe(3 * pricingConfig.BLOCK_SIZE); + }); +}); + +// ─── Integration tests for POST /API/Election/:id/CheckoutSession ───────────── + +const th = new TestHelper(); + +afterEach(() => { + jest.clearAllMocks(); + th.afterEach(); +}); + +describe("POST /Election/:id/CheckoutSession", () => { + beforeAll(() => { + jest.clearAllMocks(); + }); + + var electionId = ""; + + test("Owner creates an election", async () => { + const res = await th.createElection( + { ...testInputs.Election1, state: "draft", voter_limit: 100 } as any, + testInputs.user1token + ); + expect(res.statusCode).toBe(200); + electionId = res.election.election_id; + th.testComplete(); + }); + + test("Unauthenticated user receives 401", async () => { + const res = await th.postRequest( + `/API/Election/${electionId}/CheckoutSession`, + { items: [{ type: "voter_limit_block", quantity: 1 }] }, + null + ); + expect(res.statusCode).toBe(401); + th.testComplete(); + }); + + test("Non-owner receives 401", async () => { + const res = await th.postRequest( + `/API/Election/${electionId}/CheckoutSession`, + { items: [{ type: "voter_limit_block", quantity: 1 }] }, + testInputs.user2token + ); + expect(res.statusCode).toBe(401); + th.testComplete(); + }); + + test("Owner with unknown product type receives 400", async () => { + const res = await th.postRequest( + `/API/Election/${electionId}/CheckoutSession`, + { items: [{ type: "donation_block", quantity: 1 }] }, + testInputs.user1token + ); + expect(res.statusCode).toBe(400); + th.testComplete(); + }); + + test("Owner with quantity that would exceed 5000 voter limit receives 400", async () => { + // voter_limit starts at 100; 25 blocks * 200 = 5000 more would make 5100 > 5000 + const res = await th.postRequest( + `/API/Election/${electionId}/CheckoutSession`, + { items: [{ type: "voter_limit_block", quantity: 25 }] }, + testInputs.user1token + ); + expect(res.statusCode).toBe(400); + th.testComplete(); + }); + + test("Owner with valid cart receives 200 with checkout URL", async () => { + const res = await th.postRequest( + `/API/Election/${electionId}/CheckoutSession`, + { items: [{ type: "voter_limit_block", quantity: 3 }] }, + testInputs.user1token + ); + expect(res.statusCode).toBe(200); + expect(res.body.url).toMatch(/^https:\/\//); + th.testComplete(); + }); +}); diff --git a/packages/shared/src/domain_model/StripeCheckoutSession.ts b/packages/shared/src/domain_model/StripeCheckoutSession.ts index 08ecd945a..8238dc04e 100644 --- a/packages/shared/src/domain_model/StripeCheckoutSession.ts +++ b/packages/shared/src/domain_model/StripeCheckoutSession.ts @@ -1,16 +1,17 @@ import { Uid } from "./Uid"; -export type StripeCheckoutSessionProduct = 'voter_limit'; +export type StripeCheckoutSessionProduct = 'voter_limit_block'; 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). +// A cart line item as stored in stripeCheckoutSessionsDB — a full snapshot of +// the Stripe price_data for that item, tagged with the internal product type. +// Phase 1 ships only 'voter_limit_block'; extra fields (e.g. blocks) are +// included in the snapshot via the catalog's buildLineItems output. export interface StripeCheckoutSessionLineItem { - product: StripeCheckoutSessionProduct; + type: StripeCheckoutSessionProduct; price_data: unknown; quantity: number; + blocks?: number; } export interface StripeCheckoutSession { From 2a4d8d2904f908d9c6a209ed916e772188946d99 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Mon, 31 Aug 2026 16:54:53 -0700 Subject: [PATCH 7/7] Fix type inference in sumVoterLimitPurchases for non-strict frontend tsc frontend/tsconfig.json has strict:false, which changes how tsc infers the callback param type in kysely's .select(eb => ...) here, causing "Untyped function calls may not accept type arguments" during the frontend build (which type-checks this file transitively via useAPI.ts's import of a backend controller). Co-Authored-By: Claude Sonnet 5 --- packages/backend/src/Models/StripeCheckoutSessions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/Models/StripeCheckoutSessions.ts b/packages/backend/src/Models/StripeCheckoutSessions.ts index 5c2ca4060..9a0f2d268 100644 --- a/packages/backend/src/Models/StripeCheckoutSessions.ts +++ b/packages/backend/src/Models/StripeCheckoutSessions.ts @@ -1,6 +1,6 @@ import { ILoggingContext } from '../Services/Logging/ILogger'; import Logger from '../Services/Logging/Logger'; -import { Kysely } from 'kysely' +import { ExpressionBuilder, Kysely } from 'kysely' import { Database } from './Database'; import { StripeCheckoutSession } from '@equal-vote/star-vote-shared/domain_model/StripeCheckoutSession'; @@ -57,7 +57,7 @@ export default class StripeCheckoutSessionsDB { 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')) + .select((eb: ExpressionBuilder) => eb.fn.sum('voter_count_granted').as('total')) .where('election_id', '=', election_id) .where('status', '=', 'paid') .executeTakeFirst();