Files
speckle-server/packages/server/modules/gatekeeper/rest/billing.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

265 lines
9.7 KiB
TypeScript

import { Router } from 'express'
import { ensureError } from '@speckle/shared'
import { Stripe } from 'stripe'
import { getStripeEndpointSigningKey } from '@/modules/shared/helpers/envHelper'
import { db } from '@/db/knex'
import { completeCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout'
import {
getStripeClient,
getStripeSubscriptionDataFactory,
parseSubscriptionData
} from '@/modules/gatekeeper/clients/stripe'
import {
deleteCheckoutSessionFactory,
getCheckoutSessionFactory,
getWorkspacePlanFactory,
upsertWorkspaceSubscriptionFactory,
updateCheckoutSessionStatusFactory,
upsertPaidWorkspacePlanFactory,
getWorkspaceSubscriptionBySubscriptionIdFactory,
getWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/repositories/billing'
import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing'
import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions'
import { GetStripeClient, SubscriptionData } from '@/modules/gatekeeper/domain/billing'
import { extendLoggerComponent } from '@/observability/logging'
import {
OperationName,
OperationStatus,
stripeEventId
} from '@/observability/domain/fields'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import { asOperation } from '@/modules/shared/command'
import { getEventBus } from '@/modules/shared/services/eventBus'
export const getBillingRouter = (): Router => {
const router = Router()
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
}
// req.log will have request ID property, so all subsequent log messages can be traced
let logger = extendLoggerComponent(req.log, 'gatekeeper', 'rest', 'billing')
const stripe = getStripeClient()
let event: Stripe.Event
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) {
const e = ensureError(err, 'Unknown error constructing Stripe webhook event')
res.status(400).json({ error: e.message })
return
}
if ('id' in event.data.object)
logger = logger.child(stripeEventId(event.data.object.id))
switch (event.type) {
case 'checkout.session.async_payment_failed':
// if payment fails, we delete the failed session
await withOperationLogging(
async () =>
await deleteCheckoutSessionFactory({ db })({
checkoutSessionId: event.data.object.id
}),
{
logger,
operationName: 'deleteCheckoutSession',
operationDescription: 'Payment failed'
}
)
break
case 'checkout.session.async_payment_succeeded':
case 'checkout.session.completed':
const session = event.data.object
if (!session.subscription) {
logger.warn('Received a checkout session without a subscription')
return res.status(400).send('We only support subscription type checkouts')
}
switch (session.payment_status) {
case 'no_payment_required':
// we do not need to support this status
logger.info(
'Payment succeeded or Stripe session completed, and no payment was required'
)
break
case '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 subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
logger = logger.child({
subscriptionId,
...OperationName('completeCheckoutSession')
})
logger.info(OperationStatus.start, '[{operationName} ({operationStatus})] ')
await asOperation(
async ({ db, emit }) => {
try {
const completeCheckout = completeCheckoutSessionFactory({
getCheckoutSession: getCheckoutSessionFactory({ db }),
updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({
db
}),
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({
db
}),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
getSubscriptionData: getStripeSubscriptionDataFactory({
getStripeClient
}),
emitEvent: emit
})
return completeCheckout({
sessionId: session.id,
subscriptionId
})
} catch (e) {
if (e instanceof WorkspaceAlreadyPaidError) {
// ignore the request, this is prob a replay from stripe
logger.info('Workspace is already paid, ignoring')
} else {
throw e
}
}
},
{
logger,
name: 'completeCheckoutSession',
description:
'Payment succeeded or Stripe session completed, and payment was paid',
transaction: true
}
)
break
case 'unpaid':
// if payment fails, we delete the failed session
await withOperationLogging(
async () =>
await deleteCheckoutSessionFactory({ db })({
checkoutSessionId: event.data.object.id
}),
{
logger,
operationName: 'deleteCheckoutSession',
operationDescription:
'Payment succeeded or Stripe session completed, but payment was not made'
}
)
break
}
break
case 'checkout.session.expired':
await withOperationLogging(
async () =>
await deleteCheckoutSessionFactory({ db })({
checkoutSessionId: event.data.object.id
}),
{
logger,
operationName: 'deleteCheckoutSession',
operationDescription:
'Checkout session expired, attempting to delete checkout session'
}
)
break
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await withOperationLogging(
async () =>
await handleSubscriptionUpdateFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
getWorkspaceSubscriptionBySubscriptionId:
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }),
emitEvent: getEventBus().emit
})({ subscriptionData: parseSubscriptionData(event.data.object), logger }),
{
logger,
operationName: 'handleSubscriptionUpdate',
operationDescription:
'Subscription was updated or deleted; now handling the subscription update'
}
)
break
case 'invoice.created':
const subscriptionData = await getSubscriptionFromEventFactory({
getStripeClient
})(event)
if (!subscriptionData) break
await withOperationLogging(
async () =>
await handleSubscriptionUpdateFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
getWorkspaceSubscriptionBySubscriptionId:
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }),
emitEvent: getEventBus().emit
})({ subscriptionData, logger }),
{
logger,
operationName: 'handleSubscriptionUpdate',
operationDescription:
'Invoice was created; now handling the subscription update'
}
)
break
default:
break
}
res.status(200).send('ok')
})
return router
}
const getSubscriptionFromEventFactory =
({ getStripeClient }: { getStripeClient: GetStripeClient }) =>
async (event: Stripe.InvoiceCreatedEvent): Promise<SubscriptionData | null> => {
const subscription = event.data.object.subscription
if (!subscription) {
return null
}
if (typeof subscription === 'string') {
return await getStripeSubscriptionDataFactory({ getStripeClient })({
subscriptionId: subscription
})
}
return parseSubscriptionData(subscription)
}