-
Notifications
You must be signed in to change notification settings - Fork 56
Add voter_limit column & stripeCheckoutSessionsDB table #1589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a2aa2e8
49c62c8
f3a938a
f6cb070
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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` | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| UPDATE "electionDB" e | ||
| SET voter_limit = GREATEST(100, ( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need amount_cents here as well?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
||
| 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); | ||
| } | ||
| } |
| 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); | ||
| } | ||
| } |
| 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, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from "./SharedConfig"; | ||
| export * from "./PricingConfig"; |
| 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
There was a problem hiding this comment.
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.