import { GetWorkspacePlan, GetWorkspacePlanPriceId, GetWorkspacePlanProductId, GetWorkspaceSubscription, GetWorkspaceSubscriptionBySubscriptionId, ReconcileSubscriptionData, SubscriptionData, SubscriptionDataInput, UpsertPaidWorkspacePlan, UpsertWorkspaceSubscription, WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing' import { WorkspacePlanMismatchError, WorkspacePlanNotFoundError, WorkspaceSubscriptionNotFoundError } from '@/modules/gatekeeper/errors/billing' import { PaidWorkspacePlans, PaidWorkspacePlanStatuses, throwUncoveredError } from '@speckle/shared' import { cloneDeep } from 'lodash' import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations' export const handleSubscriptionUpdateFactory = ({ upsertPaidWorkspacePlan, getWorkspacePlan, getWorkspaceSubscriptionBySubscriptionId, upsertWorkspaceSubscription }: { getWorkspacePlan: GetWorkspacePlan upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan getWorkspaceSubscriptionBySubscriptionId: GetWorkspaceSubscriptionBySubscriptionId upsertWorkspaceSubscription: UpsertWorkspaceSubscription }) => async ({ subscriptionData }: { subscriptionData: SubscriptionData }) => { // we're only handling marking the sub scheduled for cancelation right now const subscription = await getWorkspaceSubscriptionBySubscriptionId({ subscriptionId: subscriptionData.subscriptionId }) if (!subscription) { if (subscriptionData.status === 'incomplete') { // the checkout was not completed, so not finding a matching workspace subscription is expected return } throw new WorkspaceSubscriptionNotFoundError() } const workspacePlan = await getWorkspacePlan({ workspaceId: subscription.workspaceId }) if (!workspacePlan) throw new WorkspacePlanNotFoundError() let status: PaidWorkspacePlanStatuses | undefined = undefined if ( subscriptionData.status === 'active' && subscriptionData.cancelAt && subscriptionData.cancelAt > new Date() ) { status = 'cancelationScheduled' } else if ( subscriptionData.status === 'active' && subscriptionData.cancelAt === null ) { status = 'valid' } else if (subscriptionData.status === 'past_due') { status = 'paymentFailed' } else if (subscriptionData.status === 'canceled') { status = 'canceled' } if (status) { switch (workspacePlan.name) { case 'team': case 'teamUnlimited': case 'pro': case 'proUnlimited': break case 'unlimited': case 'academia': case 'proUnlimitedInvoiced': case 'teamUnlimitedInvoiced': case 'free': throw new WorkspacePlanMismatchError() default: throwUncoveredError(workspacePlan) } await upsertPaidWorkspacePlan({ workspacePlan: { ...workspacePlan, status } }) // if there is a status in the sub, we recognize, we need to update our state await upsertWorkspaceSubscription({ workspaceSubscription: { ...subscription, updatedAt: new Date(), subscriptionData } }) } } export const addWorkspaceSubscriptionSeatIfNeededFactory = ({ getWorkspacePlan, getWorkspaceSubscription, getWorkspacePlanProductId, getWorkspacePlanPriceId, reconcileSubscriptionData, countSeatsByTypeInWorkspace }: { getWorkspacePlan: GetWorkspacePlan getWorkspaceSubscription: GetWorkspaceSubscription getWorkspacePlanProductId: GetWorkspacePlanProductId getWorkspacePlanPriceId: GetWorkspacePlanPriceId reconcileSubscriptionData: ReconcileSubscriptionData countSeatsByTypeInWorkspace: CountSeatsByTypeInWorkspace }) => async ({ workspaceId, seatType }: { workspaceId: string seatType: WorkspaceSeatType }) => { const workspacePlan = await getWorkspacePlan({ workspaceId }) // if (!workspacePlan) throw new WorkspacePlanNotFoundError() if (!workspacePlan) return const workspaceSubscription = await getWorkspaceSubscription({ workspaceId }) if (!workspaceSubscription) return // if (!workspaceSubscription) throw new WorkspaceSubscriptionNotFoundError() switch (workspacePlan.name) { case 'team': case 'teamUnlimited': case 'pro': case 'proUnlimited': // If viewer seat type, we don't need to do anything if (seatType === WorkspaceSeatType.Viewer) { return } else { break } case 'unlimited': case 'academia': case 'proUnlimitedInvoiced': case 'teamUnlimitedInvoiced': case 'free': throw new WorkspacePlanMismatchError() default: throwUncoveredError(workspacePlan) } if (workspacePlan.status === 'canceled') return // New logic, only based on seat types const productAmount = await countSeatsByTypeInWorkspace({ workspaceId, type: seatType }) const productId = getWorkspacePlanProductId({ workspacePlan: workspacePlan.name }) const priceId = getWorkspacePlanPriceId({ workspacePlan: workspacePlan.name, billingInterval: workspaceSubscription.billingInterval, currency: workspaceSubscription.currency }) const subscriptionData: SubscriptionDataInput = cloneDeep( workspaceSubscription.subscriptionData ) const currentPlanProduct = subscriptionData.products.find( (product) => product.productId === productId ) if (!currentPlanProduct) { subscriptionData.products.push({ productId, priceId, quantity: productAmount }) } else { // if there is enough seats, we do not have to do anything if (currentPlanProduct.quantity >= productAmount) return currentPlanProduct.quantity = productAmount } await reconcileSubscriptionData({ subscriptionData, prorationBehavior: 'always_invoice' }) } export const getTotalSeatsCountByPlanFactory = ({ getWorkspacePlanProductId }: { getWorkspacePlanProductId: GetWorkspacePlanProductId }) => ({ workspacePlan, subscriptionData }: { workspacePlan: PaidWorkspacePlans subscriptionData: Pick }) => { const productId = getWorkspacePlanProductId({ workspacePlan }) const product = subscriptionData.products.find( (product) => product.productId === productId ) return product?.quantity ?? 0 }