From ad196f4fa0fd8d47bbfd0bb9623f8d2e2ddc6802 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Thu, 18 Dec 2025 13:54:28 +0000 Subject: [PATCH] fix: update quantity cancellation bug fix --- server/src/external/stripe/stripeSubUtils.ts | 11 ++++++- .../addProductFlow/handlePaidProduct.ts | 4 +-- .../updateQuantityFlow/updateQuantityFlow.ts | 33 +++++++++++-------- .../upgradeFlow/handleUpgradeFlow.ts | 9 ++--- .../upgradeFlow/updateStripeSub2.ts | 6 ++-- .../stripeHandlers/handleOAuthCallback.ts | 2 +- .../utils/checkUtils/checkCustomerCorrect.ts | 16 +++------ .../merged/mergeUtils/expectSubCorrect.ts | 4 +-- .../tests/utils/expectUtils/expectSubUtils.ts | 14 ++++++-- 9 files changed, 59 insertions(+), 40 deletions(-) diff --git a/server/src/external/stripe/stripeSubUtils.ts b/server/src/external/stripe/stripeSubUtils.ts index 12087e9e8..5fc89348e 100644 --- a/server/src/external/stripe/stripeSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils.ts @@ -313,7 +313,16 @@ export const getStripeProrationBehavior = ({ : behaviourMap[ProrationBehavior.NextBilling]; }; -export const subIsCanceled = ({ sub }: { sub: Stripe.Subscription }) => { +/** + * Checks if a Stripe subscription is canceled. + * @param sub - The Stripe subscription to check. + * @returns True if the subscription is canceled, false otherwise. + */ +export const isStripeSubscriptionCanceled = ({ + sub, +}: { + sub: Stripe.Subscription; +}) => { return ( notNullish(sub.canceled_at) || notNullish(sub.cancel_at) || diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 73688e58f..df17f98d0 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -11,7 +11,7 @@ import { import type Stripe from "stripe"; import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; +import { isStripeSubscriptionCanceled } from "@/external/stripe/stripeSubUtils.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; @@ -130,7 +130,7 @@ export const handlePaidProduct = async ({ }); } - if (subIsCanceled({ sub: mergeSub })) { + if (isStripeSubscriptionCanceled({ sub: mergeSub })) { logger.info("ADD PRODUCT FLOW, CREATING NEW SCHEDULE"); schedule = await subToNewSchedule({ ctx, diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts index b207ea1d0..acc94cccf 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts @@ -4,7 +4,10 @@ import { SuccessCode, } from "@autumn/shared"; import type Stripe from "stripe"; -import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { + getStripeSubs, + isStripeSubscriptionCanceled, +} from "@/external/stripe/stripeSubUtils.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import type { AttachParams } from "../../../cusProducts/AttachParams.js"; @@ -22,7 +25,7 @@ export const handleUpdateQuantityFunction = async ({ }) => { const { db } = ctx; - // 2. Update quantities + // Update quantities const optionsToUpdate = attachParams.optionsToUpdate!; const { curSameProduct } = attachParamToCusProducts({ attachParams }); @@ -52,10 +55,23 @@ export const handleUpdateQuantityFunction = async ({ } } + for (const stripeSub of stripeSubs) { + if (isStripeSubscriptionCanceled({ sub: stripeSub })) { + stripeCli.subscriptions.update(stripeSub.id, { + cancel_at: null, + }); + } + } + await CusProductService.update({ db, cusProductId: cusProduct.id, - updates: { options: optionsToUpdate.map((o) => o.new) }, + updates: { + options: optionsToUpdate.map((o) => o.new), + canceled_at: null, + canceled: false, + ended_at: null, + }, }); return AttachFunctionResponseSchema.parse({ @@ -64,15 +80,4 @@ export const handleUpdateQuantityFunction = async ({ invoice: config.invoiceOnly && invoices.length > 0 ? invoices[0] : undefined, }); - - // res.status(200).json( - // AttachResultSchema.parse({ - // customer_id: customer.id || customer.internal_id, - // product_ids: attachParams.products.map((p) => p.id), - // invoice: - // config.invoiceOnly && invoices.length > 0 ? invoices[0] : undefined, - // code: SuccessCode.FeaturesUpdated, - // message: `Successfully updated quantity for features: ${optionsToUpdate.map((o) => o.new.feature_id).join(", ")}`, - // }), - // ); }; diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts index 19f287364..c46d5e83b 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts @@ -11,7 +11,7 @@ import { import type Stripe from "stripe"; import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; +import { isStripeSubscriptionCanceled } from "@/external/stripe/stripeSubUtils.js"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; @@ -113,6 +113,7 @@ export const handleUpgradeFlow = async ({ ); canceled = true; const { stripeCli } = attachParams; + await stripeCli.subscriptions.cancel(curSub.id, { prorate: config.proration === ProrationBehavior.Immediately, invoice_now: config.proration === ProrationBehavior.Immediately, @@ -135,8 +136,8 @@ export const handleUpgradeFlow = async ({ }); // // Renew sub - // console.log("Sub is canceled!", subIsCanceled({ sub: res.updatedSub })); - // if (subIsCanceled({ sub: res.updatedSub })) { + // console.log("Sub is canceled!", isStripeSubscriptionCanceled({ sub: res.updatedSub })); + // if (isStripeSubscriptionCanceled({ sub: res.updatedSub })) { // await attachParams.stripeCli.subscriptions.update(res.updatedSub.id, { // cancel_at_period_end: false, // cancel_at: null, @@ -211,7 +212,7 @@ export const handleUpgradeFlow = async ({ const anchorToUnix = sub ? getEarliestPeriodEnd({ sub }) * 1000 : undefined; let canceledAt: number | undefined; - if (sub && subIsCanceled({ sub })) { + if (sub && isStripeSubscriptionCanceled({ sub })) { canceledAt = sub.canceled_at ? sub.canceled_at * 1000 : curCusProduct?.canceled_at || undefined; diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index 0a700f4b8..540b230ac 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -13,7 +13,7 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free import { SubService } from "@/internal/subscriptions/SubService.js"; import { nullish } from "@/utils/genUtils.js"; import type { ItemSet } from "@/utils/models/ItemSet.js"; -import { subIsCanceled } from "../../../../../external/stripe/stripeSubUtils.js"; +import { isStripeSubscriptionCanceled } from "../../../../../external/stripe/stripeSubUtils.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js"; import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js"; @@ -91,7 +91,9 @@ export const updateStripeSub2 = async ({ // cancel_at_period_end: false, // TODO: will error if sub managed by a schedule - cancel_at_period_end: subIsCanceled({ sub: curSub }) ? false : undefined, + cancel_at_period_end: isStripeSubscriptionCanceled({ sub: curSub }) + ? false + : undefined, }); let latestInvoice = updatedSub.latest_invoice as Stripe.Invoice | null; diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts index a24f17b35..4dc08a608 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts @@ -19,7 +19,7 @@ export const handleOAuthCallback = async (c: Context) => { const { db } = initDrizzle(); // Build frontend redirect URL (default) - const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173"; + const frontendUrl = process.env.CLIENT_URL || "http://localhost:3000"; let redirectUrl = new URL(`${frontendUrl}`); redirectUrl.searchParams.set("tab", "stripe"); diff --git a/server/src/utils/checkUtils/checkCustomerCorrect.ts b/server/src/utils/checkUtils/checkCustomerCorrect.ts index d75d4bd6e..f0d5b7835 100644 --- a/server/src/utils/checkUtils/checkCustomerCorrect.ts +++ b/server/src/utils/checkUtils/checkCustomerCorrect.ts @@ -13,7 +13,7 @@ import { } from "@autumn/shared"; import type { DrizzleCli } from "@server/db/initDrizzle"; import { priceToStripeItem } from "@server/external/stripe/priceToStripeItem/priceToStripeItem"; -import { subIsCanceled } from "@server/external/stripe/stripeSubUtils"; +import { isStripeSubscriptionCanceled } from "@server/external/stripe/stripeSubUtils"; import { cusProductInPhase, logPhaseItems, @@ -163,7 +163,6 @@ const compareActualItems = async ({ continue; } - const { autumnPrice: _, ...rest } = expectedItem; console.log(`(${type}) Missing item:`, rest); @@ -255,7 +254,6 @@ const compareActualItems = async ({ } }; - export const checkCusSubCorrect = async ({ db, fullCus, @@ -271,8 +269,6 @@ export const checkCusSubCorrect = async ({ org: Organization; env: AppEnv; }) => { - - // 1. Only 1 sub ID available const cusProducts = fullCus.customer_products; const subIds = cusProductToSubIds({ cusProducts }); @@ -489,7 +485,6 @@ export const checkCusSubCorrect = async ({ } assert(!!sub, `Sub ${subId} should exist`); - if (sub) { const actualItems = sub!.items.data.map((item: any) => ({ @@ -508,7 +503,6 @@ export const checkCusSubCorrect = async ({ subId, }); } - // Should be canceled @@ -540,11 +534,12 @@ export const checkCusSubCorrect = async ({ const finalShouldBeCanceled = cusSubShouldBeCanceled; - - if (finalShouldBeCanceled) { assert(!sub!.schedule, `sub ${subId} should NOT have a schedule`); - assert(subIsCanceled({ sub: sub! }), `sub ${subId} should be canceled`); + assert( + isStripeSubscriptionCanceled({ sub: sub! }), + `sub ${subId} should be canceled`, + ); continue; } @@ -553,7 +548,6 @@ export const checkCusSubCorrect = async ({ ? schedules.find((s) => s.id === sub!.schedule) : null; - for (let i = 0; i < supposedPhases.length; i++) { const supposedPhase = supposedPhases[i]; diff --git a/server/tests/merged/mergeUtils/expectSubCorrect.ts b/server/tests/merged/mergeUtils/expectSubCorrect.ts index d49c81ade..835e93ae4 100644 --- a/server/tests/merged/mergeUtils/expectSubCorrect.ts +++ b/server/tests/merged/mergeUtils/expectSubCorrect.ts @@ -14,7 +14,7 @@ import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem.js"; -import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; +import { isStripeSubscriptionCanceled } from "@/external/stripe/stripeSubUtils.js"; import { cusProductInPhase, logPhaseItems, @@ -430,7 +430,7 @@ export const expectSubToBeCorrect = async ({ if (finalShouldBeCanceled) { expect(sub.schedule).toBeNull(); // expect(sub.cancel_at).toBeDefined(); - expect(subIsCanceled({ sub })).toBe(true); + expect(isStripeSubscriptionCanceled({ sub })).toBe(true); return; } diff --git a/server/tests/utils/expectUtils/expectSubUtils.ts b/server/tests/utils/expectUtils/expectSubUtils.ts index 2e1fa54af..80def4ee7 100644 --- a/server/tests/utils/expectUtils/expectSubUtils.ts +++ b/server/tests/utils/expectUtils/expectSubUtils.ts @@ -70,7 +70,11 @@ export const getSubsFromCusId = async ({ }; }; -const subIsCanceled = ({ sub }: { sub: Stripe.Subscription }) => { +const isStripeSubscriptionCanceled = ({ + sub, +}: { + sub: Stripe.Subscription; +}) => { return ( notNullish(sub.canceled_at) || notNullish(sub.cancel_at) || @@ -155,9 +159,13 @@ export const expectSubItemsCorrect = async ({ for (const sub of subs) { if (subCanceled) { - expect(subIsCanceled({ sub }), "sub should be canceled").to.be.true; + expect(isStripeSubscriptionCanceled({ sub }), "sub should be canceled").to + .be.true; } else { - expect(subIsCanceled({ sub }), "sub should not be canceled").to.be.false; + expect( + isStripeSubscriptionCanceled({ sub }), + "sub should not be canceled", + ).to.be.false; } }