Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/DevElections/elections/tiechecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/DevElections/elections/wizardstar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/DevElections/elections/writeins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const election: Election = {
update_date: Date.now().toString(),
head: true,
ballot_source: 'live_election',
voter_limit: 100,
};

function makeBallots(): Ballot[] {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Kysely, sql } from 'kysely'

export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('electionDB')
.addColumn('voter_limit', 'integer')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about if the voter_limit should be it's own column or part of the election settings.

It could technically be part of the settings, but on principle I think items under the settings should be assumed to by editable by the admin, and anything that requires more careful state control should be surfaced as a column.

.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`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is written using sql instead of the kysely ORM functions because kysely would have been more awkward. Here's an example of what that would have looked like

await db
  .updateTable("electionDB")
  .set((eb) => ({
    voter_limit: eb.fn("greatest", [
      eb.val(100),
      eb
        .selectFrom("electionRollDB")
        .select(eb.fn.countAll().$castTo<number>().as("count"))
        .whereRef("electionRollDB.election_id", "=", "electionDB.election_id")
        .where("head", "=", true),
    ]),
  }))
  .execute();

UPDATE "electionDB" e
SET voter_limit = GREATEST(100, (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense to hardcode this rather than use the variable from the shared package. If the variable in the shared package ever changes, then that would require an additional migration.

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())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need amount_cents here as well?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That can be derived from the itemized info in line_items

.addColumn('user_id', 'varchar', (col) => col.notNull())
.addColumn('line_items', 'jsonb', (col) => col.notNull())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line items will be a json blob matching what was passed to stripe. Here's an example for adding 600 voters to the vote limit

line_items: [
    {
      price_data: {
        currency: "usd",
        unit_amount: 500,
        product_data: {
          name: "Voter Batch",
          description: "Add a batch of voters to the vote limit (200 per batch)",
          images: [
            "https://example.com/images/product.jpg",
          ],
        },
      },
      quantity: 3,
}
]

This is written to be generic so that the transaction could also theoretically include both a voter limit increase and an add-on, such as adding a custom slug.

.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<any>): Promise<void> {
await db.schema.dropTable('stripeCheckoutSessionsDB').execute()

await db.schema.alterTable('electionDB')
.dropColumn('voter_limit')
.execute()
}
4 changes: 3 additions & 1 deletion packages/backend/src/Models/Database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 3 additions & 2 deletions packages/backend/src/Models/Elections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,9 +45,10 @@ export default class ElectionsDB implements IElectionStore {

createElection(election: Election, ctx: ILoggingContext, reason: string): Promise<Election> {
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

@jacksonloper jacksonloper Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We set voter_limit here to free tier limit. But I think they can edit it later with a suitable devious CURL using the updateElection endpoint. (like POST {"Election": {..., "voter_limit": 1000000}} to /API/Election/:id/edit)

@ArendPeter ArendPeter Sep 12, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I've got it on the roadmap for the next PRs

The automated ticket generation got really messy, but it's tracked at #1585


const newElection = this._postgresClient
.insertInto(tableName)
Expand Down
57 changes: 57 additions & 0 deletions packages/backend/src/Models/StripeCheckoutSessions.ts
Original file line number Diff line number Diff line change
@@ -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<Database>) {
this._postgresClient = postgresClient;
}

async insert(session: Omit<StripeCheckoutSession, 'id'>, ctx: ILoggingContext): Promise<void> {
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<StripeCheckoutSession | null> {
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<void> {
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<number> {
Logger.debug(ctx, `${tableName}.sumVoterLimitPurchases election_id=${election_id}`);
const result = await this._postgresClient
.selectFrom(tableName)
.select((eb) => eb.fn.sum<number>('voter_count_granted').as('total'))
.where('election_id', '=', election_id)
.where('status', '=', 'paid')
.executeTakeFirst();
return Number(result?.total ?? 0);
}
}
31 changes: 31 additions & 0 deletions packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts
Original file line number Diff line number Diff line change
@@ -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<StripeCheckoutSession, 'id'>, ctx: ILoggingContext): Promise<void> {
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<StripeCheckoutSession | null> {
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<void> {
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<number> {
return this._sessions
.filter(s => s.election_id === election_id && s.status === 'paid')
.reduce((sum, s) => sum + (s.voter_count_granted ?? 0), 0);
}
}
1 change: 1 addition & 0 deletions packages/backend/src/test/database_sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/config/PricingConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const pricingConfig = {
FREE_TIER_LIMIT: 100,
BLOCK_SIZE: 200,
PRICE_PER_BLOCK_CENTS: 1000,
};
1 change: 1 addition & 0 deletions packages/shared/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./SharedConfig";
export * from "./PricingConfig";
4 changes: 4 additions & 0 deletions packages/shared/src/domain_model/Election.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions packages/shared/src/domain_model/StripeCheckoutSession.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think maybe this should go away to match the database

voter_count_granted?: number;
stripe_checkout_session_id: string;
stripe_customer_id?: string;
status: StripeCheckoutSessionStatus;
created_date: string;
}
Loading