diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index 58a071b73..d59199de1 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -58,6 +58,7 @@ export const handleCheckoutSessionCompleted = async ({ await CusProductService.expireCurrentProduct({ sb, internalCustomerId: customer.internal_id, + productGroup: product.group, }); console.log("Creating full customer product"); diff --git a/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts b/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts index 411ea4667..f518a8a53 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts @@ -43,6 +43,7 @@ export const handleSubscriptionDeleted = async ({ const futureProduct = await CusProductService.activateFutureProduct({ sb, internalCustomerId: cusProduct.internal_customer_id, + productGroup: cusProduct.product.group, }); if (futureProduct) { diff --git a/server/src/internal/api/customers/products/cusProductRouter.ts b/server/src/internal/api/customers/products/cusProductRouter.ts index 41acae79d..b8b7db368 100644 --- a/server/src/internal/api/customers/products/cusProductRouter.ts +++ b/server/src/internal/api/customers/products/cusProductRouter.ts @@ -105,17 +105,18 @@ const handleExistingProduct = async ({ product: FullProduct; }) => { const { sb } = req; - const existingCusProduct = await CusProductService.getCurrentProduct({ + const currentProduct = await CusProductService.getCurrentProductByGroup({ sb, internalCustomerId: customer.internal_id, + productGroup: product.group, }); - // 2. Don't allow customer to get multiple of the same product - if (existingCusProduct?.internal_product_id === product.internal_id) { + if (currentProduct?.product.internal_id === product.internal_id) { // If there's a future product, delete, else const deletedCusProduct = await CusProductService.deleteFutureProduct({ sb, internalCustomerId: customer.internal_id, + productGroup: product.group, }); if (deletedCusProduct) { @@ -126,9 +127,9 @@ const handleExistingProduct = async ({ ); } // Continue current product subscription - if (existingCusProduct.processor.subscription_id) { + if (currentProduct.processor.subscription_id) { await stripeCli.subscriptions.update( - existingCusProduct.processor.subscription_id, + currentProduct.processor.subscription_id, { cancel_at_period_end: false, } @@ -146,7 +147,7 @@ const handleExistingProduct = async ({ return true; } else { throw new RecaseError({ - message: `Customer ${customer.id} already has product ${existingCusProduct.product_id}`, + message: `Customer ${customer.id} already has product ${currentProduct.product_id}`, code: ErrCode.CustomerAlreadyHasProduct, statusCode: 400, }); @@ -154,7 +155,7 @@ const handleExistingProduct = async ({ } // 3. If no existing product, check if new product is add-on - if (!existingCusProduct && product.is_add_on) { + if (!currentProduct && product.is_add_on) { throw new RecaseError({ message: `Customer has no base product`, code: ErrCode.CustomerHasNoBaseProduct, @@ -162,7 +163,7 @@ const handleExistingProduct = async ({ }); } - return existingCusProduct; + return currentProduct; }; const checkStripeConnections = async ({ diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index c7673b63c..23f29d67c 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -198,21 +198,25 @@ export const expireOrDeleteCusProduct = async ({ sb, customer, startsAt, + productGroup, }: { sb: SupabaseClient; customer: Customer; startsAt?: number; + productGroup: string; }) => { // 1. If startsAt if (startsAt && startsAt > Date.now()) { await CusProductService.deleteFutureProduct({ sb, internalCustomerId: customer.internal_id, + productGroup, }); } else { await CusProductService.expireCurrentProduct({ sb, internalCustomerId: customer.internal_id, + productGroup, }); } }; @@ -247,6 +251,7 @@ export const createFullCusProduct = async ({ sb, customer, startsAt, + productGroup: product.group, }); } diff --git a/server/src/internal/customers/change-product/handleChangeProduct.ts b/server/src/internal/customers/change-product/handleChangeProduct.ts index d22280aef..5153e312e 100644 --- a/server/src/internal/customers/change-product/handleChangeProduct.ts +++ b/server/src/internal/customers/change-product/handleChangeProduct.ts @@ -25,6 +25,7 @@ import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { InvoiceService } from "../invoices/InvoiceService.js"; import { handleAddProduct } from "../add-product/handleAddProduct.js"; +import { CusProductService } from "../products/CusProductService.js"; const scheduleStripeSubscription = async ({ customer, @@ -123,7 +124,17 @@ const handleDowngrade = async ({ }); for (const schedule of schedules.data) { - if (schedule.status !== "canceled") { + const existingCusProduct = await CusProductService.getByScheduleId({ + sb: req.sb, + scheduleId: schedule.id, + }); + + // Delete only if not in the same group + if ( + (!existingCusProduct || + existingCusProduct.product.group === product.group) && + schedule.status !== "canceled" + ) { await stripeCli.subscriptionSchedules.cancel(schedule.id); } } diff --git a/server/src/internal/customers/products/CusProductService.ts b/server/src/internal/customers/products/CusProductService.ts index f72a2bc49..a05e6f504 100644 --- a/server/src/internal/customers/products/CusProductService.ts +++ b/server/src/internal/customers/products/CusProductService.ts @@ -65,38 +65,33 @@ export class CusProductService { return data; } - static async getCurrentProduct({ + static async getCurrentProductByGroup({ sb, internalCustomerId, + productGroup, }: { sb: SupabaseClient; internalCustomerId: string; + productGroup: string; }) { const { data, error } = await sb .from("customer_products") .select("*, product:products!inner(*)") .eq("internal_customer_id", internalCustomerId) + .eq("product.group", productGroup) .eq("product.is_add_on", false) .neq("status", CusProductStatus.Expired) - .neq("status", CusProductStatus.Scheduled); + .neq("status", CusProductStatus.Scheduled) + .single(); if (error) { + if (error.code === "PGRST116") { + return null; + } throw error; } - if (data.length > 1) { - throw new RecaseError({ - message: "Multiple products found for customer", - code: ErrCode.MultipleProductsFound, - statusCode: 500, - }); - } - - if (data.length === 0) { - return null; - } - - return data[0]; + return data; } static async getByCusAndProductId({ @@ -185,6 +180,29 @@ export class CusProductService { return data; } + static async getByScheduleId({ + sb, + scheduleId, + }: { + sb: SupabaseClient; + scheduleId: string; + }) { + const { data, error } = await sb + .from("customer_products") + .select("*, product:products!inner(*)") + .eq("processor->>subscription_schedule_id", scheduleId) + .single(); + + if (error) { + if (error.code === "PGRST116") { + return null; + } + throw error; + } + + return data; + } + static async updateStatusByStripeSubId({ sb, stripeSubId, @@ -242,13 +260,17 @@ export class CusProductService { static async expireCurrentProduct({ sb, internalCustomerId, + productGroup, }: { sb: SupabaseClient; internalCustomerId: string; + productGroup: string; }) { - const currentProduct = await this.getCurrentProduct({ + // TO WORK ON EXPIRING + const currentProduct = await this.getCurrentProductByGroup({ sb, internalCustomerId, + productGroup, }); if (!currentProduct) { @@ -271,9 +293,11 @@ export class CusProductService { static async activateFutureProduct({ sb, internalCustomerId, + productGroup, }: { sb: SupabaseClient; internalCustomerId: string; + productGroup: string; }) { const { data, error } = await sb .from("customer_products") @@ -281,6 +305,7 @@ export class CusProductService { status: CusProductStatus.Active, }) .eq("internal_customer_id", internalCustomerId) + .eq("product.group", productGroup) .eq("status", CusProductStatus.Scheduled) .select() .single(); @@ -300,25 +325,39 @@ export class CusProductService { static async deleteFutureProduct({ sb, internalCustomerId, + productGroup, }: { sb: SupabaseClient; internalCustomerId: string; + productGroup: string; }) { + // 1. Get all products in same group const { data, error } = await sb .from("customer_products") - .delete() + .select("*, product:products!inner(*)") .eq("internal_customer_id", internalCustomerId) - .eq("status", CusProductStatus.Scheduled) + .eq("product.group", productGroup) + .eq("status", CusProductStatus.Scheduled); + + if (!data || data.length === 0) { + return null; + } + + // Delete product + const { error: deleteError } = await sb + .from("customer_products") + .delete() + .eq("id", data[0].id) .select() .single(); - if (error) { - if (error.code === "PGRST116") { + if (deleteError) { + if (deleteError.code === "PGRST116") { return null; } - throw error; + throw deleteError; } - return data; + return data[0]; } }