feat(gatekeeper): move to knex based repositories
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user