feat(gatekeeper): move to knex based repositories

This commit is contained in:
Gergő Jedlicska
2024-10-19 14:58:02 +02:00
parent 81d09dd07c
commit cf5cf4b9c0
7 changed files with 150 additions and 92 deletions
@@ -68,6 +68,8 @@ export const createCheckoutSessionFactory =
billingInterval,
workspacePlan,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
paymentStatus: 'unpaid'
}
}
@@ -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
@@ -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) {
@@ -0,0 +1,41 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
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<void> {
await knex.schema.dropTable('workspace_plans')
await knex.schema.dropTable('workspace_checkout_sessions')
await knex.schema.dropTable('workspace_subscriptions')
}
@@ -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<WorkspacePlan>('workspace_plans'),
workspaceCheckoutSessions: (db: Knex) =>
db<CheckoutSession>('workspace_checkout_sessions'),
workspaceSubscriptions: (db: Knex) =>
db<WorkspaceSubscription>('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)
}
@@ -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:
@@ -159,6 +159,7 @@ export const completeCheckoutSessionFactory =
const workspaceSubscription = {
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd,
workspaceId: checkoutSession.workspaceId,
billingInterval: checkoutSession.billingInterval,