From 45fd6d446fa1c4841ca93270475f7fe323d6a596 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 18 Aug 2025 19:38:03 -0700 Subject: [PATCH] fix: invoice finalize after --- .../webhookHandlers/handleInvoiceUpdated.ts | 92 ++++++++++++++++++- .../handleCreateInvoiceCheckout.ts | 40 ++++---- .../addProductFlow/createStripeSub2.ts | 1 + .../addProductFlow/handleOneOffFunction.ts | 2 +- .../attach/attachUtils/getAttachConfig.ts | 4 + .../customers/cancel/cancelImmediately.ts | 3 + .../internal/customers/cancel/cancelRouter.ts | 12 ++- shared/models/attachModels/attachBody.ts | 1 + .../attachModels/attachEnums/AttachConfig.ts | 1 + .../CancelProductDialog.tsx | 1 + .../product/components/AttachModal.tsx | 2 + .../product/components/attachProductUtils.ts | 1 + 12 files changed, 134 insertions(+), 26 deletions(-) diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts index b91230a31..fbe3765d7 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts @@ -1,7 +1,75 @@ import { AppEnv, InvoiceStatus } from "@autumn/shared"; import Stripe from "stripe"; -import { getFullStripeInvoice } from "../stripeInvoiceUtils.js"; +import { getFullStripeInvoice, invoiceToSubId } from "../stripeInvoiceUtils.js"; import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js"; +import { MetadataService } from "@/internal/metadata/MetadataService.js"; +import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; + +const handleInvoiceCheckoutVoided = async ({ + db, + stripeCli, + invoiceObject, + logger, +}: { + db: DrizzleCli; + stripeCli: Stripe; + invoiceObject: Stripe.Invoice; + logger: any; +}) => { + const fullInvoice = await getFullStripeInvoice({ + stripeCli, + stripeId: invoiceObject.id!, + }); + + const metadataId = fullInvoice.metadata?.autumn_metadata_id; + + if (!metadataId) return; + + const metadata = await MetadataService.get({ + db, + id: metadataId, + }); + + const { anchorToUnix, config, ...rest } = metadata?.data; + const attachParams = rest as AttachParams; + + if (!attachParams) return; + + const customer = attachParams.customer; + const fullCus = await CusService.getFull({ + db, + idOrInternalId: customer.id || customer.internal_id, + orgId: attachParams.org.id, + env: attachParams.customer.env, + }); + + const subId = invoiceToSubId({ invoice: fullInvoice }); + + if (!subId) return; + + const cusSubIds = fullCus.customer_products + .map((cp) => cp.subscription_ids || []) + .flat(); + + const subIdMatch = cusSubIds.includes(subId); + + if (subIdMatch) return; + + try { + const sub = await stripeCli.subscriptions.retrieve(subId); + + if (sub.status !== "canceled") { + console.log("Invoice checkout voided, cancelling sub:", subId); + await stripeCli.subscriptions.cancel(subId); + } + } catch (error: any) { + logger.warn(`Failed to cancel sub ${subId}, error: ${error?.message}`); + } +}; export const handleInvoiceUpdated = async ({ env, @@ -28,6 +96,14 @@ export const handleInvoiceUpdated = async ({ if (invoiceVoided) { logger.info(`Invoice has been voided!`); + + await handleInvoiceCheckoutVoided({ + db: req.db, + stripeCli, + invoiceObject, + logger, + }); + await InvoiceService.updateByStripeId({ db: req.db, stripeId: invoiceObject.id!, @@ -36,4 +112,18 @@ export const handleInvoiceUpdated = async ({ }, }); } + + const invoiceOpen = + prevAttributes?.status !== "open" && invoiceObject.status === "open"; + + if (invoiceOpen) { + // logger.info(`Invoice has been opened!`); + await InvoiceService.updateByStripeId({ + db: req.db, + stripeId: invoiceObject.id!, + updates: { + status: InvoiceStatus.Open, + }, + }); + } }; diff --git a/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts index f9eb4349a..a770cab4a 100644 --- a/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts @@ -1,22 +1,14 @@ -import RecaseError from "@/utils/errorUtils.js"; import { AttachParams, AttachResultSchema, } from "../cusProducts/AttachParams.js"; -import { createStripeCli } from "@/external/stripe/utils.js"; + import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; -import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import { ErrCode } from "@/errors/errCodes.js"; -import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; + import { isOneOff } from "@/internal/products/productUtils.js"; -import { attachParamsToProduct } from "../attach/attachUtils/convertAttachParams.js"; + import { handlePaidProduct } from "../attach/attachFunctions/addProductFlow/handlePaidProduct.js"; -import { - AttachBranch, - AttachConfig, - ProrationBehavior, - SuccessCode, -} from "@autumn/shared"; +import { AttachConfig, SuccessCode } from "@autumn/shared"; import Stripe from "stripe"; import { handleOneOffFunction } from "../attach/attachFunctions/addProductFlow/handleOneOffFunction.js"; @@ -71,16 +63,22 @@ export const handleCreateInvoiceCheckout = async ({ }); } - // AttachResultSchema.parse({ - // checkout_url: checkout.url, - // code: SuccessCode.CheckoutCreated, - // message: `Successfully created checkout for customer ${ - // customer.id || customer.internal_id - // }, product(s) ${attachParams.products.map((p) => p.name).join(", ")}`, - // product_ids: attachParams.products.map((p) => p.id), - // customer_id: customer.id || customer.internal_id, - // }); if (res) { + if (!config.finalizeInvoice) { + res.status(200).json( + AttachResultSchema.parse({ + invoice: invoices[0], + code: SuccessCode.CheckoutCreated, + message: `Successfully created invoice for customer ${ + attachParams.customer.id || attachParams.customer.internal_id + }, product(s) ${attachParams.products.map((p) => p.name).join(", ")}`, + product_ids: attachParams.products.map((p) => p.id), + customer_id: + attachParams.customer.id || attachParams.customer.internal_id, + }) + ); + return; + } res.status(200).json( AttachResultSchema.parse({ checkout_url: invoices[0].hosted_invoice_url, diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts index da94e31c4..4f7da9c51 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts @@ -131,6 +131,7 @@ export const createStripeSub2 = async ({ if ( invoiceOnly && config.invoiceCheckout && + config.finalizeInvoice && latestInvoice && latestInvoice.status === "draft" ) { diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index beab006b2..9ff3f405b 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -132,7 +132,7 @@ export const handleOneOffFunction = async ({ } as any); } - if (config.invoiceCheckout) { + if (config.invoiceCheckout && config.finalizeInvoice) { if (stripeInvoice.status === "draft") { stripeInvoice = await stripeCli.invoices.finalizeInvoice( stripeInvoice.id! diff --git a/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts b/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts index a4d711835..8d7464d2b 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts @@ -172,6 +172,9 @@ export const getAttachConfig = async ({ disableMerge, sameIntervals, carryTrial, + finalizeInvoice: notNullish(attachBody.finalize_invoice) + ? attachBody.finalize_invoice + : true, }; return { flags, config }; @@ -189,6 +192,7 @@ export const getDefaultAttachConfig = () => { sameIntervals: false, carryTrial: false, invoiceCheckout: false, + finalizeInvoice: true, }; return config; diff --git a/server/src/internal/customers/cancel/cancelImmediately.ts b/server/src/internal/customers/cancel/cancelImmediately.ts index 506e1fe69..04dd94d9d 100644 --- a/server/src/internal/customers/cancel/cancelImmediately.ts +++ b/server/src/internal/customers/cancel/cancelImmediately.ts @@ -28,7 +28,10 @@ export const cancelImmediately = async ({ }); const sub = await cusProductToSub({ cusProduct, stripeCli }); + if (sub) { + // if sub latest invoice not paid, don't prorate + await stripeCli.subscriptions.cancel(sub.id, { prorate: prorate }); } diff --git a/server/src/internal/customers/cancel/cancelRouter.ts b/server/src/internal/customers/cancel/cancelRouter.ts index bc4c4f71f..59e47d6a5 100644 --- a/server/src/internal/customers/cancel/cancelRouter.ts +++ b/server/src/internal/customers/cancel/cancelRouter.ts @@ -5,7 +5,7 @@ import { ErrCode, FullCusProduct } from "@autumn/shared"; import { Router } from "express"; import { expireCusProduct } from "../handlers/handleCusProductExpired.js"; import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js"; -import { nullish } from "@/utils/genUtils.js"; +import { notNullish, nullish } from "@/utils/genUtils.js"; import { handleCancelProduct } from "./handleCancelProduct.js"; const cancelRouter: Router = Router(); @@ -17,10 +17,16 @@ cancelRouter.post("", async (req, res) => action: "expire", handler: async (req, res) => { let { db, orgId, env, logtail: logger } = req; - let { customer_id, product_id, entity_id, cancel_immediately } = req.body; + let { + customer_id, + product_id, + entity_id, + cancel_immediately, + prorate: bodyProrate, + } = req.body; let expireImmediately = cancel_immediately || false; - let prorate = true; + let prorate = notNullish(bodyProrate) ? bodyProrate : true; let fullCus = await CusService.getFull({ db, diff --git a/shared/models/attachModels/attachBody.ts b/shared/models/attachModels/attachBody.ts index e80597ff3..2fbf3f549 100644 --- a/shared/models/attachModels/attachBody.ts +++ b/shared/models/attachModels/attachBody.ts @@ -45,6 +45,7 @@ export const AttachBodySchema = z reward: z.string().optional(), invoice: z.boolean().optional(), enable_product_immediately: z.boolean().optional(), + finalize_invoice: z.boolean().optional().default(true), }) .refine( (data) => { diff --git a/shared/models/attachModels/attachEnums/AttachConfig.ts b/shared/models/attachModels/attachEnums/AttachConfig.ts index bdd1dd27e..f37bcd2db 100644 --- a/shared/models/attachModels/attachEnums/AttachConfig.ts +++ b/shared/models/attachModels/attachEnums/AttachConfig.ts @@ -17,4 +17,5 @@ export interface AttachConfig { disableMerge: boolean; sameIntervals: boolean; carryTrial: boolean; + finalizeInvoice: boolean; } diff --git a/vite/src/views/customers/customer/customer-product-list/CancelProductDialog.tsx b/vite/src/views/customers/customer/customer-product-list/CancelProductDialog.tsx index 8c04c398a..aa0a5a75b 100644 --- a/vite/src/views/customers/customer/customer-product-list/CancelProductDialog.tsx +++ b/vite/src/views/customers/customer/customer-product-list/CancelProductDialog.tsx @@ -45,6 +45,7 @@ export const CancelProductDialog = ({ product_id: cusProduct.product_id, entity_id: entity?.id || entity?.internal_id, cancel_immediately: cancelImmediately, + prorate: false, }); await cusMutate(); setOpen(false); diff --git a/vite/src/views/customers/customer/product/components/AttachModal.tsx b/vite/src/views/customers/customer/product/components/AttachModal.tsx index 6fea5c8b0..993288003 100644 --- a/vite/src/views/customers/customer/product/components/AttachModal.tsx +++ b/vite/src/views/customers/customer/product/components/AttachModal.tsx @@ -78,9 +78,11 @@ export const AttachModal = ({ if (preview?.branch == AttachBranch.SameCustomEnts || flags.isFree) { return false; } + if (preview?.branch == AttachBranch.Downgrade) { return false; } + if (preview?.branch == AttachBranch.Renew) { return false; } diff --git a/vite/src/views/customers/customer/product/components/attachProductUtils.ts b/vite/src/views/customers/customer/product/components/attachProductUtils.ts index 767d1ed9a..d97f5ad2a 100644 --- a/vite/src/views/customers/customer/product/components/attachProductUtils.ts +++ b/vite/src/views/customers/customer/product/components/attachProductUtils.ts @@ -53,6 +53,7 @@ export const getAttachBody = ({ enable_product_immediately: useInvoice ? enableProductImmediately : undefined, + finalize_invoice: useInvoice ? false : undefined, force_checkout: useInvoice && enableProductImmediately === false ? true : undefined,