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..dc3c50f75 --- /dev/null +++ b/packages/backend/src/Migrations/2026_08_24_voter_limit_and_stripe_sessions.ts @@ -0,0 +1,49 @@ +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('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') + .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..bd4c9e808 --- /dev/null +++ b/packages/backend/src/Models/StripeCheckoutSessions.ts @@ -0,0 +1,57 @@ +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. + // 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('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..c58d2dd5b --- /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.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..08ecd945a --- /dev/null +++ b/packages/shared/src/domain_model/StripeCheckoutSession.ts @@ -0,0 +1,27 @@ +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; + line_items: StripeCheckoutSessionLineItem[]; + amount_cents: number; + voter_count_granted?: number; + stripe_checkout_session_id: string; + stripe_customer_id?: string; + status: StripeCheckoutSessionStatus; + created_date: string; +}