bde148f286
* 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
265 lines
9.7 KiB
TypeScript
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)
|
|
}
|