From cf5cf4b9c09252ebe4e1476f4e627589c703f500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Sat, 19 Oct 2024 14:58:02 +0200 Subject: [PATCH] feat(gatekeeper): move to knex based repositories --- .../modules/gatekeeper/clients/stripe.ts | 2 + .../modules/gatekeeper/domain/billing.ts | 14 +- packages/server/modules/gatekeeper/index.ts | 3 + .../20241018132400_workspace_checkout.ts | 41 ++++++ .../gatekeeper/repositories/billing.ts | 128 +++++++++--------- .../server/modules/gatekeeper/rest/billing.ts | 53 +++++--- .../modules/gatekeeper/services/checkout.ts | 1 + 7 files changed, 150 insertions(+), 92 deletions(-) create mode 100644 packages/server/modules/gatekeeper/migrations/20241018132400_workspace_checkout.ts diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index 3416a29e5..2bd749cfd 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -68,6 +68,8 @@ export const createCheckoutSessionFactory = billingInterval, workspacePlan, workspaceId, + createdAt: new Date(), + updatedAt: new Date(), paymentStatus: 'unpaid' } } diff --git a/packages/server/modules/gatekeeper/domain/billing.ts b/packages/server/modules/gatekeeper/domain/billing.ts index 6398a94a8..0d3ab11cf 100644 --- a/packages/server/modules/gatekeeper/domain/billing.ts +++ b/packages/server/modules/gatekeeper/domain/billing.ts @@ -17,20 +17,21 @@ export type PaidWorkspacePlanStatuses = export type TrialWorkspacePlanStatuses = 'trial' -export type PaidWorkspacePlan = { +type BaseWorkspacePlan = { workspaceId: string +} + +export type PaidWorkspacePlan = BaseWorkspacePlan & { name: PaidWorkspacePlans status: PaidWorkspacePlanStatuses } -export type TrialWorkspacePlan = { - workspaceId: string +export type TrialWorkspacePlan = BaseWorkspacePlan & { name: TrialWorkspacePlans status: TrialWorkspacePlanStatuses } -export type UnpaidWorkspacePlan = { - workspaceId: string +export type UnpaidWorkspacePlan = BaseWorkspacePlan & { name: UnpaidWorkspacePlans status: UnpaidWorkspacePlanStatuses } @@ -65,6 +66,8 @@ export type CheckoutSession = SessionInput & { workspacePlan: PaidWorkspacePlans paymentStatus: SessionPaymentStatus billingInterval: WorkspacePlanBillingIntervals + createdAt: Date + updatedAt: Date } export type SaveCheckoutSession = (args: { @@ -100,6 +103,7 @@ export type CreateCheckoutSession = (args: { export type WorkspaceSubscription = { workspaceId: string createdAt: Date + updatedAt: Date currentBillingCycleEnd: Date billingInterval: WorkspacePlanBillingIntervals subscriptionData: SubscriptionData diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index d90f26979..5e37d9315 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -1,3 +1,4 @@ +import { moduleLogger } from '@/logging/logging' import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense' @@ -18,6 +19,8 @@ const gatekeeperModule: SpeckleModule = { 'The gatekeeper module needs a valid license to run, contact Speckle to get one.' ) + moduleLogger.info('🗝️ Init gatekeeper module') + if (isInitial) { // TODO: need to subscribe to the workspaceCreated event and store the workspacePlan as a trial if billing enabled, else store as unlimited if (FF_BILLING_INTEGRATION_ENABLED) { diff --git a/packages/server/modules/gatekeeper/migrations/20241018132400_workspace_checkout.ts b/packages/server/modules/gatekeeper/migrations/20241018132400_workspace_checkout.ts new file mode 100644 index 000000000..da222e7bf --- /dev/null +++ b/packages/server/modules/gatekeeper/migrations/20241018132400_workspace_checkout.ts @@ -0,0 +1,41 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('workspace_plans', (table) => { + // im associating this to the workspace 1-1, i do not want a 1-many relationship possible + table.text('workspaceId').primary().references('id').inTable('workspaces') + table.text('name').notNullable() + table.text('status').notNullable() + }) + await knex.schema.createTable('workspace_checkout_sessions', (table) => { + // im associating this to the workspace 1-1, i do not want a 1-many relationship possible + table.text('workspaceId').primary().references('id').inTable('workspaces') + // this is not the primaryId, its the stripe provided checkout sessionId + // but we'll still need to index by it + table.text('id').notNullable().index() + table.text('url').notNullable() + table.text('workspacePlan').notNullable() + table.text('paymentStatus').notNullable() + table.text('billingInterval').notNullable() + table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable() + table.timestamp('updatedAt', { precision: 3, useTz: true }).notNullable() + }) + + await knex.schema.createTable('workspace_subscriptions', (table) => { + table.text('workspaceId').primary().references('id').inTable('workspaces') + table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable() + table.timestamp('updatedAt', { precision: 3, useTz: true }).notNullable() + table + .timestamp('currentBillingCycleEnd', { precision: 3, useTz: true }) + .notNullable() + + table.text('billingInterval').notNullable() + table.jsonb('subscriptionData').notNullable() + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable('workspace_plans') + await knex.schema.dropTable('workspace_checkout_sessions') + await knex.schema.dropTable('workspace_subscriptions') +} diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index cadec054b..93d7fa9bc 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -12,94 +12,90 @@ import { DeleteCheckoutSession, GetWorkspaceCheckoutSession } from '@/modules/gatekeeper/domain/billing' -import { CheckoutSessionNotFoundError } from '@/modules/gatekeeper/errors/billing' +import { Knex } from 'knex' + +const tables = { + workspacePlans: (db: Knex) => db('workspace_plans'), + workspaceCheckoutSessions: (db: Knex) => + db('workspace_checkout_sessions'), + workspaceSubscriptions: (db: Knex) => + db('workspace_subscriptions') +} export const getWorkspacePlanFactory = - (): GetWorkspacePlan => - ({ workspaceId }) => { - const maybePlan = workspacePlans.find((plan) => plan.workspaceId === workspaceId) - return new Promise((resolve) => { - resolve(maybePlan || null) - }) + ({ db }: { db: Knex }): GetWorkspacePlan => + async ({ workspaceId }) => { + const workspacePlan = await tables + .workspacePlans(db) + .select() + .where({ workspaceId }) + .first() + return workspacePlan ?? null } -const workspacePlans: WorkspacePlan[] = [] - const upsertWorkspacePlanFactory = - (): UpsertWorkspacePlan => - ({ workspacePlan }) => { - const maybePlan = workspacePlans.find( - (plan) => plan.workspaceId === workspacePlan.workspaceId - ) - if (maybePlan) { - maybePlan.name = workspacePlan.name - maybePlan.status = workspacePlan.status - } else { - workspacePlans.push(workspacePlan) - } - return new Promise((resolve) => { - resolve() - }) + ({ db }: { db: Knex }): UpsertWorkspacePlan => + async ({ workspacePlan }) => { + await tables + .workspacePlans(db) + .insert(workspacePlan) + .onConflict('workspaceId') + .merge(['name', 'status']) } // this is a typed rebrand of the generic workspace plan upsert // this way TS guards the payment plan type validity -export const upsertPaidWorkspacePlanFactory = (): UpsertPaidWorkspacePlan => - upsertWorkspacePlanFactory() - -const checkoutSessions: CheckoutSession[] = [] +export const upsertPaidWorkspacePlanFactory = ({ + db +}: { + db: Knex +}): UpsertPaidWorkspacePlan => upsertWorkspacePlanFactory({ db }) export const saveCheckoutSessionFactory = - (): SaveCheckoutSession => - ({ checkoutSession }) => { - checkoutSessions.push(checkoutSession) - return new Promise((resolve) => { - resolve() - }) + ({ db }: { db: Knex }): SaveCheckoutSession => + async ({ checkoutSession }) => { + await tables.workspaceCheckoutSessions(db).insert(checkoutSession) } -export const deleteCheckoutSessionFactory = (): DeleteCheckoutSession => () => { - return new Promise((resolve) => { - resolve() - }) -} +export const deleteCheckoutSessionFactory = + ({ db }: { db: Knex }): DeleteCheckoutSession => + async ({ checkoutSessionId }) => { + await tables.workspaceCheckoutSessions(db).delete().where({ id: checkoutSessionId }) + } export const getCheckoutSessionFactory = - (): GetCheckoutSession => - ({ sessionId }) => { - return new Promise((resolve) => { - resolve(checkoutSessions.find((session) => session.id === sessionId) || null) - }) + ({ db }: { db: Knex }): GetCheckoutSession => + async ({ sessionId }) => { + const checkoutSession = await tables + .workspaceCheckoutSessions(db) + .select() + .where({ id: sessionId }) + .first() + return checkoutSession || null } export const getWorkspaceCheckoutSessionFactory = - (): GetWorkspaceCheckoutSession => - ({ workspaceId }) => { - return new Promise((resolve) => { - resolve( - checkoutSessions.find((session) => session.workspaceId === workspaceId) || null - ) - }) + ({ db }: { db: Knex }): GetWorkspaceCheckoutSession => + async ({ workspaceId }) => { + const checkoutSession = await tables + .workspaceCheckoutSessions(db) + .select() + .where({ workspaceId }) + .first() + return checkoutSession || null } export const updateCheckoutSessionStatusFactory = - (): UpdateCheckoutSessionStatus => - ({ sessionId, paymentStatus }) => { - const session = checkoutSessions.find((session) => session.id === sessionId) - if (!session) throw new CheckoutSessionNotFoundError() - session.paymentStatus = paymentStatus - return new Promise((resolve) => { - resolve() - }) + ({ db }: { db: Knex }): UpdateCheckoutSessionStatus => + async ({ sessionId, paymentStatus }) => { + await tables + .workspaceCheckoutSessions(db) + .where({ id: sessionId }) + .update({ paymentStatus, updatedAt: new Date() }) } -const workspaceSubscriptions: WorkspaceSubscription[] = [] - export const saveWorkspaceSubscriptionFactory = - (): SaveWorkspaceSubscription => - ({ workspaceSubscription }) => { - workspaceSubscriptions.push(workspaceSubscription) - return new Promise((resolve) => { - resolve() - }) + ({ db }: { db: Knex }): SaveWorkspaceSubscription => + async ({ workspaceSubscription }) => { + await tables.workspaceSubscriptions(db).insert(workspaceSubscription) } diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index b4752f0e5..26d0bd1b2 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -13,11 +13,12 @@ import { import { WorkspacePlanBillingIntervals, paidWorkspacePlans, - WorkspacePricingPlans + WorkspacePricingPlans, + workspacePlanBillingIntervals } from '@/modules/gatekeeper/domain/workspacePricing' import { countWorkspaceRoleWithOptionalProjectRoleFactory, - getWorkspaceBySlugFactory + getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { db } from '@/db/knex' import { @@ -41,6 +42,7 @@ import { } from '@/modules/gatekeeper/repositories/billing' import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing' import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing' +import { withTransaction } from '@/modules/shared/helpers/dbHelper' const router = Router() @@ -80,19 +82,20 @@ const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({ }) => workspacePlanPrices()[workspacePlan][billingInterval] router.get( - '/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan', + '/api/v1/billing/workspaces/:workspaceId/checkout-session/:workspacePlan/:billingInterval', validateRequest({ params: z.object({ - workspaceSlug: z.string().min(1), - workspacePlan: paidWorkspacePlans + workspaceId: z.string().min(1), + workspacePlan: paidWorkspacePlans, + billingInterval: workspacePlanBillingIntervals }) }), async (req) => { - const { workspaceSlug, workspacePlan } = req.params - const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug }) + const { workspaceId, workspacePlan, billingInterval } = req.params + const workspace = await getWorkspaceFactory({ db })({ workspaceId }) + if (!workspace) throw new WorkspaceNotFoundError() - const workspaceId = workspace.id await authorizeResolver( req.context.userId, workspaceId, @@ -109,12 +112,12 @@ router.get( const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db }) const session = await startCheckoutSessionFactory({ - getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory(), - getWorkspacePlan: getWorkspacePlanFactory(), + getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }), + getWorkspacePlan: getWorkspacePlanFactory({ db }), countRole, createCheckoutSession, - saveCheckoutSession: saveCheckoutSessionFactory() - })({ workspacePlan, workspaceId, workspaceSlug, billingInterval: 'monthly' }) + saveCheckoutSession: saveCheckoutSessionFactory({ db }) + })({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval }) req.res?.redirect(session.url) } @@ -169,21 +172,27 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { : session.subscription.id // this must use a transaction + + const trx = await db.transaction() + const completeCheckout = completeCheckoutSessionFactory({ - getCheckoutSession: getCheckoutSessionFactory(), - updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory(), - upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory(), - saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory(), + getCheckoutSession: getCheckoutSessionFactory({ db: trx }), + updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({ db: trx }), + upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }), + saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory({ db: trx }), getSubscriptionData: getSubscriptionDataFactory({ stripe }) }) try { - await completeCheckout({ - sessionId: session.id, - subscriptionId - }) + await withTransaction( + completeCheckout({ + sessionId: session.id, + subscriptionId + }), + trx + ) } catch (err) { if (err instanceof WorkspaceAlreadyPaidError) { // ignore the request, this is prob a replay from stripe @@ -196,7 +205,9 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { case 'checkout.session.expired': // delete the checkout session from the DB - await deleteCheckoutSessionFactory()({ checkoutSessionId: event.data.object.id }) + await deleteCheckoutSessionFactory({ db })({ + checkoutSessionId: event.data.object.id + }) break default: diff --git a/packages/server/modules/gatekeeper/services/checkout.ts b/packages/server/modules/gatekeeper/services/checkout.ts index 283f53c02..b9a9e7611 100644 --- a/packages/server/modules/gatekeeper/services/checkout.ts +++ b/packages/server/modules/gatekeeper/services/checkout.ts @@ -159,6 +159,7 @@ export const completeCheckoutSessionFactory = const workspaceSubscription = { createdAt: new Date(), + updatedAt: new Date(), currentBillingCycleEnd, workspaceId: checkoutSession.workspaceId, billingInterval: checkoutSession.billingInterval,