From bf9d62073d7efa348f0bbc3fcdffe190b1076369 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 17 Dec 2025 19:05:04 +0000 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20=F0=9F=90=9B=20openapi=20v2=20vers?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/api/_openapi2.0_/openapi2.0.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/api/_openapi2.0_/openapi2.0.ts b/shared/api/_openapi2.0_/openapi2.0.ts index 90425b655..6affc9fab 100644 --- a/shared/api/_openapi2.0_/openapi2.0.ts +++ b/shared/api/_openapi2.0_/openapi2.0.ts @@ -17,7 +17,7 @@ const openapi2_0 = createDocument( openapi: "3.1.0", info: { title: "Autumn API", - version: "1.2.0", + version: "2.0.0", }, servers: [ From 3acb483e50100d7d05f096cb86988368f5325caf Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 17 Dec 2025 19:05:41 +0000 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20=F0=9F=90=9B=203ds=20and=20etc=20I?= =?UTF-8?q?AR=20for=20one=20off=20and=20subscriptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../addProductFlow/createStripeSub2.ts | 3 +- .../addProductFlow/handleOneOffFunction.ts | 59 +++- .../addProductFlow/handlePaidProduct.ts | 35 ++- .../upgradeFlow/updateStripeSub2.ts | 2 +- .../invoice-action-required5.test.ts | 258 ++++++++++++++++++ 5 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 server/tests/billing/invoice-action-required/invoice-action-required5.test.ts diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts index d03f117c5..c6776daee 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts @@ -68,7 +68,7 @@ export const createStripeSub2 = async ({ // The subscription will become active after external payment is confirmed via Payment Records API payment_behavior: isCustomPaymentMethod ? "default_incomplete" - : "error_if_incomplete", + : "allow_incomplete", add_invoice_items: invoiceItems, collection_method: invoiceOnly ? "send_invoice" : "charge_automatically", days_until_due: invoiceOnly ? 30 : undefined, @@ -165,6 +165,7 @@ export const createStripeSub2 = async ({ console.log("Error code:", error.code); console.log("Message:", error.message); console.log("Decline code:", error.decline_code); + console.log("Error original stack:", error.stack); throw new RecaseError({ code: ErrCode.CreateStripeSubscriptionFailed, diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index 796c3d9d0..c31bf9c7d 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -2,9 +2,11 @@ import { type AttachConfig, type AttachFunctionResponse, AttachFunctionResponseSchema, + MetadataType, SuccessCode, type UsagePriceConfig, } from "@autumn/shared"; +import { addMinutes } from "date-fns"; import { Decimal } from "decimal.js"; import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; @@ -20,6 +22,7 @@ import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUt import { getPriceOptions } from "@/internal/products/prices/priceUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; +import { attachParamsToMetadata } from "../../../../billing/attach/utils/attachParamsToMetadata"; import { getCustomerDisplay } from "../../../../billing/attach/utils/getCustomerDisplay"; export const handleOneOffFunction = async ({ @@ -169,19 +172,57 @@ export const handleOneOffFunction = async ({ // Create invoice items if (!invoiceOnly) { - await stripeCli.invoices.finalizeInvoice(stripeInvoice.id!); + stripeInvoice = await stripeCli.invoices.finalizeInvoice(stripeInvoice.id!); - logger.info("3. Paying invoice"); - const { paid, error } = await payForInvoice({ + logger.info("3. Creating invoice from stripe"); + await insertInvoiceFromAttach({ + db: ctx.db, + attachParams, + invoiceId: stripeInvoice.id, + logger, + }); + + logger.info("4. Paying invoice"); + const { paid, error, invoice: paidInvoice } = await payForInvoice({ stripeCli, invoiceId: stripeInvoice.id!, paymentMethod, logger, errorOnFail: false, - voidIfFailed: true, + voidIfFailed: false, }); + if (paidInvoice) { + stripeInvoice = paidInvoice; + } + if (!paid) { + // Check if invoice is still open (payment failed but invoice not voided) + if (stripeInvoice && stripeInvoice.status === "open") { + logger.info( + `[one off function] invoice action required: ${stripeInvoice.id}`, + ); + const metadata = await attachParamsToMetadata({ + db: ctx.db, + attachParams, + type: MetadataType.InvoiceActionRequired, + stripeInvoiceId: stripeInvoice.id as string, + expiresAt: addMinutes(Date.now(), 10).getTime(), + }); + + await stripeCli.invoices.update(stripeInvoice.id, { + metadata: { + autumn_metadata_id: metadata.id, + }, + }); + + return AttachFunctionResponseSchema.parse({ + checkout_url: stripeInvoice.hosted_invoice_url, + code: SuccessCode.InvoiceActionRequired, + message: "Payment action required", + }); + } + if (org.config.checkout_on_failed_payment) { return await handleCreateCheckout({ ctx, @@ -193,7 +234,7 @@ export const handleOneOffFunction = async ({ } } - logger.info("4. Creating full customer product"); + logger.info("6. Creating full customer product"); const batchInsert = []; for (const product of products) { batchInsert.push( @@ -206,14 +247,6 @@ export const handleOneOffFunction = async ({ } await Promise.all(batchInsert); - logger.info("5. Creating invoice from stripe"); - await insertInvoiceFromAttach({ - db: ctx.db, - attachParams, - invoiceId: stripeInvoice.id, - logger, - }); - const customerName = getCustomerDisplay({ customer }); const productNames = products.map((p) => p.name).join(", "); return AttachFunctionResponseSchema.parse({ diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 73688e58f..ce8f49d9b 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -4,9 +4,9 @@ import { type AttachFunctionResponse, AttachFunctionResponseSchema, AttachScenario, - ErrCode, - isTrialing, - SuccessCode, + ErrCode, isTrialing, + MetadataType, + SuccessCode } from "@autumn/shared"; import type Stripe from "stripe"; import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; @@ -32,6 +32,8 @@ import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js"; import { handleUpgradeFlowSchedule } from "../upgradeFlow/handleUpgradeFlowSchedule.js"; import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js"; import { createStripeSub2 } from "./createStripeSub2.js"; +import { attachParamsToMetadata } from "@/internal/billing/attach/utils/attachParamsToMetadata.js"; +import { addMinutes } from "date-fns"; export const handlePaidProduct = async ({ ctx, @@ -208,11 +210,38 @@ export const handlePaidProduct = async ({ logger, }); } + + const subInvoice: Stripe.Invoice | undefined = (sub.latest_invoice as Stripe.Invoice) + + if(subInvoice && subInvoice.status === "open") { + logger.info( + `[update subscription] invoice action required: ${subInvoice.id}`, + ); + const metadata = await attachParamsToMetadata({ + db: ctx.db, + attachParams, + type: MetadataType.InvoiceActionRequired, + stripeInvoiceId: subInvoice.id as string, + expiresAt: addMinutes(Date.now(), 10).getTime(), + }); + + await stripeCli.invoices.update(subInvoice.id, { + metadata: { + autumn_metadata_id: metadata.id, + }, + }); + return AttachFunctionResponseSchema.parse({ + checkout_url: subInvoice.hosted_invoice_url, + code: SuccessCode.InvoiceActionRequired, + message: "Payment action required", + }); + } } catch (error) { if ( error instanceof RecaseError && !invoiceOnly && error.code === ErrCode.CreateStripeSubscriptionFailed + ) { return await handleCreateCheckout({ ctx, diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index 0ae32f675..ebbe50034 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -7,7 +7,7 @@ import { } from "@autumn/shared"; import type Stripe from "stripe"; import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.js"; +import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateStripeSub/createProrationInvoice.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; diff --git a/server/tests/billing/invoice-action-required/invoice-action-required5.test.ts b/server/tests/billing/invoice-action-required/invoice-action-required5.test.ts new file mode 100644 index 000000000..82744253a --- /dev/null +++ b/server/tests/billing/invoice-action-required/invoice-action-required5.test.ts @@ -0,0 +1,258 @@ +import { + type AttachConfig, + type AttachFunctionResponse, + AttachFunctionResponseSchema, + MetadataType, + SuccessCode, + type UsagePriceConfig, +} from "@autumn/shared"; +import { addMinutes } from "date-fns"; +import { Decimal } from "decimal.js"; +import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.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"; +import { newPriceToInvoiceDescription } from "@/internal/invoices/invoiceFormatUtils.js"; +import { buildInvoiceMemoFromEntitlements } from "@/internal/invoices/invoiceMemoUtils.js"; +import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; +import { orgToCurrency } from "@/internal/orgs/orgUtils.js"; +import { priceToProduct } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; +import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; +import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { getPriceOptions } from "@/internal/products/prices/priceUtils.js"; +import { attachToInsertParams } from "@/internal/products/productUtils.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; +import { attachParamsToMetadata } from "../../../../billing/attach/utils/attachParamsToMetadata"; +import { getCustomerDisplay } from "../../../../billing/attach/utils/getCustomerDisplay"; + +export const handleOneOffFunction = async ({ + ctx, + attachParams, + config, +}: { + ctx: AutumnContext; + attachParams: AttachParams; + config: AttachConfig; +}): Promise => { + const { logger } = ctx; + + logger.info("Scenario 4A: One-off prices"); + + const { + stripeCli, + paymentMethod, + org, + customer, + products, + prices, + entitlements, + optionsList, + rewards, + } = attachParams; + + const { invoiceOnly } = config; + + const invoiceItems = []; + + for (const price of prices) { + const options = getPriceOptions(price, optionsList); + let quantity = options?.quantity; + + if (quantity) { + const config = price.config as UsagePriceConfig; + quantity = new Decimal(quantity) + .mul(config.billing_units || 1) + .toNumber(); + } + + let invoiceItemData = {}; + if (isFixedPrice({ price })) { + quantity = 1; + + invoiceItemData = { + pricing: { + price: price.config.stripe_price_id, + }, + quantity: 1, + }; + } else { + const amount = priceToInvoiceAmount({ + price, + quantity, + }); + + const product = priceToProduct({ + price, + products, + }); + + const description = newPriceToInvoiceDescription({ + org, + price, + product: product!, + ents: entitlements, + quantity: options?.quantity, + withProductPrefix: true, + }); + + invoiceItemData = { + description, + price_data: { + unit_amount: new Decimal(amount).mul(100).round().toNumber(), + currency: orgToCurrency({ org }), + product: price.config?.stripe_product_id || product?.processor?.id, + }, + }; + } + + invoiceItems.push({ + ...invoiceItemData, + quantity: 1, + }); + } + + let shouldMemo = false; + let invoiceMemo = ""; + try { + shouldMemo = attachParams.org.config.invoice_memos && invoiceOnly; + invoiceMemo = shouldMemo + ? await buildInvoiceMemoFromEntitlements({ + org: attachParams.org, + entitlements: attachParams.entitlements, + features: attachParams.features, + prices: attachParams.prices, + logger, + }) + : ""; + } catch (error) { + logger.error("ONE OFF FUNCTION: error adding invoice memo", { + error, + }); + } + + // Create invoice + logger.info("1. Creating invoice"); + let stripeInvoice = await stripeCli.invoices.create({ + customer: customer.processor.id!, + auto_advance: false, + currency: orgToCurrency({ org }), + discounts: rewards ? rewards.map((r) => ({ coupon: r.id })) : undefined, + collection_method: attachParams.invoiceOnly ? "send_invoice" : undefined, + days_until_due: attachParams.invoiceOnly ? 30 : undefined, + ...(shouldMemo ? { description: invoiceMemo } : {}), + }); + + logger.info("2. Creating invoice items"); + for (const invoiceItem of invoiceItems) { + await stripeCli.invoiceItems.create({ + ...invoiceItem, + customer: customer.processor.id!, + invoice: stripeInvoice.id, + }); + } + + if (config.invoiceCheckout) { + if (stripeInvoice.status === "draft" && config.finalizeInvoice) { + stripeInvoice = await stripeCli.invoices.finalizeInvoice( + stripeInvoice.id!, + ); + } + + await insertInvoiceFromAttach({ + db: ctx.db, + attachParams, + invoiceId: stripeInvoice.id, + logger, + }); + + return AttachFunctionResponseSchema.parse({ + invoice: stripeInvoice, + }); + } + + // Create invoice items + if (!invoiceOnly) { + stripeInvoice = await stripeCli.invoices.finalizeInvoice(stripeInvoice.id!); + + logger.info("3. Creating invoice from stripe"); + await insertInvoiceFromAttach({ + db: ctx.db, + attachParams, + invoiceId: stripeInvoice.id, + logger, + }); + + logger.info("4. Paying invoice"); + const { paid, error, invoice: paidInvoice } = await payForInvoice({ + stripeCli, + invoiceId: stripeInvoice.id!, + paymentMethod, + logger, + errorOnFail: false, + voidIfFailed: false, + }); + + if (paidInvoice) { + stripeInvoice = paidInvoice; + } + + if (!paid) { + // Check if invoice is still open (payment failed but invoice not voided) + if (stripeInvoice && stripeInvoice.status === "open") { + logger.info( + `[one off function] invoice action required: ${stripeInvoice.id}`, + ); + const metadata = await attachParamsToMetadata({ + db: ctx.db, + attachParams, + type: MetadataType.InvoiceActionRequired, + stripeInvoiceId: stripeInvoice.id as string, + expiresAt: addMinutes(Date.now(), 10).getTime(), + }); + + await stripeCli.invoices.update(stripeInvoice.id, { + metadata: { + autumn_metadata_id: metadata.id, + }, + }); + + return AttachFunctionResponseSchema.parse({ + checkout_url: stripeInvoice.hosted_invoice_url, + code: SuccessCode.InvoiceActionRequired, + message: "Payment action required", + }); + } + + if (org.config.checkout_on_failed_payment) { + return await handleCreateCheckout({ + ctx, + attachParams, + config, + }); + } + throw error; + } + } + + logger.info("6. Creating full customer product"); + const batchInsert = []; + for (const product of products) { + batchInsert.push( + createFullCusProduct({ + db: ctx.db, + attachParams: attachToInsertParams(attachParams, product), + logger, + }), + ); + } + await Promise.all(batchInsert); + + const customerName = getCustomerDisplay({ customer }); + const productNames = products.map((p) => p.name).join(", "); + return AttachFunctionResponseSchema.parse({ + // success: true, + message: `Successfully purchased product(s) ${productNames} and attached to customer ${customerName}`, + invoice: invoiceOnly ? stripeInvoice : undefined, + code: SuccessCode.OneOffProductAttached, + }); +}; From 2d9713e945ff8466c3634a8e0cc90ce3df3d1bcf Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 17 Dec 2025 19:18:00 +0000 Subject: [PATCH 03/11] =?UTF-8?q?test:=20=F0=9F=92=8D=20tests=20+=20fix=20?= =?UTF-8?q?crash=20cron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/cron/cronInit.ts | 16 +- .../invoice-action-required5.test.ts | 363 ++++++------------ 2 files changed, 139 insertions(+), 240 deletions(-) diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index 9efb746a2..716b5aa70 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -76,8 +76,20 @@ const main = async () => { }; await Promise.all([ cronTask(), - runProductCron(), - runInvoiceCron({ ctx }), + async () => { + try { + runProductCron() + } catch (_e) { + console.error("Error running product cron:", _e); + } + }, + async () => { + try { + runInvoiceCron({ ctx }) + } catch (_e) { + console.error("Error running invoice cron:", _e); + } + }, // TODO: Add runUsageCron({ ctx }) ]); }; diff --git a/server/tests/billing/invoice-action-required/invoice-action-required5.test.ts b/server/tests/billing/invoice-action-required/invoice-action-required5.test.ts index 82744253a..edc5ef801 100644 --- a/server/tests/billing/invoice-action-required/invoice-action-required5.test.ts +++ b/server/tests/billing/invoice-action-required/invoice-action-required5.test.ts @@ -1,258 +1,145 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { ApiVersion, SuccessCode } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { - type AttachConfig, - type AttachFunctionResponse, - AttachFunctionResponseSchema, - MetadataType, - SuccessCode, - type UsagePriceConfig, -} from "@autumn/shared"; -import { addMinutes } from "date-fns"; -import { Decimal } from "decimal.js"; -import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.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"; -import { newPriceToInvoiceDescription } from "@/internal/invoices/invoiceFormatUtils.js"; -import { buildInvoiceMemoFromEntitlements } from "@/internal/invoices/invoiceMemoUtils.js"; -import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; -import { orgToCurrency } from "@/internal/orgs/orgUtils.js"; -import { priceToProduct } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; -import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; -import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; -import { getPriceOptions } from "@/internal/products/prices/priceUtils.js"; -import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import { attachParamsToMetadata } from "../../../../billing/attach/utils/attachParamsToMetadata"; -import { getCustomerDisplay } from "../../../../billing/attach/utils/getCustomerDisplay"; + constructProduct +} from "@/utils/scriptUtils/createTestProducts.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3"; +import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; +import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout"; +import { attachAuthenticatePaymentMethod } from "@/external/stripe/stripeCusUtils"; +import { completeInvoiceConfirmation } from "@tests/utils/stripeUtils/completeInvoiceConfirmation"; -export const handleOneOffFunction = async ({ - ctx, - attachParams, - config, -}: { - ctx: AutumnContext; - attachParams: AttachParams; - config: AttachConfig; -}): Promise => { - const { logger } = ctx; +const pro = constructProduct({ + type: "pro", - logger.info("Scenario 4A: One-off prices"); + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); - const { - stripeCli, - paymentMethod, - org, - customer, - products, - prices, - entitlements, - optionsList, - rewards, - } = attachParams; +const premium = constructProduct({ + type: "premium", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); - const { invoiceOnly } = config; +const oneOff = constructProduct({ + type: "one_off", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); - const invoiceItems = []; +const testCase = "temp"; - for (const price of prices) { - const options = getPriceOptions(price, optionsList); - let quantity = options?.quantity; +describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { + const customerId = "temp"; - if (quantity) { - const config = price.config as UsagePriceConfig; - quantity = new Decimal(quantity) - .mul(config.billing_units || 1) - .toNumber(); - } + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - let invoiceItemData = {}; - if (isFixedPrice({ price })) { - quantity = 1; - - invoiceItemData = { - pricing: { - price: price.config.stripe_price_id, - }, - quantity: 1, - }; - } else { - const amount = priceToInvoiceAmount({ - price, - quantity, - }); - - const product = priceToProduct({ - price, - products, - }); - - const description = newPriceToInvoiceDescription({ - org, - price, - product: product!, - ents: entitlements, - quantity: options?.quantity, - withProductPrefix: true, - }); - - invoiceItemData = { - description, - price_data: { - unit_amount: new Decimal(amount).mul(100).round().toNumber(), - currency: orgToCurrency({ org }), - product: price.config?.stripe_product_id || product?.processor?.id, - }, - }; - } - - invoiceItems.push({ - ...invoiceItemData, - quantity: 1, + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + attachPm: "fail", }); - } - let shouldMemo = false; - let invoiceMemo = ""; - try { - shouldMemo = attachParams.org.config.invoice_memos && invoiceOnly; - invoiceMemo = shouldMemo - ? await buildInvoiceMemoFromEntitlements({ - org: attachParams.org, - entitlements: attachParams.entitlements, - features: attachParams.features, - prices: attachParams.prices, - logger, - }) - : ""; - } catch (error) { - logger.error("ONE OFF FUNCTION: error adding invoice memo", { - error, + await initProductsV0({ + ctx, + products: [pro, premium, oneOff], + prefix: testCase, }); - } - - // Create invoice - logger.info("1. Creating invoice"); - let stripeInvoice = await stripeCli.invoices.create({ - customer: customer.processor.id!, - auto_advance: false, - currency: orgToCurrency({ org }), - discounts: rewards ? rewards.map((r) => ({ coupon: r.id })) : undefined, - collection_method: attachParams.invoiceOnly ? "send_invoice" : undefined, - days_until_due: attachParams.invoiceOnly ? 30 : undefined, - ...(shouldMemo ? { description: invoiceMemo } : {}), }); - logger.info("2. Creating invoice items"); - for (const invoiceItem of invoiceItems) { - await stripeCli.invoiceItems.create({ - ...invoiceItem, - customer: customer.processor.id!, - invoice: stripeInvoice.id, + it("should call attach and get invoice action required", async () => { + let attachRes = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, }); - } + expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired); + expect(attachRes.checkout_url).toBeDefined(); + expect(attachRes.checkout_url).toContain("invoice.stripe.com"); + expect(attachRes.message).toBe("Payment action required"); - if (config.invoiceCheckout) { - if (stripeInvoice.status === "draft" && config.finalizeInvoice) { - stripeInvoice = await stripeCli.invoices.finalizeInvoice( - stripeInvoice.id!, - ); - } - - await insertInvoiceFromAttach({ - db: ctx.db, - attachParams, - invoiceId: stripeInvoice.id, - logger, + await completeInvoiceCheckout({ + url: attachRes.checkout_url, }); + }) - return AttachFunctionResponseSchema.parse({ - invoice: stripeInvoice, + it("should have attached product after completing invoice action required", async () => { + const customer = await autumnV1.customers.get(customerId); + expectProductAttached({ + customer, + product: pro, }); - } - - // Create invoice items - if (!invoiceOnly) { - stripeInvoice = await stripeCli.invoices.finalizeInvoice(stripeInvoice.id!); - - logger.info("3. Creating invoice from stripe"); - await insertInvoiceFromAttach({ - db: ctx.db, - attachParams, - invoiceId: stripeInvoice.id, - logger, - }); - - logger.info("4. Paying invoice"); - const { paid, error, invoice: paidInvoice } = await payForInvoice({ - stripeCli, - invoiceId: stripeInvoice.id!, - paymentMethod, - logger, - errorOnFail: false, - voidIfFailed: false, - }); - - if (paidInvoice) { - stripeInvoice = paidInvoice; - } - - if (!paid) { - // Check if invoice is still open (payment failed but invoice not voided) - if (stripeInvoice && stripeInvoice.status === "open") { - logger.info( - `[one off function] invoice action required: ${stripeInvoice.id}`, - ); - const metadata = await attachParamsToMetadata({ - db: ctx.db, - attachParams, - type: MetadataType.InvoiceActionRequired, - stripeInvoiceId: stripeInvoice.id as string, - expiresAt: addMinutes(Date.now(), 10).getTime(), - }); - - await stripeCli.invoices.update(stripeInvoice.id, { - metadata: { - autumn_metadata_id: metadata.id, - }, - }); - - return AttachFunctionResponseSchema.parse({ - checkout_url: stripeInvoice.hosted_invoice_url, - code: SuccessCode.InvoiceActionRequired, - message: "Payment action required", - }); - } - - if (org.config.checkout_on_failed_payment) { - return await handleCreateCheckout({ - ctx, - attachParams, - config, - }); - } - throw error; - } - } - - logger.info("6. Creating full customer product"); - const batchInsert = []; - for (const product of products) { - batchInsert.push( - createFullCusProduct({ - db: ctx.db, - attachParams: attachToInsertParams(attachParams, product), - logger, - }), - ); - } - await Promise.all(batchInsert); - - const customerName = getCustomerDisplay({ customer }); - const productNames = products.map((p) => p.name).join(", "); - return AttachFunctionResponseSchema.parse({ - // success: true, - message: `Successfully purchased product(s) ${productNames} and attached to customer ${customerName}`, - invoice: invoiceOnly ? stripeInvoice : undefined, - code: SuccessCode.OneOffProductAttached, }); -}; + + it("should call attach for one-off and get invoice action required", async () => { + let attachRes = await autumnV1.attach({ + customer_id: customerId, + product_id: oneOff.id, + }); + expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired); + expect(attachRes.checkout_url).toBeDefined(); + expect(attachRes.checkout_url).toContain("invoice.stripe.com"); + expect(attachRes.message).toBe("Payment action required"); + + await completeInvoiceCheckout({ + url: attachRes.checkout_url, + }); + }); + + it("should have attached product after completing invoice action required", async () => { + const customer = await autumnV1.customers.get(customerId); + expectProductAttached({ + customer, + product: oneOff, + }); + }); + + it("should call attach for pro and get invoice action required with 3ds", async () => { + await attachAuthenticatePaymentMethod({ + ctx, + customerId, + }); + + let attachRes = await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired); + expect(attachRes.checkout_url).toBeDefined(); + expect(attachRes.checkout_url).toContain("invoice.stripe.com"); + expect(attachRes.message).toBe("Payment action required"); + + await completeInvoiceConfirmation({ + url: attachRes.checkout_url, + }); + }); + + it("should have attached product after completing invoice action required", async () => { + const customer = await autumnV1.customers.get(customerId); + expectProductAttached({ + customer, + product: premium, + }); + }); +}); From 3c956735cf4cb9b7f982a9fa71fd66341df20589 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:24:17 +0000 Subject: [PATCH 04/11] =?UTF-8?q?fix:=20=F0=9F=90=9B=20checkout5,=20checko?= =?UTF-8?q?ut6,=20checkout8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attach/attachFunctions/addProductFlow/handlePaidProduct.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index ce8f49d9b..6ba0df16e 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -213,7 +213,7 @@ export const handlePaidProduct = async ({ const subInvoice: Stripe.Invoice | undefined = (sub.latest_invoice as Stripe.Invoice) - if(subInvoice && subInvoice.status === "open") { + if(subInvoice && subInvoice.status === "open" && !config.invoiceCheckout) { logger.info( `[update subscription] invoice action required: ${subInvoice.id}`, ); From 2dc460696c6886b43e34134cfa7fbdfa793a23c8 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 18 Dec 2025 11:48:20 +0000 Subject: [PATCH 05/11] fix: try catch on cron jobs --- server/src/cron/cronInit.ts | 17 +-- server/src/cron/invoiceCron/runInvoiceCron.ts | 54 +++---- server/src/cron/productCron/runProductCron.ts | 134 +++++++++--------- 3 files changed, 97 insertions(+), 108 deletions(-) diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index 716b5aa70..08ef8d9e9 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -76,20 +76,9 @@ const main = async () => { }; await Promise.all([ cronTask(), - async () => { - try { - runProductCron() - } catch (_e) { - console.error("Error running product cron:", _e); - } - }, - async () => { - try { - runInvoiceCron({ ctx }) - } catch (_e) { - console.error("Error running invoice cron:", _e); - } - }, + runProductCron(), + runInvoiceCron({ ctx }), + // TODO: Add runUsageCron({ ctx }) ]); }; diff --git a/server/src/cron/invoiceCron/runInvoiceCron.ts b/server/src/cron/invoiceCron/runInvoiceCron.ts index 3c621ab2e..9e914100c 100644 --- a/server/src/cron/invoiceCron/runInvoiceCron.ts +++ b/server/src/cron/invoiceCron/runInvoiceCron.ts @@ -13,38 +13,44 @@ export const handleVoidInvoiceCron = async ({ ctx: CronContext; metadata: Metadata; }) => { - const { logger, db } = ctx; - const data = metadata.data as AttachParams; - const { org, customer } = data; - const stripeCli = createStripeCli({ org, env: customer.env }); + try { + const { logger, db } = ctx; + const data = metadata.data as AttachParams; + const { org, customer } = data; + const stripeCli = createStripeCli({ org, env: customer.env }); - if (!metadata.stripe_invoice_id) { - return; - } + if (!metadata.stripe_invoice_id) { + return; + } - const invoice = await stripeCli.invoices.retrieve(metadata.stripe_invoice_id); - console.log( - `Invoice: ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`, - ); - if (invoice.status === "open") { - try { - await stripeCli.invoices.voidInvoice(metadata.stripe_invoice_id); - logger.info( - `voided invoice ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`, - ); + const invoice = await stripeCli.invoices.retrieve( + metadata.stripe_invoice_id, + ); + console.log( + `Invoice: ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`, + ); + if (invoice.status === "open") { + try { + await stripeCli.invoices.voidInvoice(metadata.stripe_invoice_id); + logger.info( + `voided invoice ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`, + ); + await MetadataService.delete({ + db, + id: metadata.id, + }); + } catch (error) { + logger.error(`Error voiding invoice: ${error}`); + } + } else if (invoice.status === "void") { await MetadataService.delete({ db, id: metadata.id, }); - } catch (error) { - logger.error(`Error voiding invoice: ${error}`); } - } else if (invoice.status === "void") { - await MetadataService.delete({ - db, - id: metadata.id, - }); + } catch (error) { + console.log("Error running invoice cron:", error); } }; diff --git a/server/src/cron/productCron/runProductCron.ts b/server/src/cron/productCron/runProductCron.ts index f5ac96edb..c63dd8150 100644 --- a/server/src/cron/productCron/runProductCron.ts +++ b/server/src/cron/productCron/runProductCron.ts @@ -12,80 +12,74 @@ import { batchDeleteCachedCustomers } from "../../internal/customers/cusUtils/ap export const runProductCron = async () => { console.log("Running product cron"); - // Get customer_products that have 0 customer_prices, and trial_ends_at is not null, and trial_ends_at > now - const results = await db - .select() - .from(customerProducts) - .innerJoin( - customers, - eq(customerProducts.internal_customer_id, customers.internal_id), - ) - .where( - and( - // No customer_prices exist for this customer_product - notExists( - db - .select() - .from(customerPrices) - .where(eq(customerPrices.customer_product_id, customerProducts.id)), + try { + // Get customer_products that have 0 customer_prices, and trial_ends_at is not null, and trial_ends_at > now + const results = await db + .select() + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .where( + and( + // No customer_prices exist for this customer_product + notExists( + db + .select() + .from(customerPrices) + .where( + eq(customerPrices.customer_product_id, customerProducts.id), + ), + ), + // status is not expired + inArray(customerProducts.status, ACTIVE_STATUSES), + + // trial_ends_at is not null + isNotNull(customerProducts.trial_ends_at), + + // is already expired + lt( + customerProducts.trial_ends_at, + sql`(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`, + ), ), - // status is not expired - inArray(customerProducts.status, ACTIVE_STATUSES), + ); - // trial_ends_at is not null - isNotNull(customerProducts.trial_ends_at), - - // is already expired - lt( - customerProducts.trial_ends_at, - sql`(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`, - ), - ), - ); - - console.log( - `Found ${results.length} customer products with no prices and active trials`, - ); - - const expireCusProducts = async (ids: string[]) => { - await db - .update(customerProducts) - .set({ - status: CusProductStatus.Expired, - }) - .where(inArray(customerProducts.id, ids)); - }; - - const batchSize = 250; - - for (let i = 0; i < results.length; i += batchSize) { - const batch = results.slice(i, i + batchSize); - await expireCusProducts(batch.map((r) => r.customer_products.id)); console.log( - `Expired batch of ${i + batch.length}/${results.length} customer products`, + `Found ${results.length} customer products with no prices and active trials`, ); - await batchDeleteCachedCustomers({ - customers: batch - .filter((r) => notNullish(r.customers.id)) - .map((r) => ({ - orgId: r.customers.org_id, - env: r.customers.env, - customerId: r.customers.id!, - })), - }); - // const clearCachePromises = []; - // for (const result of batch) { - // clearCachePromises.push( - // deleteCachedApiCustomer({ - // customerId: result.customers.id ?? "", - // orgId: result.customers.org_id, - // env: result.customers.env, - // }), - // ); - // } - // await Promise.all(clearCachePromises); - } + const expireCusProducts = async (ids: string[]) => { + await db + .update(customerProducts) + .set({ + status: CusProductStatus.Expired, + }) + .where(inArray(customerProducts.id, ids)); + }; - return results; + const batchSize = 250; + + for (let i = 0; i < results.length; i += batchSize) { + const batch = results.slice(i, i + batchSize); + await expireCusProducts(batch.map((r) => r.customer_products.id)); + console.log( + `Expired batch of ${i + batch.length}/${results.length} customer products`, + ); + + await batchDeleteCachedCustomers({ + customers: batch + .filter((r) => notNullish(r.customers.id)) + .map((r) => ({ + orgId: r.customers.org_id, + env: r.customers.env, + customerId: r.customers.id!, + })), + }); + } + return results; + } catch (error) { + console.log("Error running product cron:", error); + } }; From a486f54e676a379352c7b22b6ab9fb93fb4cb14d Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:11:41 +0000 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=F0=9F=90=9B=20case=20where=20ui?= =?UTF-8?q?=20says=20confirm=20but=20checkout=5Furl=20is=20sent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../forms/attach-product/attach-product-actions.tsx | 4 +++- .../forms/attach-product/use-attach-product-mutation.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/vite/src/components/forms/attach-product/attach-product-actions.tsx b/vite/src/components/forms/attach-product/attach-product-actions.tsx index 9fabe5c72..addb9b84b 100644 --- a/vite/src/components/forms/attach-product/attach-product-actions.tsx +++ b/vite/src/components/forms/attach-product/attach-product-actions.tsx @@ -101,7 +101,9 @@ export function AttachProductActions({ }); // Handle checkout URLs and invoice links - if (result.data.invoice) { + if (result.data.checkout_url) { + window.open(result.data.checkout_url, "_blank"); + } else if (result.data.invoice) { window.open( getStripeInvoiceLink({ stripeInvoice: result.data.invoice, diff --git a/vite/src/components/forms/attach-product/use-attach-product-mutation.ts b/vite/src/components/forms/attach-product/use-attach-product-mutation.ts index b15139b8b..da323c8f2 100644 --- a/vite/src/components/forms/attach-product/use-attach-product-mutation.ts +++ b/vite/src/components/forms/attach-product/use-attach-product-mutation.ts @@ -62,6 +62,13 @@ export function useAttachProductMutation({ return await CusService.attach(axiosInstance, attachBody); }, onSuccess: async (response) => { + // Don't show success toast if checkout_url is returned - product not attached yet + if (response.data.checkout_url) { + toast.success("Redirecting to checkout URL"); + closeSheet(); + return; + } + toast.success(successMessage); closeSheet(); queryClient.invalidateQueries({ queryKey: ["customer", customerId] }); From a4ef9a8b493f65ba960db16c64e92f2a2e6cb533 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:21:42 +0000 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20"b"=20shortcut=20f?= =?UTF-8?q?or=20billing=20portal=20in=20customer=20actiosn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite/src/views/customers2/customer/CustomerActions.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/vite/src/views/customers2/customer/CustomerActions.tsx b/vite/src/views/customers2/customer/CustomerActions.tsx index 80c9eb366..dfdf4a947 100644 --- a/vite/src/views/customers2/customer/CustomerActions.tsx +++ b/vite/src/views/customers2/customer/CustomerActions.tsx @@ -136,6 +136,7 @@ export function CustomerActions() { onClick={handleOpenBillingPortal} className="flex gap-2" disabled={portalLoading} + shortcut="b" > {portalLoading ? "Opening..." : "Open customer portal"} From 1cfcbc4b69b69857a1b0d86ddea67eb7b9ed95c3 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 18 Dec 2025 13:59:42 +0000 Subject: [PATCH 08/11] fix: updated new subscription fail to add invoice_checkout metadata type --- scripts/testGroups/g3.sh | 1 + server/src/cron/cronInit.ts | 8 +- server/src/cron/invoiceCron/runInvoiceCron.ts | 123 ++++++++++-------- .../handleInvoiceCheckoutPaid.ts | 3 + .../addProductFlow/createStripeSub2.ts | 4 +- .../addProductFlow/handlePaidProduct.ts | 34 +++-- server/tests/_temp/temp.test.ts | 29 +++-- ...new-subscription-action-required1.test.ts} | 77 +++-------- 8 files changed, 131 insertions(+), 148 deletions(-) rename server/tests/billing/invoice-action-required/{invoice-action-required5.test.ts => new-subscription/new-subscription-action-required1.test.ts} (53%) diff --git a/scripts/testGroups/g3.sh b/scripts/testGroups/g3.sh index 694385e4f..4311efa4a 100755 --- a/scripts/testGroups/g3.sh +++ b/scripts/testGroups/g3.sh @@ -26,5 +26,6 @@ BUN_PARALLEL_COMPACT \ 'server/tests/interval/multiSub' \ 'server/tests/billing/cancel' \ 'server/tests/billing/new-billing-subscription' \ + 'server/tests/billing/invoice-action-required/new-subscription' \ --max=6 diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index 08ef8d9e9..4cedc410c 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -74,13 +74,7 @@ const main = async () => { db, logger, }; - await Promise.all([ - cronTask(), - runProductCron(), - runInvoiceCron({ ctx }), - - // TODO: Add runUsageCron({ ctx }) - ]); + await Promise.all([cronTask(), runProductCron(), runInvoiceCron({ ctx })]); }; new CronJob( diff --git a/server/src/cron/invoiceCron/runInvoiceCron.ts b/server/src/cron/invoiceCron/runInvoiceCron.ts index 9e914100c..61e98d136 100644 --- a/server/src/cron/invoiceCron/runInvoiceCron.ts +++ b/server/src/cron/invoiceCron/runInvoiceCron.ts @@ -1,7 +1,8 @@ import { type Metadata, MetadataType, metadata } from "@autumn/shared"; -import { and, eq, lt } from "drizzle-orm"; +import { and, eq, isNotNull, lt, or } from "drizzle-orm"; import { createStripeCli } from "../../external/connect/createStripeCli"; +import { invoiceToSubId } from "../../external/stripe/stripeInvoiceUtils"; import type { AttachParams } from "../../internal/customers/cusProducts/AttachParams"; import { MetadataService } from "../../internal/metadata/MetadataService"; import type { CronContext } from "../utils/CronContext"; @@ -13,73 +14,91 @@ export const handleVoidInvoiceCron = async ({ ctx: CronContext; metadata: Metadata; }) => { - try { - const { logger, db } = ctx; - const data = metadata.data as AttachParams; - const { org, customer } = data; - const stripeCli = createStripeCli({ org, env: customer.env }); + const { logger, db } = ctx; + const data = metadata.data as AttachParams; + const { org, customer } = data; + const stripeCli = createStripeCli({ org, env: customer.env }); - if (!metadata.stripe_invoice_id) { - return; - } + if (!metadata.stripe_invoice_id) return; - const invoice = await stripeCli.invoices.retrieve( - metadata.stripe_invoice_id, - ); - console.log( - `Invoice: ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`, - ); - if (invoice.status === "open") { - try { - await stripeCli.invoices.voidInvoice(metadata.stripe_invoice_id); - logger.info( - `voided invoice ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`, - ); + const invoice = await stripeCli.invoices.retrieve(metadata.stripe_invoice_id); + const subId = invoiceToSubId({ invoice }); + const voidSub = metadata.type === MetadataType.InvoiceCheckout; - await MetadataService.delete({ - db, - id: metadata.id, - }); - } catch (error) { - logger.error(`Error voiding invoice: ${error}`); + console.log( + `Invoice: ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`, + ); + + if (invoice.status === "open") { + try { + await stripeCli.invoices.voidInvoice(metadata.stripe_invoice_id); + logger.info( + `voided invoice ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`, + ); + + if (voidSub && subId) { + logger.info(`Voiding sub ${subId} [created through invoice checkout]`); + try { + await stripeCli.subscriptions.cancel(subId); + } catch (error) { + logger.warn(`Error voiding sub ${subId}: ${error}`); + } } - } else if (invoice.status === "void") { + await MetadataService.delete({ db, id: metadata.id, }); + } catch (error) { + logger.error(`Error voiding invoice: ${error}`); } - } catch (error) { - console.log("Error running invoice cron:", error); + } else if ( + invoice.status === "void" || + invoice.status === "paid" || + invoice.status === "uncollectible" + ) { + await MetadataService.delete({ + db, + id: metadata.id, + }); } }; export const runInvoiceCron = async ({ ctx }: { ctx: CronContext }) => { - console.log("Running invoice cron"); - const { db } = ctx; + try { + console.log("Running invoice cron"); + const { db } = ctx; - // 1. Fetch from metadata invoices - const invoices = await db - .select() - .from(metadata) - .where( - and( - eq(metadata.type, MetadataType.InvoiceActionRequired), - lt(metadata.expires_at, Date.now()), - ), - ); + // 1. Fetch from metadata invoices + const invoices = await db + .select() + .from(metadata) + .where( + and( + or( + eq(metadata.type, MetadataType.InvoiceActionRequired), + eq(metadata.type, MetadataType.InvoiceCheckout), + ), + lt(metadata.expires_at, Date.now()), + isNotNull(metadata.stripe_invoice_id), + ), + ); - const batchSize = 50; - for (let i = 0; i < invoices.length; i += batchSize) { - const batch = invoices.slice(i, i + batchSize); + const batchSize = 50; + for (let i = 0; i < invoices.length; i += batchSize) { + const batch = invoices.slice(i, i + batchSize); - const promises = []; - for (const metadata of batch) { - promises.push(handleVoidInvoiceCron({ ctx, metadata })); + const promises = []; + for (const metadata of batch) { + promises.push(handleVoidInvoiceCron({ ctx, metadata })); + } + await Promise.all(promises); + console.log(`Handled ${i + batch.length}/${invoices.length} invoices`); + console.log("----------------------------------\n"); } - await Promise.all(promises); - console.log(`Handled ${i + batch.length}/${invoices.length} invoices`); - console.log("----------------------------------\n"); + console.log("FINISHED INVOICE CRON"); + } catch (error) { + console.error("Error running invoice cron:", error); + return; } - console.log("FINISHED INVOICE CRON"); }; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceCheckoutPaid.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceCheckoutPaid.ts index 3e9cb4510..88a26ee3c 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceCheckoutPaid.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceCheckoutPaid.ts @@ -15,6 +15,9 @@ export const handleInvoiceCheckoutPaid = async ({ metadata: Metadata; }) => { const { logger, org, env, db } = ctx; + logger.info( + `invoice.paid, handling invoice checkout paid for metadata: ${metadata.id}`, + ); const { subId, anchorToUnix, config, ...rest } = metadata.data as AttachParams; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts index c6776daee..c3264288e 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.ts @@ -1,4 +1,5 @@ import { type AttachConfig, ErrCode } from "@autumn/shared"; +import type { Logger } from "@server/external/logtail/logtailUtils"; import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; @@ -30,7 +31,7 @@ export const createStripeSub2 = async ({ config: AttachConfig; billingCycleAnchorUnix?: number; itemSet: ItemSet; - logger: any; + logger: Logger; }) => { const { customer, invoiceOnly, freeTrial, org, now, rewards, metadata } = attachParams; @@ -69,6 +70,7 @@ export const createStripeSub2 = async ({ payment_behavior: isCustomPaymentMethod ? "default_incomplete" : "allow_incomplete", + add_invoice_items: invoiceItems, collection_method: invoiceOnly ? "send_invoice" : "charge_automatically", days_until_due: invoiceOnly ? 30 : undefined, diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 6ba0df16e..616a542d4 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -4,14 +4,17 @@ import { type AttachFunctionResponse, AttachFunctionResponseSchema, AttachScenario, - ErrCode, isTrialing, + ErrCode, + isTrialing, MetadataType, - SuccessCode + SuccessCode, } from "@autumn/shared"; +import { addMinutes } from "date-fns"; 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 { attachParamsToMetadata } from "@/internal/billing/attach/utils/attachParamsToMetadata.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"; @@ -32,8 +35,6 @@ import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js"; import { handleUpgradeFlowSchedule } from "../upgradeFlow/handleUpgradeFlowSchedule.js"; import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js"; import { createStripeSub2 } from "./createStripeSub2.js"; -import { attachParamsToMetadata } from "@/internal/billing/attach/utils/attachParamsToMetadata.js"; -import { addMinutes } from "date-fns"; export const handlePaidProduct = async ({ ctx, @@ -210,21 +211,31 @@ export const handlePaidProduct = async ({ logger, }); } - - const subInvoice: Stripe.Invoice | undefined = (sub.latest_invoice as Stripe.Invoice) - if(subInvoice && subInvoice.status === "open" && !config.invoiceCheckout) { + const subInvoice: Stripe.Invoice | undefined = + sub.latest_invoice as Stripe.Invoice; + + if ( + subInvoice && + subInvoice.status === "open" && + !config.invoiceCheckout + ) { logger.info( - `[update subscription] invoice action required: ${subInvoice.id}`, + `[create subscription] invoice checkout created because invoice is open: ${subInvoice.id}`, ); const metadata = await attachParamsToMetadata({ db: ctx.db, - attachParams, - type: MetadataType.InvoiceActionRequired, + attachParams: { + ...attachParams, + subId: sub.id, + anchorToUnix: sub.billing_cycle_anchor * 1000, + config, + }, + type: MetadataType.InvoiceCheckout, stripeInvoiceId: subInvoice.id as string, expiresAt: addMinutes(Date.now(), 10).getTime(), }); - + await stripeCli.invoices.update(subInvoice.id, { metadata: { autumn_metadata_id: metadata.id, @@ -241,7 +252,6 @@ export const handlePaidProduct = async ({ error instanceof RecaseError && !invoiceOnly && error.code === ErrCode.CreateStripeSubscriptionFailed - ) { return await handleCreateCheckout({ ctx, diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 705806772..be33adc9f 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -7,7 +7,6 @@ import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { CusService } from "../../src/internal/customers/CusService"; import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3"; const free = constructProduct({ @@ -33,8 +32,8 @@ const pro = constructProduct({ ], }); -const premium = constructProduct({ - type: "premium", +const oneOff = constructProduct({ + type: "one_off", items: [ constructFeatureItem({ featureId: TestFeature.Messages, @@ -65,11 +64,11 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); beforeAll(async () => { - await CusService.deleteByOrgId({ - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, - }); + // await CusService.deleteByOrgId({ + // db: ctx.db, + // orgId: ctx.org.id, + // env: ctx.env, + // }); await initCustomerV3({ ctx, @@ -80,19 +79,21 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { await initProductsV0({ ctx, - products: [free, pro, premium], + products: [free, pro, oneOff], prefix: testCase, }); - await autumnV1.attach({ + const res = await autumnV1.attach({ customer_id: customerId, product_id: pro.id, + invoice: true, }); - await autumnV1.attach({ - customer_id: customerId, - product_id: free.id, - }); + console.log(res); + // await autumnV1.attach({ + // customer_id: customerId, + // product_id: free.id, + // }); // await autumnV1.attach({ // customer_id: customerId, // product_id: pro.id, diff --git a/server/tests/billing/invoice-action-required/invoice-action-required5.test.ts b/server/tests/billing/invoice-action-required/new-subscription/new-subscription-action-required1.test.ts similarity index 53% rename from server/tests/billing/invoice-action-required/invoice-action-required5.test.ts rename to server/tests/billing/invoice-action-required/new-subscription/new-subscription-action-required1.test.ts index edc5ef801..5fd9acdc1 100644 --- a/server/tests/billing/invoice-action-required/invoice-action-required5.test.ts +++ b/server/tests/billing/invoice-action-required/new-subscription/new-subscription-action-required1.test.ts @@ -1,19 +1,16 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { ApiVersion, SuccessCode } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; +import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; -import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout"; -import { attachAuthenticatePaymentMethod } from "@/external/stripe/stripeCusUtils"; -import { completeInvoiceConfirmation } from "@tests/utils/stripeUtils/completeInvoiceConfirmation"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { expectSubToBeCorrect } from "../../../merged/mergeUtils/expectSubCorrect"; const pro = constructProduct({ type: "pro", @@ -46,10 +43,10 @@ const oneOff = constructProduct({ ], }); -const testCase = "temp"; +const testCase = "new-subscription-action-required1"; -describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { - const customerId = "temp"; +describe(`${chalk.yellowBright("new-subscription-action-required1: new subscription, invoice action required (payment failed)")}`, () => { + const customerId = "new-subscription-action-required1"; const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); @@ -69,10 +66,11 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { }); it("should call attach and get invoice action required", async () => { - let attachRes = await autumnV1.attach({ + const attachRes = await autumnV1.attach({ customer_id: customerId, product_id: pro.id, }); + expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired); expect(attachRes.checkout_url).toBeDefined(); expect(attachRes.checkout_url).toContain("invoice.stripe.com"); @@ -81,7 +79,7 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { await completeInvoiceCheckout({ url: attachRes.checkout_url, }); - }) + }); it("should have attached product after completing invoice action required", async () => { const customer = await autumnV1.customers.get(customerId); @@ -89,57 +87,12 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { customer, product: pro, }); - }); - it("should call attach for one-off and get invoice action required", async () => { - let attachRes = await autumnV1.attach({ - customer_id: customerId, - product_id: oneOff.id, - }); - expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired); - expect(attachRes.checkout_url).toBeDefined(); - expect(attachRes.checkout_url).toContain("invoice.stripe.com"); - expect(attachRes.message).toBe("Payment action required"); - - await completeInvoiceCheckout({ - url: attachRes.checkout_url, - }); - }); - - it("should have attached product after completing invoice action required", async () => { - const customer = await autumnV1.customers.get(customerId); - expectProductAttached({ - customer, - product: oneOff, - }); - }); - - it("should call attach for pro and get invoice action required with 3ds", async () => { - await attachAuthenticatePaymentMethod({ - ctx, + await expectSubToBeCorrect({ + db: ctx.db, customerId, - }); - - let attachRes = await autumnV1.attach({ - customer_id: customerId, - product_id: premium.id, - }); - - expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired); - expect(attachRes.checkout_url).toBeDefined(); - expect(attachRes.checkout_url).toContain("invoice.stripe.com"); - expect(attachRes.message).toBe("Payment action required"); - - await completeInvoiceConfirmation({ - url: attachRes.checkout_url, - }); - }); - - it("should have attached product after completing invoice action required", async () => { - const customer = await autumnV1.customers.get(customerId); - expectProductAttached({ - customer, - product: premium, + org: ctx.org, + env: ctx.env, }); }); }); From 8b138b75c9c2f234d1a3108abb243b9fe512e09d Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 18 Dec 2025 14:21:12 +0000 Subject: [PATCH 09/11] fix: basic1 / moved entity2 to after g2 --- scripts/testGroups/g2.sh | 5 ++++ .../honoMiddlewares/validatorMiddleware.ts | 23 +++++++++++++++ .../addProductFlow/handleOneOffFunction.ts | 22 ++++++++------ server/src/internal/customers/cusRouter.ts | 1 - server/tests/attach/basic/basic1.test.ts | 29 ++++--------------- server/tests/utils/compare.ts | 4 +++ .../expectUtils/expectCustomerV0Correct.ts | 3 ++ 7 files changed, 54 insertions(+), 33 deletions(-) diff --git a/scripts/testGroups/g2.sh b/scripts/testGroups/g2.sh index d5fb0195c..d460786a4 100755 --- a/scripts/testGroups/g2.sh +++ b/scripts/testGroups/g2.sh @@ -5,6 +5,7 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_COMPACT \ 'server/tests/attach/basic' \ 'server/tests/attach/entities' \ + !('server/tests/attach/entities/entity2.test.ts') \ 'server/tests/attach/upgrade' \ 'server/tests/attach/downgrade' \ 'server/tests/attach/free' \ @@ -16,3 +17,7 @@ BUN_PARALLEL_COMPACT \ 'server/tests/billing/cancel/add-ons' \ 'server/tests/renew' \ --max=6 \ + +BUN_PARALLEL_COMPACT \ + 'server/tests/attach/entities/entity2.test.ts' \ + --max=6 \ \ No newline at end of file diff --git a/server/src/honoMiddlewares/validatorMiddleware.ts b/server/src/honoMiddlewares/validatorMiddleware.ts index 0a051b1e9..a13f518b1 100644 --- a/server/src/honoMiddlewares/validatorMiddleware.ts +++ b/server/src/honoMiddlewares/validatorMiddleware.ts @@ -9,6 +9,8 @@ import type { ZodType } from "zod/v4"; * For query validation, this uses the parsed query from queryMiddleware * to ensure boolean/array conversions are applied before validation * + * For JSON validation, empty bodies are treated as {} to allow optional body schemas + * * Usage: * ```ts * router.post( @@ -39,6 +41,27 @@ export const validator = ( }; } + // Handle JSON body - allow empty body if schema allows it + if (target === "json") { + return async (c: any, next: any) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + // Empty body or whitespace-only body - default to empty object + // Real JSON parse errors will still fail schema validation + body = {}; + } + + const result = schema.safeParse(body); + if (!result.success) { + throw result.error; + } + c.req.addValidatedData(target, result.data); + await next(); + }; + } + return zValidator(target, schema, (result, _c) => { if (!result.success) { throw result.error; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index c31bf9c7d..464aecafc 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -170,20 +170,24 @@ export const handleOneOffFunction = async ({ }); } + logger.info("3. Creating invoice from stripe"); + await insertInvoiceFromAttach({ + db: ctx.db, + attachParams, + invoiceId: stripeInvoice.id, + logger, + }); + // Create invoice items if (!invoiceOnly) { stripeInvoice = await stripeCli.invoices.finalizeInvoice(stripeInvoice.id!); - logger.info("3. Creating invoice from stripe"); - await insertInvoiceFromAttach({ - db: ctx.db, - attachParams, - invoiceId: stripeInvoice.id, - logger, - }); - logger.info("4. Paying invoice"); - const { paid, error, invoice: paidInvoice } = await payForInvoice({ + const { + paid, + error, + invoice: paidInvoice, + } = await payForInvoice({ stripeCli, invoiceId: stripeInvoice.id!, paymentMethod, diff --git a/server/src/internal/customers/cusRouter.ts b/server/src/internal/customers/cusRouter.ts index 0369df812..fd1167d51 100644 --- a/server/src/internal/customers/cusRouter.ts +++ b/server/src/internal/customers/cusRouter.ts @@ -29,7 +29,6 @@ cusRouter.post("/:customer_id/transfer", ...handleTransferProductV2); // Billing portal cusRouter.post("/:customer_id/billing_portal", ...handleCreateBillingPortal); -// cusRouter.get("/:customer_id/billing_portal", ...handleCreateBillingPortal); // Legacy... cusRouter.post("/:customer_id/balances", ...handleUpdateBalancesV2); diff --git a/server/tests/attach/basic/basic1.test.ts b/server/tests/attach/basic/basic1.test.ts index e07d339c1..7d7d88152 100644 --- a/server/tests/attach/basic/basic1.test.ts +++ b/server/tests/attach/basic/basic1.test.ts @@ -3,7 +3,6 @@ import { ApiVersion } from "@autumn/shared"; import { AutumnCli } from "@tests/cli/AutumnCli.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; @@ -59,26 +58,10 @@ describe(`${chalk.yellowBright("basic1: Testing attach free, default product")}` await expectCustomerV0Correct({ sent: sharedDefaultFree, cusRes: data, + // skipEntitlements: true, }); }); - test("should have correct entitlements", async () => { - // Expected: 5 allowance for Messages feature - const entitled = (await AutumnCli.entitled( - customerId, - TestFeature.Messages, - )) as any; - - const metered1Balance = entitled.balances.find( - (balance: any) => balance.feature_id === TestFeature.Messages, - ); - - expect(entitled.allowed).toBe(true); - expect(metered1Balance).toBeDefined(); - expect(metered1Balance.balance).toBe(5); - expect(metered1Balance.unlimited).toBeUndefined(); - }); - test("should have correct boolean1 entitlement", async () => { // Dashboard feature is not included in freeProd, should be false const entitled = await AutumnCli.entitled( @@ -101,10 +84,10 @@ describe(`${chalk.yellowBright("basic1: Testing attach free, default product")}` product: free2, }); - expectFeaturesCorrect({ - customer, - product: free2, - otherProducts: [sharedDefaultFree], - }); + // expectFeaturesCorrect({ + // customer, + // product: free2, + // otherProducts: [sharedDefaultFree], + // }); }); }); diff --git a/server/tests/utils/compare.ts b/server/tests/utils/compare.ts index 699f7b471..1bdb2097c 100644 --- a/server/tests/utils/compare.ts +++ b/server/tests/utils/compare.ts @@ -39,11 +39,13 @@ export const compareMainProduct = ({ cusRes, status = CusProductStatus.Active, optionsList = [], + skipEntitlements = false, }: { sent: any; cusRes: any; status?: CusProductStatus; optionsList?: FeatureOptions[]; + skipEntitlements?: boolean; }) => { const { products, add_ons, entitlements } = cusRes; const prod = products.find( @@ -55,6 +57,8 @@ export const compareMainProduct = ({ `Product ${sent.id} not found (status: ${status}), (${sent.is_add_on ? "add-on" : "main"})`, ).toBeDefined(); + if (skipEntitlements) return; + // Check entitlements const sentEntitlements = Object.values(sent.entitlements) as Entitlement[]; const recEntitlements = entitlements; diff --git a/server/tests/utils/expectUtils/expectCustomerV0Correct.ts b/server/tests/utils/expectUtils/expectCustomerV0Correct.ts index fa4812092..7d850f7b7 100644 --- a/server/tests/utils/expectUtils/expectCustomerV0Correct.ts +++ b/server/tests/utils/expectUtils/expectCustomerV0Correct.ts @@ -23,11 +23,13 @@ export const expectCustomerV0Correct = async ({ cusRes, status, optionsList, + skipEntitlements, }: { sent: ProductV2; cusRes: any; // V0.1 customer response status?: CusProductStatus; optionsList?: FeatureOptions[]; + skipEntitlements?: boolean; }) => { const { org, features } = ctx; @@ -44,5 +46,6 @@ export const expectCustomerV0Correct = async ({ cusRes, status, optionsList, + skipEntitlements, }); }; From f3224471705aa7209172aabc91794e7d9d8c6409 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 18 Dec 2025 14:37:48 +0000 Subject: [PATCH 10/11] chore: cleaned up handlePaidProduct --- scripts/testGroups/g2.sh | 6 +- .../addProductFlow/handlePaidProduct.ts | 111 +++++++----------- server/tests/_temp/temp.test.ts | 3 +- 3 files changed, 46 insertions(+), 74 deletions(-) diff --git a/scripts/testGroups/g2.sh b/scripts/testGroups/g2.sh index d460786a4..68024a63d 100755 --- a/scripts/testGroups/g2.sh +++ b/scripts/testGroups/g2.sh @@ -3,9 +3,8 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_COMPACT \ - 'server/tests/attach/basic' \ 'server/tests/attach/entities' \ - !('server/tests/attach/entities/entity2.test.ts') \ + 'server/tests/attach/basic' \ 'server/tests/attach/upgrade' \ 'server/tests/attach/downgrade' \ 'server/tests/attach/free' \ @@ -18,6 +17,3 @@ BUN_PARALLEL_COMPACT \ 'server/tests/renew' \ --max=6 \ -BUN_PARALLEL_COMPACT \ - 'server/tests/attach/entities/entity2.test.ts' \ - --max=6 \ \ No newline at end of file diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 616a542d4..a237ac1ae 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -4,7 +4,6 @@ import { type AttachFunctionResponse, AttachFunctionResponseSchema, AttachScenario, - ErrCode, isTrialing, MetadataType, SuccessCode, @@ -16,14 +15,12 @@ import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSu import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; import { attachParamsToMetadata } from "@/internal/billing/attach/utils/attachParamsToMetadata.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"; import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { addIntervalToAnchor } from "@/internal/products/prices/billingIntervalUtils2.js"; import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { getCustomerDisplay } from "../../../../billing/attach/utils/getCustomerDisplay.js"; import { @@ -169,16 +166,16 @@ export const handlePaidProduct = async ({ prices: attachParams.prices, }); - // 1. If anchor to start of month, get next month anchor if (org.config.anchor_start_of_month) { + // 1. If anchor to start of month, get next month anchor billingCycleAnchorUnix = getNextStartOfMonthUnix({ interval: smallestInterval!.interval, intervalCount: smallestInterval!.intervalCount, }); } - // 2. If merge sub anchor, use it if (mergeSub && !config.disableMerge) { + // 2. If merge sub anchor, use it billingCycleAnchorUnix = addIntervalToAnchor({ anchorUnix: mergeSub.billing_cycle_anchor * 1000, intervalConfig: smallestInterval!, @@ -186,81 +183,61 @@ export const handlePaidProduct = async ({ }); } - // 3. If billing cycle anchor, just use it if (attachParams.billingAnchor) { + // 3. If billing cycle anchor, just use it billingCycleAnchorUnix = attachParams.billingAnchor; } // console.log("Item set: ", itemSet); - try { - sub = await createStripeSub2({ + sub = await createStripeSub2({ + db: ctx.db, + stripeCli, + attachParams, + itemSet, + billingCycleAnchorUnix, + config, + logger, + }); + + if (sub?.latest_invoice) { + invoice = await insertInvoiceFromAttach({ db: ctx.db, - stripeCli, + stripeInvoice: sub.latest_invoice as Stripe.Invoice, attachParams, - itemSet, - billingCycleAnchorUnix, - config, logger, }); + } - if (sub?.latest_invoice) { - invoice = await insertInvoiceFromAttach({ - db: ctx.db, - stripeInvoice: sub.latest_invoice as Stripe.Invoice, - attachParams, - logger, - }); - } + const subInvoice: Stripe.Invoice | undefined = + sub.latest_invoice as Stripe.Invoice; - const subInvoice: Stripe.Invoice | undefined = - sub.latest_invoice as Stripe.Invoice; - - if ( - subInvoice && - subInvoice.status === "open" && - !config.invoiceCheckout - ) { - logger.info( - `[create subscription] invoice checkout created because invoice is open: ${subInvoice.id}`, - ); - const metadata = await attachParamsToMetadata({ - db: ctx.db, - attachParams: { - ...attachParams, - subId: sub.id, - anchorToUnix: sub.billing_cycle_anchor * 1000, - config, - }, - type: MetadataType.InvoiceCheckout, - stripeInvoiceId: subInvoice.id as string, - expiresAt: addMinutes(Date.now(), 10).getTime(), - }); - - await stripeCli.invoices.update(subInvoice.id, { - metadata: { - autumn_metadata_id: metadata.id, - }, - }); - return AttachFunctionResponseSchema.parse({ - checkout_url: subInvoice.hosted_invoice_url, - code: SuccessCode.InvoiceActionRequired, - message: "Payment action required", - }); - } - } catch (error) { - if ( - error instanceof RecaseError && - !invoiceOnly && - error.code === ErrCode.CreateStripeSubscriptionFailed - ) { - return await handleCreateCheckout({ - ctx, - attachParams, + if (subInvoice && subInvoice.status === "open" && !config.invoiceCheckout) { + logger.info( + `[create subscription] invoice checkout created because invoice is open: ${subInvoice.id}`, + ); + const metadata = await attachParamsToMetadata({ + db: ctx.db, + attachParams: { + ...attachParams, + subId: sub.id, + anchorToUnix: sub.billing_cycle_anchor * 1000, config, - }); - } + }, + type: MetadataType.InvoiceCheckout, + stripeInvoiceId: subInvoice.id as string, + expiresAt: addMinutes(Date.now(), 10).getTime(), + }); - throw error; + await stripeCli.invoices.update(subInvoice.id, { + metadata: { + autumn_metadata_id: metadata.id, + }, + }); + return AttachFunctionResponseSchema.parse({ + checkout_url: subInvoice.hosted_invoice_url, + code: SuccessCode.InvoiceActionRequired, + message: "Payment action required", + }); } } diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index be33adc9f..538430a88 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -85,8 +85,7 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { const res = await autumnV1.attach({ customer_id: customerId, - product_id: pro.id, - invoice: true, + product_id: oneOff.id, }); console.log(res); From 34ff4d0b2169cef1256f7fdd09fb1aa5c76a646a Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 18 Dec 2025 14:40:15 +0000 Subject: [PATCH 11/11] fix: types --- .../attach/attachFunctions/upgradeFlow/updateStripeSub2.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index 7e3f6cc28..d87d28a52 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -7,12 +7,13 @@ import { } from "@autumn/shared"; import type Stripe from "stripe"; import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateStripeSub/createProrationInvoice.js"; + import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; import { nullish } from "@/utils/genUtils.js"; import type { ItemSet } from "@/utils/models/ItemSet.js"; +import { createProrationInvoice } from "../../../../../external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.js"; import { subIsCanceled } from "../../../../../external/stripe/stripeSubUtils.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js";