From b7fd1208f01dcf065c7675946f485f8f8638dabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= <57442769+gjedlicska@users.noreply.github.com> Date: Mon, 21 Oct 2024 13:48:25 +0200 Subject: [PATCH] fix(gatekeeper): fix billing router initialization (#3349) --- packages/server/modules/gatekeeper/index.ts | 4 +- .../server/modules/gatekeeper/rest/billing.ts | 249 +++++++++--------- 2 files changed, 128 insertions(+), 125 deletions(-) diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 5e37d9315..c5e5a4fd4 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -2,7 +2,7 @@ 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' -import billingRouter from '@/modules/gatekeeper/rest/billing' +import { getBillingRouter } from '@/modules/gatekeeper/rest/billing' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -24,7 +24,7 @@ const gatekeeperModule: SpeckleModule = { 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) { - app.use(billingRouter) + app.use(getBillingRouter()) const isLicenseValid = await validateModuleLicense({ requiredModules: ['billing'] diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index da2a815f9..a02093e06 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -44,12 +44,6 @@ import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing' import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing' import { withTransaction } from '@/modules/shared/helpers/dbHelper' -const router = Router() - -export default router - -const stripe = new Stripe(getStripeApiKey(), { typescript: true }) - const workspacePlanPrices = (): Record< WorkspacePricingPlans, Record & { productId: string } @@ -81,144 +75,153 @@ const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({ billingInterval }) => workspacePlanPrices()[workspacePlan][billingInterval] -// this prob needs to be turned into a GQL resolver for better frontend integration for errors -router.get( - '/api/v1/billing/workspaces/:workspaceId/checkout-session/:workspacePlan/:billingInterval', - validateRequest({ - params: z.object({ - workspaceId: z.string().min(1), - workspacePlan: paidWorkspacePlans, - billingInterval: workspacePlanBillingIntervals - }) - }), - async (req) => { - const { workspaceId, workspacePlan, billingInterval } = req.params - const workspace = await getWorkspaceFactory({ db })({ workspaceId }) +export const getBillingRouter = (): Router => { + const router = Router() - if (!workspace) throw new WorkspaceNotFoundError() + const stripe = new Stripe(getStripeApiKey(), { typescript: true }) - await authorizeResolver( - req.context.userId, - workspaceId, - Roles.Workspace.Admin, - req.context.resourceAccessRules - ) + // this prob needs to be turned into a GQL resolver for better frontend integration for errors + router.get( + '/api/v1/billing/workspaces/:workspaceId/checkout-session/:workspacePlan/:billingInterval', + validateRequest({ + params: z.object({ + workspaceId: z.string().min(1), + workspacePlan: paidWorkspacePlans, + billingInterval: workspacePlanBillingIntervals + }) + }), + async (req) => { + const { workspaceId, workspacePlan, billingInterval } = req.params + const workspace = await getWorkspaceFactory({ db })({ workspaceId }) - const createCheckoutSession = createCheckoutSessionFactory({ - stripe, - frontendOrigin: getFrontendOrigin(), - getWorkspacePlanPrice - }) + if (!workspace) throw new WorkspaceNotFoundError() - const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db }) + await authorizeResolver( + req.context.userId, + workspaceId, + Roles.Workspace.Admin, + req.context.resourceAccessRules + ) - const session = await startCheckoutSessionFactory({ - getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }), - getWorkspacePlan: getWorkspacePlanFactory({ db }), - countRole, - createCheckoutSession, - saveCheckoutSession: saveCheckoutSessionFactory({ db }) - })({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval }) + const createCheckoutSession = createCheckoutSessionFactory({ + stripe, + frontendOrigin: getFrontendOrigin(), + getWorkspacePlanPrice + }) - req.res?.redirect(session.url) - } -) + const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db }) -router.post('/api/v1/billing/webhooks', async (req, res) => { - const endpointSecret = getStripeEndpointSigningKey() - const sig = req.headers['stripe-signature'] - if (!sig) { - res.status(400).send('Missing payload signature') - return - } + const session = await startCheckoutSessionFactory({ + getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }), + getWorkspacePlan: getWorkspacePlanFactory({ db }), + countRole, + createCheckoutSession, + saveCheckoutSession: saveCheckoutSessionFactory({ db }) + })({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval }) - let event: Stripe.Event + req.res?.redirect(session.url) + } + ) - try { - event = stripe.webhooks.constructEvent( - // yes, the express json middleware auto parses the payload and stri need it in a string - req.body, - sig, - endpointSecret - ) - } catch (err) { - res.status(400).send(`Webhook Error: ${ensureError(err).message}`) - return - } + router.post('/api/v1/billing/webhooks', async (req, res) => { + const endpointSecret = getStripeEndpointSigningKey() + const sig = req.headers['stripe-signature'] + if (!sig) { + res.status(400).send('Missing payload signature') + return + } - switch (event.type) { - case 'checkout.session.async_payment_failed': - // TODO: need to alert the user and delete the session ? - break - case 'checkout.session.async_payment_succeeded': - case 'checkout.session.completed': - const session = event.data.object + let event: Stripe.Event - if (!session.subscription) - return res.status(400).send('We only support subscription type checkouts') + try { + event = stripe.webhooks.constructEvent( + // yes, the express json middleware auto parses the payload and stri need it in a string + req.body, + sig, + endpointSecret + ) + } catch (err) { + res.status(400).send(`Webhook Error: ${ensureError(err).message}`) + return + } - if (session.payment_status === 'paid') { - // If the workspace is already on a paid plan, we made a bo bo. - // existing subs should be updated via the api, not pushed through the checkout sess again - // the start checkout endpoint should guard this! - // get checkout session from the DB, if not found CONTACT SUPPORT!!! - // if the session is already paid, means, we've already settled this checkout, and this is a webhook recall - // set checkout state to paid - // go ahead and provision the plan - // store customer id and subscription Id associated to the workspace plan + switch (event.type) { + case 'checkout.session.async_payment_failed': + // TODO: need to alert the user and delete the session ? + break + case 'checkout.session.async_payment_succeeded': + case 'checkout.session.completed': + const session = event.data.object - const subscriptionId = - typeof session.subscription === 'string' - ? session.subscription - : session.subscription.id + if (!session.subscription) + return res.status(400).send('We only support subscription type checkouts') - // this must use a transaction + if (session.payment_status === 'paid') { + // If the workspace is already on a paid plan, we made a bo bo. + // existing subs should be updated via the api, not pushed through the checkout sess again + // the start checkout endpoint should guard this! + // get checkout session from the DB, if not found CONTACT SUPPORT!!! + // if the session is already paid, means, we've already settled this checkout, and this is a webhook recall + // set checkout state to paid + // go ahead and provision the plan + // store customer id and subscription Id associated to the workspace plan - const trx = await db.transaction() + const subscriptionId = + typeof session.subscription === 'string' + ? session.subscription + : session.subscription.id - const completeCheckout = completeCheckoutSessionFactory({ - getCheckoutSession: getCheckoutSessionFactory({ db: trx }), - updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({ db: trx }), - upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }), - saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory({ db: trx }), - getSubscriptionData: getSubscriptionDataFactory({ - stripe - }) - }) + // this must use a transaction - try { - await withTransaction( - completeCheckout({ - sessionId: session.id, - subscriptionId + const trx = await db.transaction() + + const completeCheckout = completeCheckoutSessionFactory({ + getCheckoutSession: getCheckoutSessionFactory({ db: trx }), + updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({ + db: trx }), - trx - ) - } catch (err) { - if (err instanceof WorkspaceAlreadyPaidError) { - // ignore the request, this is prob a replay from stripe - } else { - throw err + upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }), + saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory({ db: trx }), + getSubscriptionData: getSubscriptionDataFactory({ + stripe + }) + }) + + try { + await withTransaction( + completeCheckout({ + sessionId: session.id, + subscriptionId + }), + trx + ) + } catch (err) { + if (err instanceof WorkspaceAlreadyPaidError) { + // ignore the request, this is prob a replay from stripe + } else { + throw err + } } } - } - break + break - case 'checkout.session.expired': - // delete the checkout session from the DB - await deleteCheckoutSessionFactory({ db })({ - checkoutSessionId: event.data.object.id - }) - break + case 'checkout.session.expired': + // delete the checkout session from the DB + await deleteCheckoutSessionFactory({ db })({ + checkoutSessionId: event.data.object.id + }) + break - default: - break - } + default: + break + } - res.status(200).send('ok') -}) + res.status(200).send('ok') + }) -// prob needed when the checkout is cancelled -router.delete( - '/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan' -) + // prob needed when the checkout is cancelled + router.delete( + '/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan' + ) + return router +}