From e7bfa387e89c94b7705eae2cf40778317490b85c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 17 Oct 2024 07:31:34 +0200 Subject: [PATCH] feat(gatekeeper): add checkout session completion webhook callback path --- .../server/modules/gatekeeper/rest/billing.ts | 146 ++++++++++-------- 1 file changed, 82 insertions(+), 64 deletions(-) diff --git a/packages/server/modules/gatekeeper/rest/billing.ts b/packages/server/modules/gatekeeper/rest/billing.ts index d1cafa181..1fdb7af93 100644 --- a/packages/server/modules/gatekeeper/rest/billing.ts +++ b/packages/server/modules/gatekeeper/rest/billing.ts @@ -2,20 +2,17 @@ import { Router } from 'express' import { validateRequest } from 'zod-express' import { z } from 'zod' import { authorizeResolver } from '@/modules/shared' -import { ensureError, Roles, throwUncoveredError } from '@speckle/shared' +import { ensureError, Roles } from '@speckle/shared' import { Stripe } from 'stripe' import { getFrontendOrigin, + getStringFromEnv, getStripeApiKey, - getStripeEndpointSigningKey, - getWorkspaceBusinessSeatStripePriceId, - getWorkspaceGuestSeatStripePriceId, - getWorkspaceProSeatStripePriceId, - getWorkspaceTeamSeatStripePriceId + getStripeEndpointSigningKey } from '@/modules/shared/helpers/envHelper' import { WorkspacePlanBillingIntervals, - workspacePlans, + paidWorkspacePlans, WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing' import { @@ -23,13 +20,26 @@ import { getWorkspaceBySlugFactory } from '@/modules/workspaces/repositories/workspaces' import { db } from '@/db/knex' -import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/workspaces' -import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' -import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/stripe' import { + completeCheckoutSessionFactory, + startCheckoutSessionFactory +} from '@/modules/gatekeeper/services/workspaces' +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { + createCheckoutSessionFactory, + getSubscriptionDataFactory +} from '@/modules/gatekeeper/clients/stripe' +import { + deleteCheckoutSessionFactory, + getCheckoutSessionFactory, + getWorkspaceCheckoutSessionFactory, getWorkspacePlanFactory, - saveCheckoutSessionFactory + saveCheckoutSessionFactory, + saveWorkspaceSubscriptionFactory, + updateCheckoutSessionStatusFactory, + upsertPaidWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' +import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing' const router = Router() @@ -37,33 +47,43 @@ export default router const stripe = new Stripe(getStripeApiKey(), { typescript: true }) -const getWorkspacePlanPrice = ({ - workspacePlan -}: { - workspacePlan: WorkspacePricingPlans - billingInterval: WorkspacePlanBillingIntervals -}): string => { - // right now, ignoring interval - switch (workspacePlan) { - case 'team': - return getWorkspaceTeamSeatStripePriceId() - case 'pro': - return getWorkspaceProSeatStripePriceId() - case 'business': - return getWorkspaceBusinessSeatStripePriceId() - case 'guest': - return getWorkspaceGuestSeatStripePriceId() - default: - throwUncoveredError(workspacePlan) +const workspacePlanPrices = (): Record< + WorkspacePricingPlans, + Record & { productId: string } +> => ({ + guest: { + productId: getStringFromEnv('WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID') + }, + team: { + productId: getStringFromEnv('WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_TEAM_SEAT_STRIPE_PRICE_ID') + }, + pro: { + productId: getStringFromEnv('WORKSPACE_PRO_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_PRO_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_PRO_SEAT_STRIPE_PRICE_ID') + }, + business: { + productId: getStringFromEnv('WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID'), + monthly: getStringFromEnv('WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID'), + yearly: getStringFromEnv('WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID') } -} +}) + +const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({ + workspacePlan, + billingInterval +}) => workspacePlanPrices()[workspacePlan][billingInterval] router.get( '/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan', validateRequest({ params: z.object({ workspaceSlug: z.string().min(1), - workspacePlan: workspacePlans + workspacePlan: paidWorkspacePlans }) }), async (req) => { @@ -88,6 +108,7 @@ router.get( const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db }) const session = await startCheckoutSessionFactory({ + getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory(), getWorkspacePlan: getWorkspacePlanFactory(), countRole, createCheckoutSession, @@ -98,34 +119,6 @@ router.get( } ) -// const fulfillCheckoutFactory = -// ({ stripe }: { stripe: Stripe }) => -// async ({ sessionId }: { sessionId: string }) => { -// // Set your secret key. Remember to switch to your live secret key in production. -// // See your keys here: https://dashboard.stripe.com/apikeys - -// console.log('Fulfilling Checkout Session ' + sessionId) - -// // TODO: Make this function safe to run multiple times, -// // even concurrently, with the same session ID - -// // TODO: Make sure fulfillment hasn't already been -// // peformed for this Checkout Session - -// // Retrieve the Checkout Session from the API with line_items expanded -// const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId, { -// expand: ['line_items'] -// }) - -// // Check the Checkout Session's payment_status property -// // to determine if fulfillment should be peformed -// if (checkoutSession.payment_status !== 'unpaid') { -// // TODO: Perform fulfillment of the line items -// // TODO: Record/save fulfillment status for this -// // Checkout Session -// } -// } - router.post('/api/v1/billing/webhooks', async (req, res) => { const endpointSecret = getStripeEndpointSigningKey() const sig = req.headers['stripe-signature'] @@ -156,9 +149,10 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { case 'checkout.session.completed': const session = event.data.object - session.subscription + if (!session.subscription) + return res.status(400).send('We only support subscription type checkouts') - if (session.payment_status !== 'unpaid') { + 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! @@ -167,11 +161,35 @@ router.post('/api/v1/billing/webhooks', async (req, res) => { // set checkout state to paid // go ahead and provision the plan // store customer id and subscription Id associated to the workspace plan - } - // move the workspace plan to the new plan + const subscriptionId = + typeof session.subscription === 'string' + ? session.subscription + : session.subscription.id + + // this must use a transaction + const completeCheckout = completeCheckoutSessionFactory({ + getCheckoutSession: getCheckoutSessionFactory(), + updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory(), + upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory(), + saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory(), + getSubscriptionData: getSubscriptionDataFactory({ + stripe + }) + }) + + await completeCheckout({ + sessionId: session.id, + subscriptionId + }) + } + break + case 'checkout.session.expired': - // delete the checkout session from the DB + // delete the checkout session from the DB + await deleteCheckoutSessionFactory()({ checkoutSessionId: event.data.object.id }) + break + default: break }