From 43f4a8ccc24a740ec1db1298b5e5737ede181015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daphn=C3=A9=20Popin?= Date: Mon, 30 Oct 2023 20:24:09 +0100 Subject: [PATCH] Update models & types to handle paid plans (#2300) * Update models & types to handle paid plans * move stripeCustomerId to subscription * Make subscriptionId nullable on PlanType * Do not create processing subscriptions for paid plans * Remove success boolean from subscription route --- front/lib/auth.ts | 28 ++-- front/lib/error.ts | 5 +- front/lib/models/plan.ts | 20 +++ front/lib/plans/enterprise_plans.ts | 1 + front/lib/plans/free_plans.ts | 2 + front/lib/plans/pro_plans.ts | 2 + front/lib/plans/subscription.ts | 141 +++++++++--------- .../{subscription => subscriptions}/index.ts | 43 +++--- front/pages/w/[wId]/subscription/index.tsx | 2 +- front/types/user.ts | 5 + 10 files changed, 137 insertions(+), 112 deletions(-) rename front/pages/api/w/[wId]/{subscription => subscriptions}/index.ts (58%) diff --git a/front/lib/auth.ts b/front/lib/auth.ts index e1aa9579c76f..96e841c64825 100644 --- a/front/lib/auth.ts +++ b/front/lib/auth.ts @@ -440,27 +440,20 @@ export async function planForWorkspace( w: Workspace ): Promise> { const activeSubscription = await Subscription.findOne({ - attributes: ["id", "startDate", "endDate"], + attributes: [ + "id", + "sId", + "stripeSubscriptionId", + "stripeCustomerId", + "startDate", + "endDate", + ], where: { workspaceId: w.id, status: "active" }, include: [ { model: Plan, as: "plan", required: true, - attributes: [ - "code", - "name", - "isSlackbotAllowed", - "maxMessages", - "isManagedSlackAllowed", - "isManagedNotionAllowed", - "isManagedGoogleDriveAllowed", - "isManagedGithubAllowed", - "maxDataSourcesCount", - "maxDataSourcesDocumentsCount", - "maxDataSourcesDocumentsSizeMb", - "maxUsersInWorkspace", - ], }, ], }); @@ -490,6 +483,11 @@ export async function planForWorkspace( code: plan.code, name: plan.name, status: "active", + subscriptionId: activeSubscription?.sId || null, + stripeSubscriptionId: activeSubscription?.stripeSubscriptionId || null, + stripeCustomerId: activeSubscription?.stripeCustomerId || null, + stripeProductId: plan.stripeProductId, + billingType: plan.billingType, startDate: startDate?.getTime() || null, endDate: endDate?.getTime() || null, limits: { diff --git a/front/lib/error.ts b/front/lib/error.ts index 1cdbcb3626b5..2eabe1f937c9 100644 --- a/front/lib/error.ts +++ b/front/lib/error.ts @@ -46,7 +46,10 @@ export type APIErrorType = | "message_not_found" | "message_reaction_error" | "test_plan_message_limit_reached" - | "global_agent_error"; + | "global_agent_error" + | "subscription_error" + | "stripe_webhook_error" + | "stripe_api_error"; export type APIError = { type: APIErrorType; diff --git a/front/lib/models/plan.ts b/front/lib/models/plan.ts index 419336403ab7..738d8810366a 100644 --- a/front/lib/models/plan.ts +++ b/front/lib/models/plan.ts @@ -23,6 +23,7 @@ export class Plan extends Model< declare code: string; // unique declare name: string; declare stripeProductId: string | null; + declare billingType: "fixed" | "monthly_active_users" | "free"; // workspace limitations declare maxMessages: number; @@ -65,6 +66,14 @@ Plan.init( type: DataTypes.STRING, allowNull: true, }, + billingType: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "fixed", + validate: { + isIn: [["fixed", "monthly_active_users", "free"]], + }, + }, maxMessages: { type: DataTypes.INTEGER, allowNull: false, @@ -134,6 +143,9 @@ export class Subscription extends Model< declare planId: ForeignKey; declare plan: NonAttribute; + + declare stripeCustomerId: string | null; + declare stripeSubscriptionId: string | null; } Subscription.init( { @@ -171,6 +183,14 @@ Subscription.init( type: DataTypes.DATE, allowNull: true, }, + stripeCustomerId: { + type: DataTypes.STRING, + allowNull: true, + }, + stripeSubscriptionId: { + type: DataTypes.STRING, + allowNull: true, + }, }, { modelName: "subscription", diff --git a/front/lib/plans/enterprise_plans.ts b/front/lib/plans/enterprise_plans.ts index 2e3deb83601b..c9f3dca0d67e 100644 --- a/front/lib/plans/enterprise_plans.ts +++ b/front/lib/plans/enterprise_plans.ts @@ -25,6 +25,7 @@ export const ENT_PLAN_FAKE_DATA: PlanAttributes = { code: ENT_PLAN_FAKE_CODE, name: "Entreprise", stripeProductId: null, + billingType: "fixed", maxMessages: -1, maxUsersInWorkspace: -1, isSlackbotAllowed: true, diff --git a/front/lib/plans/free_plans.ts b/front/lib/plans/free_plans.ts index d2cf89fc66bf..426f3cc6cf88 100644 --- a/front/lib/plans/free_plans.ts +++ b/front/lib/plans/free_plans.ts @@ -28,6 +28,7 @@ export const FREE_TEST_PLAN_DATA: PlanAttributes = { code: FREE_TEST_PLAN_CODE, name: "Test", stripeProductId: null, + billingType: "free", maxMessages: 100, maxUsersInWorkspace: 1, isSlackbotAllowed: false, @@ -49,6 +50,7 @@ const FREE_PLANS_DATA: PlanAttributes[] = [ code: FREE_UPGRADED_PLAN_CODE, name: "Free Trial", stripeProductId: null, + billingType: "free", maxMessages: -1, maxUsersInWorkspace: -1, isSlackbotAllowed: true, diff --git a/front/lib/plans/pro_plans.ts b/front/lib/plans/pro_plans.ts index bdf6df497586..f4163f0cd5fb 100644 --- a/front/lib/plans/pro_plans.ts +++ b/front/lib/plans/pro_plans.ts @@ -30,6 +30,7 @@ const PRO_PLANS_DATA: PlanAttributes[] = [ code: "PRO_PLAN_MAU_29", name: "Pro", stripeProductId: "prod_OtB9SOIwFyiQnl", + billingType: "monthly_active_users", maxMessages: -1, maxUsersInWorkspace: 500, isSlackbotAllowed: true, @@ -45,6 +46,7 @@ const PRO_PLANS_DATA: PlanAttributes[] = [ code: "PRO_PLAN_FIXED_1000", name: "Pro Fixed", stripeProductId: "prod_OtBhelMswszehT", + billingType: "fixed", maxMessages: -1, maxUsersInWorkspace: 50, isSlackbotAllowed: true, diff --git a/front/lib/plans/subscription.ts b/front/lib/plans/subscription.ts index ba487eea9877..ea918a3bc6dd 100644 --- a/front/lib/plans/subscription.ts +++ b/front/lib/plans/subscription.ts @@ -7,6 +7,7 @@ import { FREE_UPGRADED_PLAN_CODE, PlanAttributes, } from "@app/lib/plans/free_plans"; +//import { createCheckoutSession } from "@app/lib/plans/stripe"; PART OF NEXT PR import { generateModelSId } from "@app/lib/utils"; import { PlanType } from "@app/types/user"; @@ -43,6 +44,11 @@ export const internalSubscribeWorkspaceToFreeTestPlan = async ({ code: freeTestPlan.code, name: freeTestPlan.name, status: "active", + subscriptionId: null, + stripeSubscriptionId: null, + stripeCustomerId: null, + stripeProductId: null, + billingType: freeTestPlan.billingType, startDate: null, endDate: null, limits: { @@ -123,6 +129,7 @@ export const internalSubscribeWorkspaceToFreeUpgradedPlan = async ({ planId: plan.id, status: "active", startDate: now, + stripeCustomerId: activeSubscription?.stripeCustomerId || null, }, { transaction: t } ); @@ -131,6 +138,11 @@ export const internalSubscribeWorkspaceToFreeUpgradedPlan = async ({ code: plan.code, name: plan.name, status: "active", + subscriptionId: newSubscription.sId, + stripeSubscriptionId: newSubscription.stripeSubscriptionId, + stripeCustomerId: newSubscription.stripeCustomerId, + stripeProductId: null, + billingType: "free", startDate: newSubscription.startDate.getTime(), endDate: newSubscription.endDate?.getTime() || null, limits: { @@ -162,7 +174,7 @@ export const internalSubscribeWorkspaceToFreeUpgradedPlan = async ({ export const subscribeWorkspaceToPlan = async ( auth: Authenticator, { planCode }: { planCode: string } -): Promise => { +): Promise => { const user = auth.user(); const workspace = auth.workspace(); const activePlan = auth.plan(); @@ -182,87 +194,68 @@ export const subscribeWorkspaceToPlan = async ( // Case of a downgrade to the free default plan: we use the internal function if (planCode === FREE_TEST_PLAN_CODE) { - return await internalSubscribeWorkspaceToFreeTestPlan({ + await internalSubscribeWorkspaceToFreeTestPlan({ workspaceId: workspace.sId, }); + return; } - const now = new Date(); - - return await front_sequelize.transaction(async (t) => { - // We get the plan to subscribe to - const newPlan = await Plan.findOne({ - where: { code: planCode }, - transaction: t, - }); - if (!newPlan) { - throw new Error(`Cannot subscribe to plan ${planCode}: not found.`); - } - - // We search for an active subscription for this workspace - const activeSubscription = await Subscription.findOne({ - where: { workspaceId: workspace.id, status: "active" }, - transaction: t, - }); - - // We check if the user is already subscribed to this plan - if (activeSubscription && activeSubscription.planId === newPlan.id) { - throw new Error( - `Cannot subscribe to plan ${planCode}: already subscribed.` - ); - } + // We make sure the user is not trying to subscribe to a plan he already has + const newPlan = await Plan.findOne({ + where: { code: planCode }, + }); + if (!newPlan) { + throw new Error(`Cannot subscribe to plan ${planCode}: not found.`); + } + const activeSubscription = await Subscription.findOne({ + where: { workspaceId: workspace.id, status: "active" }, + }); + if (activeSubscription && activeSubscription.planId === newPlan.id) { + throw new Error( + `Cannot subscribe to plan ${planCode}: already subscribed.` + ); + } - // We end the active subscription if any - if (activeSubscription) { - await activeSubscription.update( + if (newPlan.billingType === "free") { + // We can immediately subscribe to a free plan: end the current subscription if any and create a new active one. + const now = new Date(); + await front_sequelize.transaction(async (t) => { + if (activeSubscription) { + await activeSubscription.update( + { + status: "ended", + endDate: now, + }, + { transaction: t } + ); + } + await Subscription.create( { - status: "ended", - endDate: now, + sId: generateModelSId(), + workspaceId: workspace.id, + planId: newPlan.id, + status: "active", + startDate: now, + stripeCustomerId: activeSubscription?.stripeCustomerId || null, }, { transaction: t } ); - } - - // We create a new subscription - const newSubscription = await Subscription.create( - { - sId: generateModelSId(), - workspaceId: workspace.id, - planId: newPlan.id, - status: "active", - startDate: now, - }, - { transaction: t } + }); + } else if (newPlan.stripeProductId) { + // We enter Stripe Checkout flow + // const checkoutUrl = await createCheckoutSession({ COMMENTED CAUSE PART OF THE NEXT PR + // owner: workspace, + // planCode: newPlan.code, + // productId: newPlan.stripeProductId, + // isFixedPriceBilling: true, + // stripeCustomerId: activeSubscription?.stripeCustomerId || null, + // }); + // if (checkoutUrl) { + // return checkoutUrl; + // } + } else { + throw new Error( + `Plan with code ${planCode} is not a free plan and has no Stripe Product ID.` ); - - return { - code: newPlan.code, - name: newPlan.name, - status: "active", - startDate: newSubscription.startDate.getTime(), - endDate: newSubscription.endDate?.getTime() || null, - limits: { - assistant: { - isSlackBotAllowed: newPlan.isSlackbotAllowed, - maxMessages: newPlan.maxMessages, - }, - connections: { - isSlackAllowed: newPlan.isManagedSlackAllowed, - isNotionAllowed: newPlan.isManagedNotionAllowed, - isGoogleDriveAllowed: newPlan.isManagedGoogleDriveAllowed, - isGithubAllowed: newPlan.isManagedGithubAllowed, - }, - dataSources: { - count: newPlan.maxDataSourcesCount, - documents: { - count: newPlan.maxDataSourcesDocumentsCount, - sizeMb: newPlan.maxDataSourcesDocumentsSizeMb, - }, - }, - users: { - maxUsers: newPlan.maxUsersInWorkspace, - }, - }, - }; - }); + } }; diff --git a/front/pages/api/w/[wId]/subscription/index.ts b/front/pages/api/w/[wId]/subscriptions/index.ts similarity index 58% rename from front/pages/api/w/[wId]/subscription/index.ts rename to front/pages/api/w/[wId]/subscriptions/index.ts index 59bdb5040193..cee35fb270b0 100644 --- a/front/pages/api/w/[wId]/subscription/index.ts +++ b/front/pages/api/w/[wId]/subscriptions/index.ts @@ -1,17 +1,18 @@ import { NextApiRequest, NextApiResponse } from "next"; import { Authenticator, getSession } from "@app/lib/auth"; +import { ReturnedAPIErrorType } from "@app/lib/error"; import { subscribeWorkspaceToPlan } from "@app/lib/plans/subscription"; +import logger from "@app/logger/logger"; import { apiError, withLogging } from "@app/logger/withlogging"; -import { PlanType } from "@app/types/user"; -export type GetSubscriptionResponseBody = { - plan: PlanType; +export type PostSubscriptionResponseBody = { + checkoutUrl: string | null; }; async function handler( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse ): Promise { const session = await getSession(req, res); const auth = await Authenticator.fromSession( @@ -43,29 +44,29 @@ async function handler( } switch (req.method) { - case "GET": - // Should return the list of featured plans - return apiError(req, res, { - status_code: 404, - api_error: { - type: "invalid_request_error", - message: "Not implemented yet.", - }, - }); - case "POST": - const newPlan = await subscribeWorkspaceToPlan(auth, { - planCode: req.body.planCode, - }); - res.status(200).json({ plan: newPlan }); - return; - + try { + const stripeCheckoutUrl = await subscribeWorkspaceToPlan(auth, { + planCode: req.body.planCode, + }); + return res.status(200).json({ checkoutUrl: stripeCheckoutUrl || null }); + } catch (error) { + logger.error({ error }, "Error while subscribing workspace to plan"); + return apiError(req, res, { + status_code: 500, + api_error: { + type: "subscription_error", + message: "Error while subscribing workspace to plan", + }, + }); + } + break; default: return apiError(req, res, { status_code: 405, api_error: { type: "method_not_supported_error", - message: "The method passed is not supported, GET is expected.", + message: "The method passed is not supported, POST is expected.", }, }); } diff --git a/front/pages/w/[wId]/subscription/index.tsx b/front/pages/w/[wId]/subscription/index.tsx index 48fbf7fd3fef..5827c4ee55c2 100644 --- a/front/pages/w/[wId]/subscription/index.tsx +++ b/front/pages/w/[wId]/subscription/index.tsx @@ -60,7 +60,7 @@ export default function Subscription({ async function handleSubscribeToPlan(planCode: string): Promise { setIsProcessing(true); - const res = await fetch(`/api/w/${owner.sId}/subscription`, { + const res = await fetch(`/api/w/${owner.sId}/subscriptions`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/front/types/user.ts b/front/types/user.ts index 24fe3f47e5c6..4d551cba9a4a 100644 --- a/front/types/user.ts +++ b/front/types/user.ts @@ -34,6 +34,11 @@ export type PlanType = { code: string; name: string; status: "active" | "ended"; + subscriptionId: string | null; // null for the free test plan that is not in db + stripeSubscriptionId: string | null; + stripeCustomerId: string | null; + stripeProductId: string | null; + billingType: "fixed" | "monthly_active_users" | "free"; startDate: number | null; endDate: number | null; limits: LimitsType;