Fix: Various billing fixes (#3569)

This commit is contained in:
Mike
2024-11-28 20:24:05 +01:00
committed by GitHub
parent 562902d58b
commit b2cebea7eb
23 changed files with 575 additions and 125 deletions
@@ -3559,8 +3559,9 @@ export type UpdateVersionInput = {
};
export type UpgradePlanInput = {
targetPlan: PaidWorkspacePlans;
billingInterval: BillingInterval;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
};
/**
@@ -3540,8 +3540,9 @@ export type UpdateVersionInput = {
};
export type UpgradePlanInput = {
targetPlan: PaidWorkspacePlans;
billingInterval: BillingInterval;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
};
/**
@@ -177,7 +177,7 @@ export const reconcileWorkspaceSubscriptionFactory =
// 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: product.subscriptionItemId, deleted: true })
items.push({ id: existingProduct.subscriptionItemId, deleted: true })
} else {
items.push({
quantity: product.quantity,
@@ -26,7 +26,8 @@ import {
getWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
saveCheckoutSessionFactory,
upsertPaidWorkspacePlanFactory
upsertPaidWorkspacePlanFactory,
upsertWorkspaceSubscriptionFactory
} from '@/modules/gatekeeper/repositories/billing'
import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/featureAuthorization'
import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/services/subscriptions'
@@ -131,7 +132,7 @@ export = FF_GATEKEEPER_MODULE_ENABLED
return session
},
upgradePlan: async (parent, args, ctx) => {
const { workspaceId, targetPlan } = args.input
const { workspaceId, workspacePlan, billingInterval } = args.input
await authorizeResolver(
ctx.userId,
workspaceId,
@@ -139,16 +140,22 @@ export = FF_GATEKEEPER_MODULE_ENABLED
ctx.resourceAccessRules
)
const stripe = getStripeClient()
const countWorkspaceRole = countWorkspaceRoleWithOptionalProjectRoleFactory({
db
})
await upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({
stripe
}),
countWorkspaceRole,
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db }),
getWorkspacePlanPrice,
getWorkspacePlanProductId,
upsertWorkspacePlan: upsertPaidWorkspacePlanFactory({ db })
})({ workspaceId, targetPlan })
upsertWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
})({ workspaceId, targetPlan: workspacePlan, billingInterval })
return true
}
}
@@ -16,6 +16,7 @@ import {
} from '@/modules/gatekeeper/domain/billing'
import {
PaidWorkspacePlans,
WorkspacePlanBillingIntervals,
WorkspacePricingPlans
} from '@/modules/gatekeeper/domain/workspacePricing'
import {
@@ -344,6 +345,8 @@ export const upgradeWorkspaceSubscriptionFactory =
getWorkspacePlanPrice,
getWorkspaceSubscription,
reconcileSubscriptionData,
updateWorkspaceSubscription,
countWorkspaceRole,
upsertWorkspacePlan
}: {
getWorkspacePlan: GetWorkspacePlan
@@ -351,14 +354,18 @@ export const upgradeWorkspaceSubscriptionFactory =
getWorkspacePlanPrice: GetWorkspacePlanPrice
getWorkspaceSubscription: GetWorkspaceSubscription
reconcileSubscriptionData: ReconcileSubscriptionData
updateWorkspaceSubscription: UpsertWorkspaceSubscription
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
upsertWorkspacePlan: UpsertPaidWorkspacePlan
}) =>
async ({
workspaceId,
targetPlan
targetPlan,
billingInterval
}: {
workspaceId: string
targetPlan: PaidWorkspacePlans
billingInterval: WorkspacePlanBillingIntervals
}) => {
const workspacePlan = await getWorkspacePlan({ workspaceId })
@@ -397,18 +404,68 @@ export const upgradeWorkspaceSubscriptionFactory =
starter: 1
}
if (planOrder[workspacePlan.name] >= planOrder[targetPlan])
if (
planOrder[workspacePlan.name] === planOrder[targetPlan] &&
workspaceSubscription.billingInterval === billingInterval
)
throw new WorkspacePlanDowngradeError()
if (planOrder[workspacePlan.name] > planOrder[targetPlan])
throw new WorkspacePlanDowngradeError()
switch (billingInterval) {
case 'monthly':
if (workspaceSubscription.billingInterval === 'yearly')
throw new WorkspacePlanDowngradeError()
case 'yearly':
break
default:
throwUncoveredError(billingInterval)
}
const subscriptionData: SubscriptionDataInput = cloneDeep(
workspaceSubscription.subscriptionData
)
const product = subscriptionData.products.find(
(p) =>
p.productId === getWorkspacePlanProductId({ workspacePlan: workspacePlan.name })
)
if (!product) throw new WorkspacePlanMismatchError()
const seatCount = product.quantity
const [guestCount, memberCount, adminCount] = await Promise.all([
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }),
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }),
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' })
])
workspaceSubscription.updatedAt = new Date()
if (workspaceSubscription.billingInterval !== billingInterval) {
workspaceSubscription.billingInterval = billingInterval
workspaceSubscription.currentBillingCycleEnd = calculateNewBillingCycleEnd({
workspaceSubscription
})
const guestProduct = subscriptionData.products.find(
(p) => p.productId === getWorkspacePlanProductId({ workspacePlan: 'guest' })
)
if (guestProduct) {
mutateSubscriptionDataWithNewValidSeatNumbers({
seatCount: 0,
getWorkspacePlanProductId,
subscriptionData,
workspacePlan: 'guest'
})
subscriptionData.products.push({
quantity: guestCount,
productId: getWorkspacePlanProductId({ workspacePlan: 'guest' }),
priceId: getWorkspacePlanPrice({
workspacePlan: 'guest',
billingInterval
}),
subscriptionItemId: undefined
})
}
}
// set current plan seat count to 0
mutateSubscriptionDataWithNewValidSeatNumbers({
@@ -420,11 +477,11 @@ export const upgradeWorkspaceSubscriptionFactory =
// set target plan seat count to current seat count
subscriptionData.products.push({
quantity: seatCount,
quantity: memberCount + adminCount,
productId: getWorkspacePlanProductId({ workspacePlan: targetPlan }),
priceId: getWorkspacePlanPrice({
workspacePlan: targetPlan,
billingInterval: workspaceSubscription.billingInterval
billingInterval
}),
subscriptionItemId: undefined
})
@@ -438,4 +495,5 @@ export const upgradeWorkspaceSubscriptionFactory =
createdAt: new Date()
}
})
await updateWorkspaceSubscription({ workspaceSubscription })
}
@@ -923,12 +923,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -958,12 +965,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -996,12 +1010,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -1035,12 +1056,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -1071,18 +1099,25 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message)
})
it('throws WorkspacePlanDowngradeError', async () => {
it('throws WorkspacePlanDowngradeError for downgrading the plan', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription()
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
@@ -1106,12 +1141,108 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
expect(err.message).to.equal(new WorkspacePlanDowngradeError().message)
})
it('throws WorkspacePlanDowngradeError for downgrading the billing interval', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription({
billingInterval: 'yearly'
})
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
name: 'business',
status: 'valid'
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
getWorkspacePlanPrice: () => {
expect.fail()
},
getWorkspaceSubscription: async () => {
return workspaceSubscription
},
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business',
billingInterval: 'monthly'
})
})
expect(err.message).to.equal(new WorkspacePlanDowngradeError().message)
})
it('throws WorkspacePlanDowngradeError for noop requests', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription({
billingInterval: 'monthly'
})
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
name: 'business',
status: 'valid'
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
getWorkspacePlanPrice: () => {
expect.fail()
},
getWorkspaceSubscription: async () => {
return workspaceSubscription
},
reconcileSubscriptionData: () => {
expect.fail()
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -1150,12 +1281,19 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: () => {
expect.fail()
},
updateWorkspaceSubscription: () => {
expect.fail()
},
countWorkspaceRole: () => {
expect.fail()
}
})
const err = await expectToThrow(async () => {
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'monthly'
})
})
@@ -1184,11 +1322,13 @@ describe('subscriptions @gatekeeper', () => {
]
}
const workspaceSubscription = createTestWorkspaceSubscription({
subscriptionData
subscriptionData,
billingInterval: 'monthly'
})
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
let updatedWorkspacePlan: WorkspacePlan | undefined = undefined
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
@@ -1219,27 +1359,36 @@ describe('subscriptions @gatekeeper', () => {
},
upsertWorkspacePlan: async ({ workspacePlan }) => {
updatedWorkspacePlan = workspacePlan
},
updateWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedWorkspaceSubscription = workspaceSubscription
},
countWorkspaceRole: async () => {
return 4
}
})
await upgradeWorkspaceSubscription({
workspaceId,
targetPlan: 'business'
targetPlan: 'business',
billingInterval: 'yearly'
})
expect(updatedWorkspacePlan!.name).to.equal('business')
expect(reconciledSubscriptionData!.products.length).to.equal(2)
expect(updatedWorkspaceSubscription!.billingInterval === 'yearly')
expect(
reconciledSubscriptionData!.products.find(
(p) => p.productId === 'guestProduct'
)!.quantity
).to.equal(10)
).to.equal(4)
const newProduct = reconciledSubscriptionData!.products.find(
(p) => p.productId === 'businessProduct'
)
expect(newProduct!.quantity).to.equal(20)
expect(newProduct!.quantity).to.equal(8)
expect(newProduct!.priceId).to.equal('newPlanPrice')
})
})