Files
speckle-server/packages/server/modules/gatekeeper/clients/stripe.ts
T
Gergő Jedlicska bf80347abf gergo/web 2664 workspace backend powered metrics (#3985)
* feat(workspaces): delete workspace emit event

* feat(workspaces): move workspace group metrics to the backend

* Removed FE mixpanel group update

* Remove fragment

* test(gatekeeper): add unittest to new gatekeeper service

---------

Co-authored-by: Mike Tasset <mike.tasset@gmail.com>
2025-02-17 09:50:16 +01:00

209 lines
6.4 KiB
TypeScript

/* eslint-disable camelcase */
import {
CreateCheckoutSession,
GetSubscriptionData,
ReconcileSubscriptionData,
SubscriptionData
} from '@/modules/gatekeeper/domain/billing'
import {
WorkspacePlanBillingIntervals,
WorkspacePricingPlans
} from '@/modules/gatekeeperCore/domain/billing'
import { EnvironmentResourceError, LogicError } from '@/modules/shared/errors'
import { Stripe } from 'stripe'
type GetWorkspacePlanPrice = (args: {
workspacePlan: WorkspacePricingPlans
billingInterval: WorkspacePlanBillingIntervals
}) => string
const getResultUrl = ({
frontendOrigin,
workspaceId,
workspaceSlug
}: {
frontendOrigin: string
workspaceSlug: string
workspaceId: string
}) => new URL(`${frontendOrigin}/workspaces/${workspaceSlug}?workspace=${workspaceId}`)
export const createCheckoutSessionFactory =
({
stripe,
frontendOrigin,
getWorkspacePlanPrice
}: {
stripe: Stripe
frontendOrigin: string
getWorkspacePlanPrice: GetWorkspacePlanPrice
}): CreateCheckoutSession =>
async ({
seatCount,
guestCount,
workspacePlan,
billingInterval,
workspaceSlug,
workspaceId,
isCreateFlow
}) => {
const resultUrl = getResultUrl({ frontendOrigin, workspaceId, workspaceSlug })
const price = getWorkspacePlanPrice({ billingInterval, workspacePlan })
const costLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [
{ price, quantity: seatCount }
]
if (guestCount > 0)
costLineItems.push({
price: getWorkspacePlanPrice({
workspacePlan: 'guest',
billingInterval
}),
quantity: guestCount
})
const cancel_url = isCreateFlow
? `${frontendOrigin}/workspaces/create?workspaceId=${workspaceId}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}`
: `${resultUrl.toString()}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}`
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: costLineItems,
success_url: `${resultUrl.toString()}&payment_status=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url
})
if (!session.url)
throw new EnvironmentResourceError('Failed to create an active checkout session')
return {
id: session.id,
url: session.url,
billingInterval,
workspacePlan,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
paymentStatus: 'unpaid'
}
}
export const createCustomerPortalUrlFactory =
({
stripe,
frontendOrigin
}: // getWorkspacePlanPrice
{
stripe: Stripe
frontendOrigin: string
// getWorkspacePlanPrice: GetWorkspacePlanPrice
}) =>
async ({
workspaceId,
workspaceSlug,
customerId
}: {
customerId: string
workspaceId: string
workspaceSlug: string
}): Promise<string> => {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: getResultUrl({
frontendOrigin,
workspaceId,
workspaceSlug
}).toString()
})
return session.url
}
export const getSubscriptionDataFactory =
({
stripe
}: // getWorkspacePlanPrice
{
stripe: Stripe
// getWorkspacePlanPrice: GetWorkspacePlanPrice
}): GetSubscriptionData =>
async ({ subscriptionId }) => {
const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId)
return parseSubscriptionData(stripeSubscription)
}
export const parseSubscriptionData = (
stripeSubscription: Stripe.Subscription
): SubscriptionData => {
const subscriptionData = {
customerId:
typeof stripeSubscription.customer === 'string'
? stripeSubscription.customer
: stripeSubscription.customer.id,
subscriptionId: stripeSubscription.id,
status: stripeSubscription.status,
cancelAt: stripeSubscription.cancel_at
? new Date(stripeSubscription.cancel_at * 1000)
: null,
products: stripeSubscription.items.data.map((subscriptionItem) => {
const productId =
typeof subscriptionItem.price.product === 'string'
? subscriptionItem.price.product
: subscriptionItem.price.product.id
const quantity = subscriptionItem.quantity
if (!quantity)
throw new LogicError(
'invalid subscription, we do not support products without quantities'
)
return {
priceId: subscriptionItem.price.id,
productId,
quantity,
subscriptionItemId: subscriptionItem.id
}
})
}
return subscriptionData
}
// this should be a reconcile subscriptions, we keep an accurate state in the DB
// on each change, we're reconciling that state to stripe
export const reconcileWorkspaceSubscriptionFactory =
({ stripe }: { stripe: Stripe }): ReconcileSubscriptionData =>
async ({ subscriptionData, applyProrotation }) => {
const existingSubscriptionState = await getSubscriptionDataFactory({ stripe })({
subscriptionId: subscriptionData.subscriptionId
})
const items: Stripe.SubscriptionUpdateParams.Item[] = []
for (const product of subscriptionData.products) {
const existingProduct = existingSubscriptionState.products.find(
(p) => p.productId === product.productId
)
// we're adding a new product to the sub
if (!existingProduct) {
items.push({ quantity: product.quantity, price: product.priceId })
// we're moving a product to a new price for ie upgrading to a yearly plan
} else if (existingProduct.priceId !== product.priceId) {
items.push({ quantity: product.quantity, price: product.priceId })
items.push({ id: existingProduct.subscriptionItemId, deleted: true })
} else {
items.push({
quantity: product.quantity,
id: existingProduct.subscriptionItemId
})
}
}
// remove products from the sub
const productIds = subscriptionData.products.map((p) => p.productId)
const removedProducts = existingSubscriptionState.products.filter(
(p) => !productIds.includes(p.productId)
)
for (const removedProduct of removedProducts) {
items.push({ id: removedProduct.subscriptionItemId, deleted: true })
}
// workspaceSubscription.subscriptionData.products.
// const item = workspaceSubscription.subscriptionData.products.find(p => p.)
await stripe.subscriptions.update(subscriptionData.subscriptionId, {
items,
proration_behavior: applyProrotation ? 'create_prorations' : 'none'
})
}