Skip to content
Draft
1,930 changes: 1,528 additions & 402 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/Controllers/Election/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export * from './setPublicResultsController';
export * from './sendEmailController';
export * from './claimElectionController';
export * from './setWriteInResultsController'
export * from './createCheckoutSessionController'
21 changes: 15 additions & 6 deletions packages/backend/src/Controllers/Roll/addElectionRollController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}

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,
};

// 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')
.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<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

const newElection = this._postgresClient
.insertInto(tableName)
Expand Down
66 changes: 66 additions & 0 deletions packages/backend/src/Models/StripeCheckoutSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ILoggingContext } from '../Services/Logging/ILogger';
import Logger from '../Services/Logging/Logger';
import { ExpressionBuilder, 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 updateStripeSessionId(placeholder_id: string, real_session_id: string, ctx: ILoggingContext): Promise<void> {
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<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: ExpressionBuilder<Database, typeof tableName>) => eb.fn.sum<number>('voter_count_granted').as('total'))
.where('election_id', '=', election_id)
.where('status', '=', 'paid')
.executeTakeFirst();
return Number(result?.total ?? 0);
}
}
2 changes: 2 additions & 0 deletions packages/backend/src/Models/__mocks__/Elections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions packages/backend/src/Models/__mocks__/StripeCheckoutSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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 updateStripeSessionId(placeholder_id: string, real_session_id: string, ctx: ILoggingContext): Promise<void> {
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<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);
}
}
Loading