feat(activity): added user info to checkout_subscription and subscription upgrade (#4967)
* feat: added userId to checkout_subscription * feat: add update intent to subscription
This commit is contained in:
committed by
GitHub
parent
2d8cab7772
commit
51d6a8dd67
@@ -23,6 +23,7 @@ export const createCheckoutSessionFactory =
|
||||
billingInterval,
|
||||
workspaceSlug,
|
||||
workspaceId,
|
||||
userId,
|
||||
isCreateFlow,
|
||||
currency
|
||||
}) => {
|
||||
@@ -53,6 +54,7 @@ export const createCheckoutSessionFactory =
|
||||
billingInterval,
|
||||
workspacePlan,
|
||||
workspaceId,
|
||||
userId,
|
||||
currency,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -60,6 +60,7 @@ export type SessionPaymentStatus = 'paid' | 'unpaid'
|
||||
export type CheckoutSession = SessionInput & {
|
||||
url: string
|
||||
workspaceId: string
|
||||
userId: string
|
||||
workspacePlan: PaidWorkspacePlans
|
||||
paymentStatus: SessionPaymentStatus
|
||||
billingInterval: WorkspacePlanBillingIntervals
|
||||
@@ -91,6 +92,7 @@ export type UpdateCheckoutSessionStatus = (args: {
|
||||
|
||||
export type CreateCheckoutSession = (args: {
|
||||
workspaceId: string
|
||||
userId: string
|
||||
workspaceSlug: string
|
||||
editorsCount: number
|
||||
workspacePlan: PaidWorkspacePlans
|
||||
@@ -106,17 +108,34 @@ export type WorkspaceSubscription = {
|
||||
currentBillingCycleEnd: Date
|
||||
billingInterval: WorkspacePlanBillingIntervals
|
||||
currency: Currency
|
||||
updateIntent: SubscriptionUpdateIntent | null
|
||||
subscriptionData: SubscriptionData
|
||||
}
|
||||
|
||||
export type SubscriptionUpdateIntent = {
|
||||
userId: string
|
||||
products: SubscriptionIntentProduct[]
|
||||
planName: PaidWorkspacePlans
|
||||
} & Pick<
|
||||
WorkspaceSubscription,
|
||||
// status is not needed cause its always provided by stripe
|
||||
'currentBillingCycleEnd' | 'currency' | 'billingInterval' | 'updatedAt'
|
||||
>
|
||||
|
||||
const subscriptionProduct = z.object({
|
||||
productId: z.string(),
|
||||
subscriptionItemId: z.string(),
|
||||
subscriptionItemId: z.string(), // does not exist until billing is called with success
|
||||
priceId: z.string(),
|
||||
quantity: z.number()
|
||||
})
|
||||
|
||||
export type SubscriptionProduct = z.infer<typeof subscriptionProduct>
|
||||
|
||||
type SubscriptionIntentProduct = Pick<
|
||||
SubscriptionProduct,
|
||||
'productId' | 'priceId' | 'quantity'
|
||||
>
|
||||
|
||||
export const SubscriptionData = z.object({
|
||||
subscriptionId: z.string().min(1),
|
||||
customerId: z.string().min(1),
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
getWorkspacePlanFactory,
|
||||
getWorkspaceSubscriptionFactory,
|
||||
saveCheckoutSessionFactory,
|
||||
upsertPaidWorkspacePlanFactory,
|
||||
upsertWorkspaceSubscriptionFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization'
|
||||
@@ -41,7 +40,7 @@ import {
|
||||
WorkspaceSeatType
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import { WorkspacePaymentMethod } from '@/test/graphql/generated/graphql'
|
||||
import { LogicError } from '@/modules/shared/errors'
|
||||
import { LogicError, UnauthorizedError } from '@/modules/shared/errors'
|
||||
import { getWorkspacePlanProductPricesFactory } from '@/modules/gatekeeper/services/prices'
|
||||
import { extendLoggerComponent } from '@/observability/logging'
|
||||
import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/checkout/createCheckoutSession'
|
||||
@@ -395,12 +394,15 @@ export = FF_GATEKEEPER_MODULE_ENABLED
|
||||
const { workspaceId, workspacePlan, billingInterval, isCreateFlow } =
|
||||
args.input
|
||||
logger = logger.child({ workspaceId, workspacePlan })
|
||||
const userId = ctx.userId
|
||||
if (!userId) throw new UnauthorizedError()
|
||||
|
||||
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
|
||||
|
||||
if (!workspace) throw new WorkspaceNotFoundError()
|
||||
|
||||
await authorizeResolver(
|
||||
ctx.userId,
|
||||
userId,
|
||||
workspaceId,
|
||||
Roles.Workspace.Admin,
|
||||
ctx.resourceAccessRules
|
||||
@@ -425,6 +427,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED
|
||||
await startCheckoutSession({
|
||||
workspacePlan,
|
||||
workspaceId,
|
||||
userId,
|
||||
workspaceSlug: workspace.slug,
|
||||
isCreateFlow: isCreateFlow || false,
|
||||
billingInterval,
|
||||
@@ -442,8 +445,10 @@ export = FF_GATEKEEPER_MODULE_ENABLED
|
||||
const { workspaceId, workspacePlan, billingInterval } = args.input
|
||||
logger = logger.child({ workspaceId, workspacePlan })
|
||||
|
||||
const userId = ctx.userId
|
||||
if (!userId) throw new UnauthorizedError()
|
||||
await authorizeResolver(
|
||||
ctx.userId,
|
||||
userId,
|
||||
workspaceId,
|
||||
Roles.Workspace.Admin,
|
||||
ctx.resourceAccessRules
|
||||
@@ -461,15 +466,14 @@ export = FF_GATEKEEPER_MODULE_ENABLED
|
||||
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
|
||||
getWorkspacePlanPriceId,
|
||||
getWorkspacePlanProductId,
|
||||
upsertWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
|
||||
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({
|
||||
db
|
||||
}),
|
||||
emitEvent: getEventBus().emit
|
||||
})
|
||||
})
|
||||
await withOperationLogging(
|
||||
async () =>
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: workspacePlan, // This should not be casted and the cast will be removed once we will not support old plans anymore
|
||||
billingInterval
|
||||
|
||||
@@ -43,7 +43,8 @@ const WorkspaceSubscriptions = buildTableHelper('workspace_subscriptions', [
|
||||
'currentBillingCycleEnd',
|
||||
'billingInterval',
|
||||
'subscriptionData',
|
||||
'currency'
|
||||
'currency',
|
||||
'updateIntent'
|
||||
])
|
||||
|
||||
const tables = {
|
||||
|
||||
@@ -203,7 +203,7 @@ export const getBillingRouter = (): Router => {
|
||||
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
|
||||
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }),
|
||||
emitEvent: getEventBus().emit
|
||||
})({ subscriptionData: parseSubscriptionData(event.data.object) }),
|
||||
})({ subscriptionData: parseSubscriptionData(event.data.object), logger }),
|
||||
{
|
||||
logger,
|
||||
operationName: 'handleSubscriptionUpdate',
|
||||
@@ -226,7 +226,7 @@ export const getBillingRouter = (): Router => {
|
||||
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
|
||||
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db }),
|
||||
emitEvent: getEventBus().emit
|
||||
})({ subscriptionData }),
|
||||
})({ subscriptionData, logger }),
|
||||
{
|
||||
logger,
|
||||
operationName: 'handleSubscriptionUpdate',
|
||||
|
||||
@@ -82,6 +82,7 @@ export const completeCheckoutSessionFactory =
|
||||
workspaceId: checkoutSession.workspaceId,
|
||||
billingInterval: checkoutSession.billingInterval,
|
||||
currency: checkoutSession.currency,
|
||||
updateIntent: null,
|
||||
subscriptionData
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const startCheckoutSessionFactory =
|
||||
}) =>
|
||||
async ({
|
||||
workspaceId,
|
||||
userId,
|
||||
workspaceSlug,
|
||||
workspacePlan,
|
||||
billingInterval,
|
||||
@@ -48,6 +49,7 @@ export const startCheckoutSessionFactory =
|
||||
currency
|
||||
}: {
|
||||
workspaceId: string
|
||||
userId: string
|
||||
workspaceSlug: string
|
||||
workspacePlan: PaidWorkspacePlans
|
||||
billingInterval: WorkspacePlanBillingIntervals
|
||||
@@ -132,6 +134,7 @@ export const startCheckoutSessionFactory =
|
||||
|
||||
const checkoutSession = await createCheckoutSession({
|
||||
workspaceId,
|
||||
userId,
|
||||
workspaceSlug,
|
||||
billingInterval,
|
||||
workspacePlan,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { cloneDeep } from 'lodash'
|
||||
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
|
||||
import { EventBusEmit } from '@/modules/shared/services/eventBus'
|
||||
import { GatekeeperEvents } from '@/modules/gatekeeperCore/domain/events'
|
||||
import { Logger } from '@/observability/logging'
|
||||
|
||||
export const handleSubscriptionUpdateFactory =
|
||||
({
|
||||
@@ -42,8 +43,14 @@ export const handleSubscriptionUpdateFactory =
|
||||
upsertWorkspaceSubscription: UpsertWorkspaceSubscription
|
||||
emitEvent: EventBusEmit
|
||||
}) =>
|
||||
async ({ subscriptionData }: { subscriptionData: SubscriptionData }) => {
|
||||
// we're only handling marking the sub scheduled for cancelation right now
|
||||
async ({
|
||||
subscriptionData,
|
||||
logger
|
||||
}: {
|
||||
subscriptionData: SubscriptionData
|
||||
logger: Logger
|
||||
}) => {
|
||||
// we're only handling marking the sub scheduled for cancellation right now
|
||||
const subscription = await getWorkspaceSubscriptionBySubscriptionId({
|
||||
subscriptionId: subscriptionData.subscriptionId
|
||||
})
|
||||
@@ -79,52 +86,138 @@ export const handleSubscriptionUpdateFactory =
|
||||
status = 'canceled'
|
||||
}
|
||||
|
||||
if (status) {
|
||||
switch (workspacePlan.name) {
|
||||
case WorkspacePlans.Team:
|
||||
case WorkspacePlans.TeamUnlimited:
|
||||
case WorkspacePlans.Pro:
|
||||
case WorkspacePlans.ProUnlimited:
|
||||
break
|
||||
case WorkspacePlans.Unlimited:
|
||||
case WorkspacePlans.Academia:
|
||||
case WorkspacePlans.ProUnlimitedInvoiced:
|
||||
case WorkspacePlans.TeamUnlimitedInvoiced:
|
||||
case WorkspacePlans.Free:
|
||||
case WorkspacePlans.Enterprise:
|
||||
throw new WorkspacePlanMismatchError()
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
if (!status) {
|
||||
logger.info({ workspaceId: subscription.workspaceId }, 'Nothing to update')
|
||||
return
|
||||
}
|
||||
|
||||
const newWorkspacePlan = { ...workspacePlan, status }
|
||||
await upsertPaidWorkspacePlan({
|
||||
workspacePlan: newWorkspacePlan
|
||||
})
|
||||
// if there is a status in the sub, we recognize, we need to update our state
|
||||
await upsertWorkspaceSubscription({
|
||||
workspaceSubscription: {
|
||||
...subscription,
|
||||
updatedAt: new Date(),
|
||||
subscriptionData
|
||||
}
|
||||
})
|
||||
switch (workspacePlan.name) {
|
||||
case WorkspacePlans.Team:
|
||||
case WorkspacePlans.TeamUnlimited:
|
||||
case WorkspacePlans.Pro:
|
||||
case WorkspacePlans.ProUnlimited:
|
||||
break
|
||||
case WorkspacePlans.Unlimited:
|
||||
case WorkspacePlans.Academia:
|
||||
case WorkspacePlans.ProUnlimitedInvoiced:
|
||||
case WorkspacePlans.TeamUnlimitedInvoiced:
|
||||
case WorkspacePlans.Free:
|
||||
case WorkspacePlans.Enterprise:
|
||||
throw new WorkspacePlanMismatchError()
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
|
||||
await emitEvent({
|
||||
eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated,
|
||||
payload: {
|
||||
workspacePlan: newWorkspacePlan,
|
||||
subscription: {
|
||||
totalEditorSeats: calculateSubscriptionSeats({ subscriptionData })
|
||||
const updateIntent = subscription.updateIntent
|
||||
let planName
|
||||
let billingInterval
|
||||
let currentBillingCycleEnd
|
||||
let currency
|
||||
let updatedAt
|
||||
|
||||
if (updateIntent) {
|
||||
// this is the branch where a user intents to upgrade his subscription
|
||||
// if stripe comes back with a status, and we have a update intent in the subscription
|
||||
// we're assuming that the target that the user wants to upgrade was written in the update intent
|
||||
|
||||
planName = updateIntent.planName
|
||||
updatedAt = updateIntent.updatedAt
|
||||
currency = updateIntent.currency
|
||||
billingInterval = updateIntent.billingInterval
|
||||
currentBillingCycleEnd = updateIntent.currentBillingCycleEnd
|
||||
|
||||
const productsAreEquivalent = (
|
||||
a: Array<{ priceId: string; quantity: number }>,
|
||||
b: Array<{ priceId: string; quantity: number }>
|
||||
) =>
|
||||
a.every((item) => {
|
||||
return !!b.find(
|
||||
(bi) => bi.priceId === item.priceId && bi.quantity === item.quantity
|
||||
)
|
||||
})
|
||||
|
||||
if (!productsAreEquivalent(updateIntent.products, subscriptionData.products)) {
|
||||
logger.error(
|
||||
{
|
||||
event: subscriptionData.products,
|
||||
target: updateIntent.products,
|
||||
workspaceId: subscription.workspaceId,
|
||||
targetPlanName: planName,
|
||||
planName: workspacePlan.name
|
||||
},
|
||||
previousSubscription: {
|
||||
totalEditorSeats: calculateSubscriptionSeats({
|
||||
subscriptionData: subscription.subscriptionData
|
||||
})
|
||||
}
|
||||
'Fatal: Stripe product ID mismatch with subscription update intent'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
planName = workspacePlan.name
|
||||
billingInterval = subscription.billingInterval
|
||||
currentBillingCycleEnd = subscription.currentBillingCycleEnd
|
||||
currency = subscription.currency
|
||||
updatedAt = new Date()
|
||||
// Stripe can have many cases were we receive an event
|
||||
// - subscription cancellation schedules
|
||||
// - subscription cancellations
|
||||
// - payment failures
|
||||
// - duplicated events
|
||||
// - manual changes in the dashboard
|
||||
// - ...
|
||||
// at the moment, we are assuming this new status and update the status as given by stripe
|
||||
// take into account that manual subscription updates in stripe dashboard can lead into
|
||||
// errors, as changing quantity in the products may work, but changing product ids wont update
|
||||
// the workspace plan and will result in errors
|
||||
}
|
||||
|
||||
const newWorkspacePlan = {
|
||||
...workspacePlan,
|
||||
status,
|
||||
name: planName,
|
||||
updatedAt
|
||||
}
|
||||
|
||||
const newSubscription = {
|
||||
...subscription,
|
||||
currency,
|
||||
currentBillingCycleEnd,
|
||||
billingInterval,
|
||||
updateIntent: null,
|
||||
updatedAt,
|
||||
subscriptionData
|
||||
}
|
||||
|
||||
await upsertPaidWorkspacePlan({
|
||||
workspacePlan: newWorkspacePlan
|
||||
})
|
||||
await upsertWorkspaceSubscription({
|
||||
workspaceSubscription: newSubscription
|
||||
})
|
||||
|
||||
if (
|
||||
workspacePlan.name !== newWorkspacePlan.name ||
|
||||
workspacePlan.status !== newWorkspacePlan.status
|
||||
) {
|
||||
await emitEvent({
|
||||
eventName: GatekeeperEvents.WorkspacePlanUpdated,
|
||||
payload: {
|
||||
previousPlan: workspacePlan,
|
||||
workspacePlan: newWorkspacePlan
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await emitEvent({
|
||||
eventName: GatekeeperEvents.WorkspaceSubscriptionUpdated,
|
||||
payload: {
|
||||
workspacePlan: newWorkspacePlan,
|
||||
subscription: {
|
||||
totalEditorSeats: calculateSubscriptionSeats({ subscriptionData })
|
||||
},
|
||||
previousSubscription: {
|
||||
totalEditorSeats: calculateSubscriptionSeats({
|
||||
subscriptionData: subscription.subscriptionData
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const addWorkspaceSubscriptionSeatIfNeededFactory =
|
||||
|
||||
+20
-40
@@ -5,7 +5,6 @@ import {
|
||||
GetWorkspaceSubscription,
|
||||
ReconcileSubscriptionData,
|
||||
SubscriptionDataInput,
|
||||
UpsertPaidWorkspacePlan,
|
||||
UpsertWorkspaceSubscription,
|
||||
WorkspaceSeatType
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
@@ -23,7 +22,6 @@ import { isPaidPlanType } from '@/modules/gatekeeper/helpers/plans'
|
||||
import { calculateNewBillingCycleEnd } from '@/modules/gatekeeper/services/subscriptions/calculateNewBillingCycleEnd'
|
||||
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
|
||||
import { isUpgradeWorkspacePlanValid } from '@/modules/gatekeeper/services/upgrades'
|
||||
import { EventBusEmit } from '@/modules/shared/services/eventBus'
|
||||
import {
|
||||
PaidWorkspacePlans,
|
||||
throwUncoveredError,
|
||||
@@ -40,9 +38,7 @@ export const upgradeWorkspaceSubscriptionFactory =
|
||||
getWorkspaceSubscription,
|
||||
reconcileSubscriptionData,
|
||||
updateWorkspaceSubscription,
|
||||
countSeatsByTypeInWorkspace,
|
||||
upsertWorkspacePlan,
|
||||
emitEvent
|
||||
countSeatsByTypeInWorkspace
|
||||
}: {
|
||||
getWorkspacePlan: GetWorkspacePlan
|
||||
getWorkspacePlanProductId: GetWorkspacePlanProductId
|
||||
@@ -51,14 +47,14 @@ export const upgradeWorkspaceSubscriptionFactory =
|
||||
reconcileSubscriptionData: ReconcileSubscriptionData
|
||||
updateWorkspaceSubscription: UpsertWorkspaceSubscription
|
||||
countSeatsByTypeInWorkspace: CountSeatsByTypeInWorkspace
|
||||
upsertWorkspacePlan: UpsertPaidWorkspacePlan
|
||||
emitEvent: EventBusEmit
|
||||
}) =>
|
||||
async ({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan,
|
||||
billingInterval
|
||||
}: {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
targetPlan: PaidWorkspacePlans
|
||||
billingInterval: WorkspacePlanBillingIntervals
|
||||
@@ -139,9 +135,7 @@ export const upgradeWorkspaceSubscriptionFactory =
|
||||
default:
|
||||
throwUncoveredError(billingInterval)
|
||||
}
|
||||
// must update the billing interval to the new one
|
||||
workspaceSubscription.billingInterval = billingInterval
|
||||
workspaceSubscription.currentBillingCycleEnd = calculateNewBillingCycleEnd({
|
||||
const currentBillingCycleEnd = calculateNewBillingCycleEnd({
|
||||
workspaceSubscription
|
||||
})
|
||||
|
||||
@@ -160,8 +154,6 @@ export const upgradeWorkspaceSubscriptionFactory =
|
||||
type: WorkspaceSeatType.Editor
|
||||
})
|
||||
|
||||
workspaceSubscription.updatedAt = new Date()
|
||||
|
||||
// set current plan seat count to 0
|
||||
mutateSubscriptionDataWithNewValidSeatNumbers({
|
||||
seatCount: 0,
|
||||
@@ -170,43 +162,31 @@ export const upgradeWorkspaceSubscriptionFactory =
|
||||
workspacePlan: workspacePlan.name
|
||||
})
|
||||
|
||||
// set target plan seat count to current seat count
|
||||
subscriptionData.products.push({
|
||||
// set target plan and subscription
|
||||
const newProduct = {
|
||||
quantity: editorsCount,
|
||||
productId: getWorkspacePlanProductId({ workspacePlan: targetPlan }),
|
||||
priceId: getWorkspacePlanPriceId({
|
||||
workspacePlan: targetPlan,
|
||||
billingInterval,
|
||||
currency: workspaceSubscription.currency
|
||||
}),
|
||||
subscriptionItemId: undefined
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
workspaceSubscription.updateIntent = {
|
||||
userId,
|
||||
planName: targetPlan,
|
||||
billingInterval,
|
||||
currentBillingCycleEnd,
|
||||
currency: workspaceSubscription.currency,
|
||||
updatedAt: new Date(),
|
||||
products: [newProduct]
|
||||
}
|
||||
await updateWorkspaceSubscription({ workspaceSubscription })
|
||||
|
||||
subscriptionData.products.push(newProduct)
|
||||
await reconcileSubscriptionData({
|
||||
subscriptionData,
|
||||
prorationBehavior: 'always_invoice'
|
||||
})
|
||||
await upsertWorkspacePlan({
|
||||
workspacePlan: {
|
||||
status: workspacePlan.status,
|
||||
workspaceId,
|
||||
name: targetPlan,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
await updateWorkspaceSubscription({ workspaceSubscription })
|
||||
await emitEvent({
|
||||
eventName: 'gatekeeper.workspace-plan-updated',
|
||||
payload: {
|
||||
workspacePlan: {
|
||||
workspaceId,
|
||||
status: workspacePlan.status,
|
||||
name: targetPlan
|
||||
},
|
||||
...(workspacePlan && {
|
||||
previousPlan: { name: workspacePlan.name }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const createTestWorkspaceSubscription = (
|
||||
updatedAt: new Date(),
|
||||
currentBillingCycleEnd: new Date(),
|
||||
subscriptionData: createTestSubscriptionData(),
|
||||
updateIntent: null,
|
||||
currency: 'usd',
|
||||
workspaceId: cryptoRandomString({ length: 10 })
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const buildTestWorkspaceSubscription = (
|
||||
updatedAt: new Date(),
|
||||
currentBillingCycleEnd: new Date(),
|
||||
billingInterval: 'monthly',
|
||||
updateIntent: {},
|
||||
currency: 'usd',
|
||||
subscriptionData: buildTestSubscriptionData()
|
||||
},
|
||||
|
||||
@@ -255,6 +255,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
expect(storedSession).to.be.null
|
||||
const checkoutSession = {
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
userId: cryptoRandomString({ length: 10 }),
|
||||
billingInterval: 'monthly',
|
||||
createdAt: new Date(),
|
||||
paymentStatus: 'unpaid',
|
||||
@@ -279,6 +280,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
const workspaceId = workspace.id
|
||||
const checkoutSession = {
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
userId: cryptoRandomString({ length: 10 }),
|
||||
billingInterval: 'monthly',
|
||||
createdAt: new Date(),
|
||||
paymentStatus: 'unpaid',
|
||||
@@ -312,6 +314,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
const workspaceId = workspace.id
|
||||
const checkoutSession = {
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
userId: cryptoRandomString({ length: 10 }),
|
||||
billingInterval: 'monthly',
|
||||
createdAt: new Date(),
|
||||
paymentStatus: 'unpaid',
|
||||
@@ -354,6 +357,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
const workspaceId = workspace.id
|
||||
const checkoutSession = {
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
userId: cryptoRandomString({ length: 10 }),
|
||||
billingInterval: 'monthly',
|
||||
createdAt: new Date(),
|
||||
paymentStatus: 'unpaid',
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('Workspaces Billing', () => {
|
||||
currentBillingCycleEnd: dayjs().add(1, 'month').toDate(),
|
||||
currency: 'usd',
|
||||
billingInterval: 'monthly',
|
||||
updateIntent: null,
|
||||
subscriptionData: {
|
||||
subscriptionId: cryptoRandomString({ length: 10 }),
|
||||
customerId: cryptoRandomString({ length: 10 }),
|
||||
@@ -187,6 +188,7 @@ describe('Workspaces Billing', () => {
|
||||
updatedAt: new Date(),
|
||||
currentBillingCycleEnd: dayjs().add(1, 'month').toDate(),
|
||||
currency: 'usd',
|
||||
updateIntent: null,
|
||||
billingInterval: 'monthly',
|
||||
subscriptionData: {
|
||||
subscriptionId: cryptoRandomString({ length: 10 }),
|
||||
|
||||
@@ -54,12 +54,14 @@ describe('checkout @gatekeeper', () => {
|
||||
it('throws for already paid checkout sessions', async () => {
|
||||
const sessionId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
|
||||
const err = await expectToThrow(async () => {
|
||||
await completeCheckoutSessionFactory({
|
||||
getCheckoutSession: async () => ({
|
||||
billingInterval: 'monthly',
|
||||
id: sessionId,
|
||||
userId,
|
||||
paymentStatus: 'paid',
|
||||
url: 'https://example.com',
|
||||
workspaceId: cryptoRandomString({ length: 10 }),
|
||||
@@ -93,10 +95,12 @@ describe('checkout @gatekeeper', () => {
|
||||
const sessionId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionId = cryptoRandomString({ length: 10 })
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
|
||||
const storedCheckoutSession: CheckoutSession = {
|
||||
billingInterval,
|
||||
id: sessionId,
|
||||
userId,
|
||||
paymentStatus: 'unpaid',
|
||||
url: 'https://example.com',
|
||||
workspaceId,
|
||||
@@ -199,6 +203,7 @@ describe('checkout @gatekeeper', () => {
|
||||
describe('startCheckoutSessionFactory creates a function, that', () => {
|
||||
it('does not allow checkout if workspace plan does not exists', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const err = await expectToThrow(() =>
|
||||
startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => null,
|
||||
@@ -219,6 +224,7 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
})({
|
||||
workspaceId,
|
||||
userId,
|
||||
billingInterval: 'monthly',
|
||||
workspacePlan: 'pro',
|
||||
workspaceSlug: cryptoRandomString({ length: 10 }),
|
||||
@@ -230,6 +236,7 @@ describe('checkout @gatekeeper', () => {
|
||||
})
|
||||
it('does not allow checkout for paid workspace plans, that is in a valid state', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const err = await expectToThrow(() =>
|
||||
startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
@@ -256,6 +263,7 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
})({
|
||||
workspaceId,
|
||||
userId,
|
||||
billingInterval: 'monthly',
|
||||
workspacePlan: 'pro',
|
||||
workspaceSlug: cryptoRandomString({ length: 10 }),
|
||||
@@ -267,6 +275,7 @@ describe('checkout @gatekeeper', () => {
|
||||
})
|
||||
it('does not allow checkout for workspace plans, that is in a paymentFailed state', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const err = await expectToThrow(() =>
|
||||
startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
@@ -293,6 +302,7 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
})({
|
||||
workspaceId,
|
||||
userId,
|
||||
billingInterval: 'monthly',
|
||||
workspacePlan: 'pro',
|
||||
workspaceSlug: cryptoRandomString({ length: 10 }),
|
||||
@@ -304,6 +314,7 @@ describe('checkout @gatekeeper', () => {
|
||||
})
|
||||
it('does not allow checkout for a workspace, that already has a checkout session', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const err = await expectToThrow(() =>
|
||||
startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
@@ -319,6 +330,7 @@ describe('checkout @gatekeeper', () => {
|
||||
paymentStatus: 'unpaid',
|
||||
url: '',
|
||||
workspaceId,
|
||||
userId,
|
||||
workspacePlan: PaidWorkspacePlans.Team,
|
||||
currency: 'usd',
|
||||
createdAt: new Date(),
|
||||
@@ -338,6 +350,7 @@ describe('checkout @gatekeeper', () => {
|
||||
expect.fail()
|
||||
}
|
||||
})({
|
||||
userId,
|
||||
workspaceId,
|
||||
billingInterval: 'monthly',
|
||||
workspacePlan: 'team',
|
||||
@@ -353,11 +366,13 @@ describe('checkout @gatekeeper', () => {
|
||||
|
||||
it('creates and stores a checkout for FREE workspaces', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspacePlan: PaidWorkspacePlans = 'pro'
|
||||
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
|
||||
const checkoutSession: CheckoutSession = {
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
workspaceId,
|
||||
userId,
|
||||
workspacePlan,
|
||||
url: 'https://example.com',
|
||||
billingInterval,
|
||||
@@ -386,6 +401,7 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
})({
|
||||
workspaceId,
|
||||
userId,
|
||||
billingInterval,
|
||||
workspacePlan,
|
||||
workspaceSlug: cryptoRandomString({ length: 10 }),
|
||||
@@ -398,11 +414,13 @@ describe('checkout @gatekeeper', () => {
|
||||
|
||||
it('creates and stores a checkout for FREE workspaces even if it has an old unpaid checkout session', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspacePlan: PaidWorkspacePlans = 'team'
|
||||
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
|
||||
const checkoutSession: CheckoutSession = {
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
workspaceId,
|
||||
userId,
|
||||
workspacePlan,
|
||||
url: 'https://example.com',
|
||||
billingInterval,
|
||||
@@ -420,6 +438,7 @@ describe('checkout @gatekeeper', () => {
|
||||
currency: 'usd',
|
||||
url: 'https://example.com',
|
||||
workspaceId,
|
||||
userId,
|
||||
workspacePlan
|
||||
}
|
||||
let storedCheckoutSession: CheckoutSession | undefined = undefined
|
||||
@@ -442,6 +461,7 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
})({
|
||||
workspaceId,
|
||||
userId,
|
||||
billingInterval,
|
||||
workspacePlan,
|
||||
workspaceSlug: cryptoRandomString({ length: 10 }),
|
||||
@@ -455,6 +475,7 @@ describe('checkout @gatekeeper', () => {
|
||||
|
||||
it('does not allow checkout for FREE workspaces if there is a paid checkout session', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspacePlan: PaidWorkspacePlans = 'pro'
|
||||
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
|
||||
let existingCheckoutSession: CheckoutSession | undefined = {
|
||||
@@ -466,6 +487,7 @@ describe('checkout @gatekeeper', () => {
|
||||
url: 'https://example.com',
|
||||
currency: 'usd',
|
||||
workspaceId,
|
||||
userId,
|
||||
workspacePlan
|
||||
}
|
||||
const err = await expectToThrow(async () => {
|
||||
@@ -487,6 +509,7 @@ describe('checkout @gatekeeper', () => {
|
||||
},
|
||||
saveCheckoutSession: async () => {}
|
||||
})({
|
||||
userId,
|
||||
workspaceId,
|
||||
billingInterval,
|
||||
workspacePlan,
|
||||
@@ -500,11 +523,13 @@ describe('checkout @gatekeeper', () => {
|
||||
|
||||
it('creates and stores a checkout for CANCELED workspaces', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspacePlan: PaidWorkspacePlans = 'pro'
|
||||
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
|
||||
const checkoutSession: CheckoutSession = {
|
||||
id: cryptoRandomString({ length: 10 }),
|
||||
workspaceId,
|
||||
userId,
|
||||
workspacePlan,
|
||||
url: 'https://example.com',
|
||||
billingInterval,
|
||||
@@ -519,6 +544,7 @@ describe('checkout @gatekeeper', () => {
|
||||
paymentStatus: 'paid',
|
||||
url: '',
|
||||
workspaceId,
|
||||
userId,
|
||||
workspacePlan: 'team',
|
||||
currency: 'usd',
|
||||
createdAt: new Date(),
|
||||
@@ -544,6 +570,7 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
})({
|
||||
workspaceId,
|
||||
userId,
|
||||
billingInterval,
|
||||
workspacePlan,
|
||||
workspaceSlug: cryptoRandomString({ length: 10 }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
SubscriptionData,
|
||||
SubscriptionDataInput,
|
||||
SubscriptionUpdateIntent,
|
||||
WorkspaceSeatType,
|
||||
WorkspaceSubscription
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
@@ -29,6 +30,7 @@ import { omit } from 'lodash'
|
||||
import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions/upgradeWorkspaceSubscription'
|
||||
import { EventBusEmit } from '@/modules/shared/services/eventBus'
|
||||
import { GatekeeperEvents } from '@/modules/gatekeeperCore/domain/events'
|
||||
import { testLogger } from '@/observability/logging'
|
||||
|
||||
describe('subscriptions @gatekeeper', () => {
|
||||
describe('handleSubscriptionUpdateFactory creates a function, that', () => {
|
||||
@@ -49,7 +51,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
emitEvent: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})({ subscriptionData })
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
})
|
||||
expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message)
|
||||
})
|
||||
@@ -70,7 +72,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
emitEvent: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})({ subscriptionData })
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
})
|
||||
it('throws if workspacePlan is not found', async () => {
|
||||
const subscriptionData = createTestSubscriptionData()
|
||||
@@ -88,7 +90,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
emitEvent: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})({ subscriptionData })
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
})
|
||||
expect(err.message).to.equal(new WorkspacePlanNotFoundError().message)
|
||||
})
|
||||
@@ -119,7 +121,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
emitEvent: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})({ subscriptionData })
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
})
|
||||
expect(err.message).to.equal(new WorkspacePlanMismatchError().message)
|
||||
})
|
||||
@@ -160,7 +162,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
updatedPlan = workspacePlan
|
||||
},
|
||||
emitEvent
|
||||
})({ subscriptionData })
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
expect(updatedPlan!.status).to.be.equal('cancelationScheduled')
|
||||
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
|
||||
workspaceSubscription.updatedAt
|
||||
@@ -190,6 +192,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
billingInterval: 'monthly' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
updateIntent: null,
|
||||
currency: 'usd' as const,
|
||||
currentBillingCycleEnd: new Date(),
|
||||
workspaceId
|
||||
@@ -220,7 +223,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
updatedPlan = workspacePlan
|
||||
},
|
||||
emitEvent
|
||||
})({ subscriptionData })
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
expect(updatedPlan!.status).to.be.equal('valid')
|
||||
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
|
||||
workspaceSubscription.updatedAt
|
||||
@@ -239,6 +242,91 @@ describe('subscriptions @gatekeeper', () => {
|
||||
'previousSubscription.totalEditorSeats': 3
|
||||
})
|
||||
})
|
||||
it('updates the plan with the subscription update intent', async () => {
|
||||
const subscriptionData = createTestSubscriptionData({
|
||||
status: 'active',
|
||||
cancelAt: null
|
||||
})
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const now = new Date()
|
||||
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
const inOneYear = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const workspaceSubscription = {
|
||||
subscriptionData,
|
||||
billingInterval: 'monthly' as const,
|
||||
createdAt: oneMonthAgo,
|
||||
updatedAt: oneMonthAgo,
|
||||
updateIntent: {
|
||||
userId,
|
||||
planName: 'proUnlimited' as const,
|
||||
billingInterval: 'yearly' as const,
|
||||
currentBillingCycleEnd: inOneYear,
|
||||
updatedAt: now,
|
||||
currency: 'usd' as const,
|
||||
products: [
|
||||
{
|
||||
priceId: subscriptionData.products[0].priceId,
|
||||
productId: subscriptionData.products[0].productId,
|
||||
quantity: subscriptionData.products[0].quantity
|
||||
}
|
||||
]
|
||||
},
|
||||
currency: 'usd' as const,
|
||||
currentBillingCycleEnd: new Date(),
|
||||
workspaceId
|
||||
}
|
||||
|
||||
let updatedSubscription: WorkspaceSubscription | undefined = undefined
|
||||
let updatedPlan: WorkspacePlan | undefined = undefined
|
||||
let emittedEventName: string | undefined = undefined
|
||||
let emittedEventPayload: unknown = undefined
|
||||
const emitEvent: EventBusEmit = async ({ eventName, payload }) => {
|
||||
emittedEventName = eventName
|
||||
emittedEventPayload = payload
|
||||
}
|
||||
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
||||
getWorkspacePlan: async () => ({
|
||||
name: PaidWorkspacePlans.Team,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'paymentFailed'
|
||||
}),
|
||||
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
||||
updatedSubscription = workspaceSubscription
|
||||
},
|
||||
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
|
||||
updatedPlan = workspacePlan
|
||||
},
|
||||
emitEvent
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
expect(updatedPlan!.name).to.be.equal('proUnlimited')
|
||||
expect(updatedPlan!.status).to.be.equal('valid')
|
||||
expect(updatedSubscription).to.be.deep.equal({
|
||||
workspaceId,
|
||||
billingInterval: 'yearly',
|
||||
currentBillingCycleEnd: inOneYear,
|
||||
updateIntent: null,
|
||||
currency: 'usd',
|
||||
updatedAt: now,
|
||||
createdAt: oneMonthAgo,
|
||||
subscriptionData
|
||||
})
|
||||
expect(emittedEventName).to.eq('gatekeeper.workspace-subscription-updated')
|
||||
expect(emittedEventPayload).to.have.nested.include({
|
||||
'workspacePlan.status': 'valid'
|
||||
})
|
||||
expect(emittedEventPayload).to.have.nested.include({
|
||||
'subscription.totalEditorSeats': 3
|
||||
})
|
||||
expect(emittedEventPayload).to.have.nested.include({
|
||||
'previousSubscription.totalEditorSeats': 3
|
||||
})
|
||||
})
|
||||
it('sets the state to paymentFailed', async () => {
|
||||
const subscriptionData = createTestSubscriptionData({
|
||||
status: 'past_due'
|
||||
@@ -275,7 +363,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
updatedPlan = workspacePlan
|
||||
},
|
||||
emitEvent
|
||||
})({ subscriptionData })
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
expect(updatedPlan!.status).to.be.equal('paymentFailed')
|
||||
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
|
||||
workspaceSubscription.updatedAt
|
||||
@@ -304,6 +392,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
billingInterval: 'monthly' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
updateIntent: null,
|
||||
currency: 'usd' as const,
|
||||
currentBillingCycleEnd: new Date(),
|
||||
workspaceId
|
||||
@@ -334,7 +423,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
updatedPlan = workspacePlan
|
||||
},
|
||||
emitEvent
|
||||
})({ subscriptionData })
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
expect(updatedPlan!.status).to.be.equal('canceled')
|
||||
expect(updatedSubscription!.updatedAt).to.be.greaterThanOrEqual(
|
||||
workspaceSubscription.updatedAt
|
||||
@@ -385,7 +474,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
emitEvent: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})({ subscriptionData })
|
||||
})({ subscriptionData, logger: testLogger })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1072,6 +1161,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
describe('upgradeWorkspaceSubscriptionFactory creates a function, that', () => {
|
||||
it('throws WorkspacePlanNotFound if no plan can be found', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => null,
|
||||
getWorkspacePlanProductId: () => {
|
||||
@@ -1086,21 +1176,16 @@ describe('subscriptions @gatekeeper', () => {
|
||||
reconcileSubscriptionData: () => {
|
||||
expect.fail()
|
||||
},
|
||||
upsertWorkspacePlan: () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceSubscription: () => {
|
||||
expect.fail()
|
||||
},
|
||||
countSeatsByTypeInWorkspace: () => {
|
||||
expect.fail()
|
||||
},
|
||||
emitEvent: () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: 'team',
|
||||
billingInterval: 'monthly'
|
||||
@@ -1112,6 +1197,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
;(['unlimited', 'academia'] as const).forEach((plan) => {
|
||||
it(`throws WorkspaceNotPaidPlan for ${plan}`, async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
createdAt: new Date(),
|
||||
@@ -1132,21 +1218,16 @@ describe('subscriptions @gatekeeper', () => {
|
||||
reconcileSubscriptionData: () => {
|
||||
expect.fail()
|
||||
},
|
||||
upsertWorkspacePlan: () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceSubscription: () => {
|
||||
expect.fail()
|
||||
},
|
||||
countSeatsByTypeInWorkspace: () => {
|
||||
expect.fail()
|
||||
},
|
||||
emitEvent: () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: 'team',
|
||||
billingInterval: 'monthly'
|
||||
@@ -1161,6 +1242,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
(status) => {
|
||||
it(`throws WorkspaceNotPaidPlan for ${plan} on a non valid status: ${status}`, async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
workspaceId,
|
||||
@@ -1181,21 +1263,16 @@ describe('subscriptions @gatekeeper', () => {
|
||||
reconcileSubscriptionData: () => {
|
||||
expect.fail()
|
||||
},
|
||||
upsertWorkspacePlan: () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceSubscription: () => {
|
||||
expect.fail()
|
||||
},
|
||||
countSeatsByTypeInWorkspace: () => {
|
||||
expect.fail()
|
||||
},
|
||||
emitEvent: () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: 'pro',
|
||||
billingInterval: 'monthly'
|
||||
@@ -1209,6 +1286,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
it('throws WorkspaceSubscriptionNotFound', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
workspaceId,
|
||||
@@ -1229,21 +1307,16 @@ describe('subscriptions @gatekeeper', () => {
|
||||
reconcileSubscriptionData: () => {
|
||||
expect.fail()
|
||||
},
|
||||
upsertWorkspacePlan: () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceSubscription: () => {
|
||||
expect.fail()
|
||||
},
|
||||
countSeatsByTypeInWorkspace: () => {
|
||||
expect.fail()
|
||||
},
|
||||
emitEvent: () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: 'team',
|
||||
billingInterval: 'monthly'
|
||||
@@ -1255,6 +1328,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
it('throws WorkspacePlanUpgradeError for downgrading the plan', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceSubscription = createTestWorkspaceSubscription()
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
@@ -1276,21 +1350,16 @@ describe('subscriptions @gatekeeper', () => {
|
||||
reconcileSubscriptionData: () => {
|
||||
expect.fail()
|
||||
},
|
||||
upsertWorkspacePlan: () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceSubscription: () => {
|
||||
expect.fail()
|
||||
},
|
||||
countSeatsByTypeInWorkspace: () => {
|
||||
expect.fail()
|
||||
},
|
||||
emitEvent: () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: 'team',
|
||||
billingInterval: 'yearly'
|
||||
@@ -1302,6 +1371,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
it('throws WorkspacePlanUpgradeError for downgrading the billing interval', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
billingInterval: 'yearly'
|
||||
})
|
||||
@@ -1325,21 +1395,16 @@ describe('subscriptions @gatekeeper', () => {
|
||||
reconcileSubscriptionData: () => {
|
||||
expect.fail()
|
||||
},
|
||||
upsertWorkspacePlan: () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceSubscription: () => {
|
||||
expect.fail()
|
||||
},
|
||||
countSeatsByTypeInWorkspace: () => {
|
||||
expect.fail()
|
||||
},
|
||||
emitEvent: () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: 'team',
|
||||
billingInterval: 'monthly'
|
||||
@@ -1350,6 +1415,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
it('throws WorkspacePlanDowngradeError for noop requests', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
billingInterval: 'monthly'
|
||||
})
|
||||
@@ -1373,21 +1439,16 @@ describe('subscriptions @gatekeeper', () => {
|
||||
reconcileSubscriptionData: () => {
|
||||
expect.fail()
|
||||
},
|
||||
upsertWorkspacePlan: () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceSubscription: () => {
|
||||
expect.fail()
|
||||
},
|
||||
countSeatsByTypeInWorkspace: () => {
|
||||
expect.fail()
|
||||
},
|
||||
emitEvent: () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: 'team',
|
||||
billingInterval: 'monthly'
|
||||
@@ -1398,6 +1459,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
it('throws WorkspacePlanMismatchError if subscription has no seats for the current plan', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionData: SubscriptionData = {
|
||||
cancelAt: null,
|
||||
customerId: cryptoRandomString({ length: 10 }),
|
||||
@@ -1429,21 +1491,16 @@ describe('subscriptions @gatekeeper', () => {
|
||||
reconcileSubscriptionData: () => {
|
||||
expect.fail()
|
||||
},
|
||||
upsertWorkspacePlan: () => {
|
||||
expect.fail()
|
||||
},
|
||||
updateWorkspaceSubscription: () => {
|
||||
expect.fail()
|
||||
},
|
||||
countSeatsByTypeInWorkspace: () => {
|
||||
expect.fail()
|
||||
},
|
||||
emitEvent: () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: 'pro',
|
||||
billingInterval: 'monthly'
|
||||
@@ -1454,6 +1511,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
it('replaces current products with new product', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionData: SubscriptionData = {
|
||||
cancelAt: null,
|
||||
customerId: cryptoRandomString({ length: 10 }),
|
||||
@@ -1475,10 +1533,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
|
||||
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
|
||||
let updatedWorkspacePlan: WorkspacePlan | undefined = undefined
|
||||
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
|
||||
let emitedEventName: string | undefined = undefined
|
||||
let emitedEventPayload: unknown = undefined
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
workspaceId,
|
||||
@@ -1508,49 +1563,38 @@ describe('subscriptions @gatekeeper', () => {
|
||||
reconcileSubscriptionData: async ({ subscriptionData }) => {
|
||||
reconciledSubscriptionData = subscriptionData
|
||||
},
|
||||
upsertWorkspacePlan: async ({ workspacePlan }) => {
|
||||
updatedWorkspacePlan = workspacePlan
|
||||
},
|
||||
updateWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
||||
updatedWorkspaceSubscription = workspaceSubscription
|
||||
},
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
return 4
|
||||
},
|
||||
emitEvent: async ({ eventName, payload }) => {
|
||||
emitedEventName = eventName
|
||||
emitedEventPayload = payload
|
||||
}
|
||||
})
|
||||
await upgradeWorkspaceSubscription({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetPlan: 'pro',
|
||||
billingInterval: 'yearly'
|
||||
})
|
||||
|
||||
expect(updatedWorkspacePlan!.name).to.equal('pro')
|
||||
const updateIntent = updatedWorkspaceSubscription!
|
||||
.updateIntent as SubscriptionUpdateIntent
|
||||
expect(updateIntent).to.deep.contain({
|
||||
billingInterval: 'yearly',
|
||||
planName: 'pro',
|
||||
products: [
|
||||
{
|
||||
productId: 'proProduct',
|
||||
priceId: 'newPlanPrice',
|
||||
quantity: 4
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(reconciledSubscriptionData!.products.length).to.equal(1)
|
||||
expect(updatedWorkspaceSubscription!.billingInterval === 'yearly')
|
||||
expect(
|
||||
reconciledSubscriptionData!.products.find((p) => p.productId === 'proProduct')!
|
||||
.quantity
|
||||
).to.equal(4)
|
||||
const newProduct = reconciledSubscriptionData!.products.find(
|
||||
(p) => p.productId === 'proProduct'
|
||||
)
|
||||
expect(newProduct!.quantity).to.equal(4)
|
||||
expect(newProduct!.priceId).to.equal('newPlanPrice')
|
||||
expect(emitedEventName).to.eq('gatekeeper.workspace-plan-updated')
|
||||
expect(emitedEventPayload).to.deep.eq({
|
||||
workspacePlan: {
|
||||
workspaceId,
|
||||
status: 'valid',
|
||||
name: 'pro'
|
||||
},
|
||||
previousPlan: {
|
||||
name: 'team'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('getTotalSeatsCountByPlanFactory returns a function that, ', () => {
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { Knex } from 'knex'
|
||||
|
||||
const TABLE_NAME = 'workspace_checkout_sessions'
|
||||
const COLUMN_NAME = 'userId'
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable(TABLE_NAME, (table) => {
|
||||
table.string(COLUMN_NAME).nullable()
|
||||
})
|
||||
|
||||
// only on migration we are assuming its a random workspace admin who created the session
|
||||
// these are ongoing sessions, wont be recorded as activity
|
||||
const workspaceIds: { workspaceId: string }[] = await knex
|
||||
.select('workspaceId')
|
||||
.from(TABLE_NAME)
|
||||
|
||||
const admins: { workspaceId: string; userId: string }[] = await knex
|
||||
.select('workspace_acl.workspaceId', 'workspace_acl.userId')
|
||||
.from('workspace_acl')
|
||||
.where({ role: 'workspace:admin' })
|
||||
.join(
|
||||
'workspace_checkout_sessions',
|
||||
'workspace_acl.workspaceId',
|
||||
'workspace_checkout_sessions.workspaceId'
|
||||
)
|
||||
|
||||
for (const { workspaceId } of workspaceIds) {
|
||||
const admin = admins.find((a) => a.workspaceId === workspaceId)
|
||||
await knex(TABLE_NAME)
|
||||
.update({ [COLUMN_NAME]: admin?.userId || '' }) // fallback to empty string if no admin found (should not happen)
|
||||
.where({ workspaceId })
|
||||
}
|
||||
|
||||
await knex.schema.alterTable(TABLE_NAME, (table) => {
|
||||
table.string(COLUMN_NAME).notNullable().alter()
|
||||
})
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable(TABLE_NAME, (table) => {
|
||||
table.dropColumn(COLUMN_NAME)
|
||||
})
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Knex } from 'knex'
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('workspace_subscriptions', (table) => {
|
||||
table.jsonb('updateIntent').defaultTo(null)
|
||||
})
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('workspace_subscriptions', (table) => {
|
||||
table.dropColumn('updateIntent')
|
||||
})
|
||||
}
|
||||
@@ -290,6 +290,7 @@ export const createTestWorkspace = async (
|
||||
currentBillingCycleEnd: dayjs().add(1, 'month').toDate(),
|
||||
billingInterval: 'monthly',
|
||||
currency: 'usd',
|
||||
updateIntent: null,
|
||||
subscriptionData: {
|
||||
subscriptionId: cryptoRandomString({ length: 10 }),
|
||||
customerId: cryptoRandomString({ length: 10 }),
|
||||
|
||||
Reference in New Issue
Block a user