From 2f88bc7e08c0588a2034bd525d9fe4dc570b8fbc Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:12:44 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20free=20plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + .../vercel/handlers/handleListBillingPlans.ts | 8 ++- .../installations/handleUpsertInstallation.ts | 2 +- .../vercel/misc/vercelSubscriptions.ts | 55 +++++++++++----- .../addProductFlow/handleAddProduct.ts | 62 +++++++++++++++++++ .../stripeHandlers/handleGetOAuthUrl.ts | 4 ++ 6 files changed, 114 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 8b3b143b2..e5a70c140 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "migrate-functions:prod": "infisical run --env=prod -- bun scripts/migrations/migrate-functions.ts", "validate-schema": "infisical run --env=prod -- bun scripts/migrations/validate-schema.ts", + "vite:build": "cd shared && bun ts && cd ../vite && bun run build", + "setupci": "node scripts/setup/setupci.js", "replicate": "bun scripts/db/replicate.ts", "db:push": " bun -F @autumn/shared db:push", diff --git a/server/src/external/vercel/handlers/handleListBillingPlans.ts b/server/src/external/vercel/handlers/handleListBillingPlans.ts index 9362a5a3c..4a53c2b51 100644 --- a/server/src/external/vercel/handlers/handleListBillingPlans.ts +++ b/server/src/external/vercel/handlers/handleListBillingPlans.ts @@ -19,7 +19,10 @@ import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPric import { formatAmount } from "@/utils/formatUtils.js"; import { sortProductsByPrice } from "../../../internal/products/productUtils/sortProductUtils.js"; -import { isOneOff } from "../../../internal/products/productUtils.js"; +import { + isFreeProduct, + isOneOff, +} from "../../../internal/products/productUtils.js"; import type { VercelBillingPlan } from "../misc/vercelTypes.js"; /** @@ -157,12 +160,13 @@ export const listVercelPlansForOrg = async ({ // 1. Get rid of products that have usage prices // 2. Get rid of products that are archived + // 3. Get rid of products that are one off, only if they are not free const filteredProducts = products .filter((p) => !p.prices.some((price) => isUsagePrice({ price }))) .filter( (p) => !p.is_add_on && - !isOneOff(p.prices) && + (!isOneOff(p.prices) || isFreeProduct(p.prices)) && !p.archived && (p.entitlements.length > 0 || p.is_default), ); diff --git a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts index 77c2ff541..b16c11d91 100644 --- a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts @@ -163,7 +163,7 @@ export const handleUpsertInstallation = createRoute({ }), orgCurrency: ctx.org.default_currency ?? "usd", }) - : null, + : undefined, }; return c.json(installation, 200); diff --git a/server/src/external/vercel/misc/vercelSubscriptions.ts b/server/src/external/vercel/misc/vercelSubscriptions.ts index 0ed53ed75..1c48d4b51 100644 --- a/server/src/external/vercel/misc/vercelSubscriptions.ts +++ b/server/src/external/vercel/misc/vercelSubscriptions.ts @@ -15,7 +15,10 @@ import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { createStripeSub2 } from "@/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.js"; +import { handleFreeProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js"; +import { CusService } from "@/internal/customers/CusService.js"; import { ProductService } from "@/internal/products/ProductService.js"; +import { isFreeProduct } from "@/internal/products/productUtils.js"; import { getVercelAttachBody, parseVercelPrepaidQuantities, @@ -64,7 +67,7 @@ export const createVercelSubscription = async ({ c: Context; metadata?: Record; resourceId?: string; -}): Promise<{ subscription: Stripe.Subscription; product: FullProduct }> => { +}): Promise<{ product: FullProduct }> => { // 1. Check for existing non-incomplete subscription (only allow one per installation) const existingSubscription = stripeCustomer.subscriptions?.data.find( (s) => @@ -101,6 +104,13 @@ export const createVercelSubscription = async ({ }); } + const refreshedCustomer = await CusService.getFull({ + db, + idOrInternalId: customer.internal_id, + orgId: org.id, + env, + }); + // 3. Get custom payment method (created in handleUpsertInstallation) const customPaymentMethod = await getCusPaymentMethod({ stripeCli, @@ -148,21 +158,34 @@ export const createVercelSubscription = async ({ resourceId, }); - // 6. Get subscription items - const itemSet = await getStripeSubItems2({ - attachParams, - config, - }); + if (isFreeProduct(product.prices)) { + if ( + !refreshedCustomer?.customer_products.find( + (cp) => cp.product_id === billingPlanId, + ) + ) { + await handleFreeProduct({ + ctx: c.get("ctx"), + attachParams, + }); + } + } else { + // 6. Get subscription items + const itemSet = await getStripeSubItems2({ + attachParams, + config, + }); - // 7. Create Stripe subscription - const subscription = await createStripeSub2({ - db, - stripeCli, - attachParams, - config, - itemSet, - logger, - }); + // 7. Create Stripe subscription + await createStripeSub2({ + db, + stripeCli, + attachParams, + config, + itemSet, + logger, + }); + } // Subscription will be 'incomplete' initially with an 'open' invoice // Payment flow: @@ -174,5 +197,5 @@ export const createVercelSubscription = async ({ // - Attaches payment record to invoice // 4. Invoice becomes 'paid' → Subscription becomes 'active' - return { subscription, product }; + return { product }; }; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts index c3962ccc2..617645234 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts @@ -5,6 +5,7 @@ import { SuccessCode, } from "@autumn/shared"; import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; import { createFullCusProduct } from "../../../add-product/createFullCusProduct.js"; @@ -108,3 +109,64 @@ export const handleAddProduct = async ({ } } }; + +export const handleFreeProduct = async ({ + ctx, + attachParams, + config, +}: { + ctx: AutumnContext; + attachParams: AttachParams; + config?: AttachConfig; +}) => { + const { logger } = ctx; + const { products, prices } = attachParams; + + const defaultConfig: AttachConfig = getDefaultAttachConfig(); + + // 1. If paid product + + if (prices.length < 0) { + return; + } + + logger.info("Inserting free product in handleFreeProduct"); + + const batchInsert = []; + + const { mergeSub } = await getMergeCusProduct({ + attachParams, + config: config || defaultConfig, + products, + }); + + for (const product of products) { + const curCusProduct = attachParamsToCurCusProduct({ attachParams }); + let anchorToUnix; + + if (curCusProduct && config?.branch === AttachBranch.NewVersion) { + anchorToUnix = curCusProduct.created_at; + } + + if (mergeSub) { + const { end } = subToPeriodStartEnd({ sub: mergeSub }); + anchorToUnix = end * 1000; + } + + // Expire previous product + + batchInsert.push( + createFullCusProduct({ + db: ctx.db, + attachParams: attachToInsertParams(attachParams, product), + billLaterOnly: true, + carryExistingUsages: config?.carryUsage || false, + anchorToUnix, + logger, + }), + ); + } + await Promise.all(batchInsert); + + logger.info("Successfully created full cus product"); +}; diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts index 2a0be9128..b47dd7707 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts @@ -46,6 +46,10 @@ export const handleGetOAuthUrl = createRoute({ serverUrl = `https://express.dev.useautumn.com`; } + if (process.env.NGROK_URL && serverUrl?.includes("localhost")) { + serverUrl = process.env.NGROK_URL; + } + // Add state + redirect_uri baseUrl.searchParams.set("state", stateKey); baseUrl.searchParams.set( From b48b02d8ad57f828a8bbe2f1de8ae498901458ad Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:02:43 +0000 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=F0=9F=90=9B=20transactionize=20crea?= =?UTF-8?q?te=20resource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/handleCreateResource.ts | 145 +++++++++++------- 1 file changed, 92 insertions(+), 53 deletions(-) diff --git a/server/src/external/vercel/handlers/resources/handleCreateResource.ts b/server/src/external/vercel/handlers/resources/handleCreateResource.ts index 68c2ba98d..735dcb7ed 100644 --- a/server/src/external/vercel/handlers/resources/handleCreateResource.ts +++ b/server/src/external/vercel/handlers/resources/handleCreateResource.ts @@ -1,7 +1,14 @@ -import { AppEnv, CusExpand, RecaseError } from "@autumn/shared"; +import { + AppEnv, + CusExpand, + type FullProduct, + RecaseError, +} from "@autumn/shared"; import { ErrCode } from "@shared/enums/ErrCode.js"; +import { DrizzleError } from "drizzle-orm"; import { StatusCodes } from "http-status-codes"; import { z } from "zod/v4"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { sendCustomSvixEvent } from "@/external/svix/svixHelpers.js"; import { createVercelSubscription } from "@/external/vercel/misc/vercelSubscriptions.js"; @@ -73,37 +80,50 @@ export const handleCreateResource = createRoute({ // 2. Create resource in database (enforces 1-resource limit) const resourceId = generateId("vre"); - await VercelResourceService.create({ - db, - resource: { - id: resourceId, - org_id: orgId, - env: env as AppEnv, - installation_id: integrationConfigurationId, - name, - status: "pending", - metadata: metadata ?? {}, - }, - }); - - // 3. Create subscription (installation-level billing) - const { product } = await createVercelSubscription({ - db, - org, - env: env as AppEnv, - customer, - stripeCustomer, - stripeCli, - integrationConfigurationId, - billingPlanId, - features, - logger, - c, - metadata, - resourceId, - }); - try { + const product = await db.transaction(async (tx) => { + await VercelResourceService.create({ + db: tx as unknown as DrizzleCli, + resource: { + id: resourceId, + org_id: orgId, + env: env as AppEnv, + installation_id: integrationConfigurationId, + name, + status: "pending", + metadata: metadata ?? {}, + }, + }); + + let createdProduct: FullProduct; + + try { + // 3. Create subscription (installation-level billing) + const { product } = await createVercelSubscription({ + db: tx as unknown as DrizzleCli, + org, + env: env as AppEnv, + customer, + stripeCustomer, + stripeCli, + integrationConfigurationId, + billingPlanId, + features, + logger, + c, + metadata, + resourceId, + }); + + createdProduct = product; + } catch (error) { + tx.rollback(); + throw error; + } + + return createdProduct; + }); + await sendCustomSvixEvent({ appId: org.processor_configs?.vercel?.svix?.[ @@ -121,27 +141,46 @@ export const handleCreateResource = createRoute({ access_token: customer.processors?.vercel?.access_token ?? "", } satisfies VercelResourceCreatedEvent, }); - } catch (_error) {} - // 4. Return resource response - return c.json({ - id: resourceId, - productId, - name, - metadata, - status: "pending", // Will become "ready" after marketplace.invoice.paid confirms payment - billingPlan: { - ...productToBillingPlan({ - product, - orgCurrency: org?.default_currency ?? "usd", - }), - scope: "installation", // Always installation-level - }, - secrets: [], - notification: { - level: "info", - title: "Resource provisioning", - message: `Setting up ${name}...`, - }, - }); + + // 4. Return resource response + return c.json({ + id: resourceId, + productId, + name, + metadata, + status: "pending", // Will become "ready" after marketplace.invoice.paid confirms payment + billingPlan: { + ...productToBillingPlan({ + product, + orgCurrency: org?.default_currency ?? "usd", + }), + scope: "installation", // Always installation-level + }, + secrets: [], + notification: { + level: "info", + title: "Resource provisioning", + message: `Setting up ${name}...`, + }, + }); + } catch (error) { + return c.json( + { + error: { + code: "conflict", + message: + error instanceof DrizzleError + ? error.message.includes("Rollback") + ? "An error occurred while creating the resource's subscription" + : error.message + : error instanceof RecaseError + ? error.message + : "An error occurred while creating the resource", + user: null, + }, + }, + StatusCodes.CONFLICT, + ); + } }, }); From adba980cb2288d1b6ad803d1fbb171d0e375e08a Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:27:53 +0000 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20entity=20->=20custom?= =?UTF-8?q?er=20transfer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handleDecreaseAndTransfer.ts | 6 ++-- .../handlers/handleTransferProductV2.ts | 30 +++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts b/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts index 1a452dccc..f7640211b 100644 --- a/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts +++ b/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts @@ -25,7 +25,7 @@ export const handleDecreaseAndTransfer = async ({ ctx: AutumnContext; fullCus: FullCustomer; cusProduct: FullCusProduct; - toEntity: Entity; + toEntity?: Entity | null; }) => { // 1. Create new cus product for entity... const { org, env, db, logger, features } = ctx; @@ -90,8 +90,8 @@ export const handleDecreaseAndTransfer = async ({ replaceables: [], entities: fullCus.entities, features, - internalEntityId: toEntity.internal_id, - entityId: toEntity.id, + internalEntityId: toEntity?.internal_id ? toEntity.internal_id : undefined, + entityId: toEntity?.id ? toEntity.id : undefined, }, product, ), diff --git a/server/src/internal/customers/handlers/handleTransferProductV2.ts b/server/src/internal/customers/handlers/handleTransferProductV2.ts index 46022c3db..616388a80 100644 --- a/server/src/internal/customers/handlers/handleTransferProductV2.ts +++ b/server/src/internal/customers/handlers/handleTransferProductV2.ts @@ -17,10 +17,14 @@ import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreas const TransferProductSchema = z.object({ from_entity_id: z.string().nullish(), - to_entity_id: z.string(), + to_entity_id: z.string().nullish(), product_id: z.string(), }); +// Supports: +// - Transfer from entity to entity +// - Transfer from entity to org +// - Transfer from org to entity export const handleTransferProductV2 = createRoute({ body: TransferProductSchema, resource: AffectedResource.Customer, @@ -30,6 +34,12 @@ export const handleTransferProductV2 = createRoute({ const { customer_id } = c.req.param(); const { from_entity_id, to_entity_id, product_id } = c.req.valid("json"); + if(!from_entity_id && !to_entity_id) { + throw new RecaseError({ + message: "Must specify atleast one of: from_entity_id, to_entity_id", + }); + } + const customer = await CusService.getFull({ idOrInternalId: customer_id, orgId: org.id, @@ -56,9 +66,11 @@ export const handleTransferProductV2 = createRoute({ (e: any) => e.id === from_entity_id, ); - const toEntity = customer.entities.find((e: any) => e.id === to_entity_id); + const toEntity = to_entity_id + ? customer.entities.find((e: any) => e.id === to_entity_id) + : null; - if (!toEntity) { + if (to_entity_id && !toEntity) { throw new RecaseError({ message: `Entity ${to_entity_id} not found`, }); @@ -73,14 +85,14 @@ export const handleTransferProductV2 = createRoute({ const toCusProduct = customer.customer_products.find( (cp: any) => - cp.internal_entity_id === toEntity.internal_id && + cp.internal_entity_id === (toEntity?.internal_id || null) && cp.product.group === product.group, ); if (toCusProduct) { throw new CusProductAlreadyExistsError({ productId: product_id, - entityId: toEntity.id, + entityId: toEntity?.id, }); } @@ -105,8 +117,8 @@ export const handleTransferProductV2 = createRoute({ db, cusProductId: cusProduct.id, updates: { - entity_id: toEntity.id, - internal_entity_id: toEntity.internal_id, + entity_id: toEntity?.id || null, + internal_entity_id: toEntity?.internal_id || null, }, }); @@ -119,8 +131,8 @@ export const handleTransferProductV2 = createRoute({ scenario: AttachScenario.New, cusProduct: { ...cusProduct, - entity_id: toEntity.id, - internal_entity_id: toEntity.internal_id, + entity_id: toEntity?.id || null, + internal_entity_id: toEntity?.internal_id || null, }, logger: ctx.logger, }); From 43cd40e0cebd7cf79d558432837033060fc196bd Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:28:50 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=F0=9F=90=9B=20disable=20sentry=20te?= =?UTF-8?q?lemetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite/vite.config.ts b/vite/vite.config.ts index 73c9c1cc8..42e8cc069 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ sentryVitePlugin({ org: process.env.VITE_SENTRY_ORG, project: process.env.VITE_SENTRY_PROJECT, + telemetry: false }), ], From 4df2f920f5a37e64cde3318d63126af84119aef9 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:41:55 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=F0=9F=90=9B=20cleaner=20error=20mes?= =?UTF-8?q?sage=20where=20possible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/internal/customers/handlers/handleTransferProductV2.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/internal/customers/handlers/handleTransferProductV2.ts b/server/src/internal/customers/handlers/handleTransferProductV2.ts index 616388a80..ce7a17781 100644 --- a/server/src/internal/customers/handlers/handleTransferProductV2.ts +++ b/server/src/internal/customers/handlers/handleTransferProductV2.ts @@ -93,6 +93,7 @@ export const handleTransferProductV2 = createRoute({ throw new CusProductAlreadyExistsError({ productId: product_id, entityId: toEntity?.id, + customerId: (from_entity_id && !to_entity_id) ? customer_id : undefined, }); } From 109c8f06a41a18e4c76eeae4f9b9c0012ab9c4b6 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:45:42 +0000 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20=F0=9F=A4=96=20ngrok=20for=20local?= =?UTF-8?q?=20stripe=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts index 2a0be9128..97d54cc71 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts @@ -46,6 +46,10 @@ export const handleGetOAuthUrl = createRoute({ serverUrl = `https://express.dev.useautumn.com`; } + if(process.env.NGROK_URL) { + serverUrl = process.env.NGROK_URL; + } + // Add state + redirect_uri baseUrl.searchParams.set("state", stateKey); baseUrl.searchParams.set( From a005f6d8793c8d857d6422e44d556d29981a956d Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 26 Nov 2025 10:28:02 +0000 Subject: [PATCH 7/7] refactor: separate subscriptions from customer cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split subscription data into separate cache keys for better performance - Updated cache utilities to handle subscriptions independently - Modified Lua scripts to manage separate subscription cache entries - Updated API models and expand logic for separated subscriptions - Fixed test utilities to match new data structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../_luaScripts/cusLuaScripts/setCustomer.lua | 1 + .../cusLuaScripts/setSubscriptions.lua | 20 ++++++---- .../entityLuaScripts/setEntitiesBatch.lua | 1 + .../entityLuaScripts/setEntity.lua | 1 + .../entityLuaScripts/setEntityProducts.lua | 34 +++++++++-------- .../luaUtils/getCustomerEntityUtils.lua | 28 +++++++++++++- server/src/external/redis/initRedis.ts | 4 +- .../handlers/handleProductsUpdated.ts | 6 ++- .../apiCusCacheUtils/setCachedApiCustomer.ts | 6 ++- .../apiCusCacheUtils/setCachedApiSubs.ts | 36 +++++++++++++----- .../apiCusUtils/getApiCustomerBase.ts | 7 +++- .../apiCusUtils/getApiCustomerExpand.ts | 1 + .../getApiSubscription/getApiSubscription.ts | 15 ++++++-- .../apiEntityUtils/getApiEntityBase.ts | 5 ++- .../entities/handlers/handleGetEntity.ts | 1 + .../utils/cacheUtils/normalizeFromSchema.ts | 16 ++++++-- .../tests/attach/downgrade/downgrade1.test.ts | 4 +- server/tests/attach/entities/entity5.test.ts | 24 ++++++++++-- .../expectUtils/expectProductAttached.ts | 29 ++++++++++++++ .../utils/expectUtils/expectScheduleUtils.ts | 38 +++++-------------- server/tests/utils/stripeUtils.ts | 2 +- shared/api/customers/apiCustomer.ts | 4 ++ .../customers/changes/V1.2_CustomerChange.ts | 21 +++++++--- .../V1.2_CustomerQueryChange.ts | 1 + shared/api/entities/apiEntity.ts | 1 + .../api/entities/changes/V1.2_EntityChange.ts | 18 ++++++++- shared/api/entities/entityOpModels.ts | 1 + .../requestChanges/V1.2_EntityQueryChange.ts | 1 + shared/models/cusModels/cusExpand.ts | 1 + shared/utils/expandUtils.ts | 11 ++++++ 30 files changed, 252 insertions(+), 86 deletions(-) diff --git a/server/src/_luaScripts/cusLuaScripts/setCustomer.lua b/server/src/_luaScripts/cusLuaScripts/setCustomer.lua index 0262f3dc3..440d5abc8 100644 --- a/server/src/_luaScripts/cusLuaScripts/setCustomer.lua +++ b/server/src/_luaScripts/cusLuaScripts/setCustomer.lua @@ -56,6 +56,7 @@ local baseCustomer = { env = customerData.env, metadata = customerData.metadata, subscriptions = customerData.subscriptions, + scheduled_subscriptions = customerData.scheduled_subscriptions, invoices = customerData.invoices, legacyData = customerData.legacyData, entities = customerData.entities, diff --git a/server/src/_luaScripts/cusLuaScripts/setSubscriptions.lua b/server/src/_luaScripts/cusLuaScripts/setSubscriptions.lua index 9e0358c56..7e63183af 100644 --- a/server/src/_luaScripts/cusLuaScripts/setSubscriptions.lua +++ b/server/src/_luaScripts/cusLuaScripts/setSubscriptions.lua @@ -1,14 +1,16 @@ -- setSubscriptions.lua --- Updates only the subscriptions array in the customer cache +-- Updates both subscriptions and scheduled_subscriptions arrays in the customer cache -- ARGV[1]: serialized subscriptions array JSON string (ApiSubscription[]) --- ARGV[2]: org_id --- ARGV[3]: env --- ARGV[4]: customer_id +-- ARGV[2]: serialized scheduled_subscriptions array JSON string (ApiSubscription[]) +-- ARGV[3]: org_id +-- ARGV[4]: env +-- ARGV[5]: customer_id local subscriptionsJson = ARGV[1] -local orgId = ARGV[2] -local env = ARGV[3] -local customerId = ARGV[4] +local scheduledSubscriptionsJson = ARGV[2] +local orgId = ARGV[3] +local env = ARGV[4] +local customerId = ARGV[5] -- Build versioned cache key using shared utility local cacheKey = buildCustomerCacheKey(orgId, env, customerId) @@ -23,9 +25,11 @@ end -- Decode the base customer and subscriptions local baseCustomer = cjson.decode(baseJson) local subscriptions = cjson.decode(subscriptionsJson) +local scheduledSubscriptions = cjson.decode(scheduledSubscriptionsJson) --- Update the subscriptions field +-- Update the subscriptions fields baseCustomer.subscriptions = subscriptions +baseCustomer.scheduled_subscriptions = scheduledSubscriptions -- Store updated base customer as JSON and extend TTL redis.call("SET", baseKey, cjson.encode(baseCustomer)) diff --git a/server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua b/server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua index ab8bed82e..12fdd6a63 100644 --- a/server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua +++ b/server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua @@ -41,6 +41,7 @@ for _, entityWrapper in ipairs(entities) do created_at = entityData.created_at, env = entityData.env, subscriptions = entityData.subscriptions, + scheduled_subscriptions = entityData.scheduled_subscriptions, legacyData = entityData.legacyData, _balanceFeatureIds = balanceFeatureIds } diff --git a/server/src/_luaScripts/entityLuaScripts/setEntity.lua b/server/src/_luaScripts/entityLuaScripts/setEntity.lua index cc44a7732..e3f02f84a 100644 --- a/server/src/_luaScripts/entityLuaScripts/setEntity.lua +++ b/server/src/_luaScripts/entityLuaScripts/setEntity.lua @@ -43,6 +43,7 @@ local baseEntity = { created_at = entityData.created_at, env = entityData.env, subscriptions = entityData.subscriptions, + scheduled_subscriptions = entityData.scheduled_subscriptions, legacyData = entityData.legacyData, _balanceFeatureIds = balanceFeatureIds } diff --git a/server/src/_luaScripts/entityLuaScripts/setEntityProducts.lua b/server/src/_luaScripts/entityLuaScripts/setEntityProducts.lua index 343f0af2a..c360c5429 100644 --- a/server/src/_luaScripts/entityLuaScripts/setEntityProducts.lua +++ b/server/src/_luaScripts/entityLuaScripts/setEntityProducts.lua @@ -1,16 +1,18 @@ -- setEntityProducts.lua --- Updates only the products array in the entity cache --- ARGV[1]: serialized products array JSON string --- ARGV[2]: org_id --- ARGV[3]: env --- ARGV[4]: customer_id --- ARGV[5]: entity_id +-- Updates both subscriptions and scheduled_subscriptions arrays in the entity cache +-- ARGV[1]: serialized subscriptions array JSON string (ApiSubscription[]) +-- ARGV[2]: serialized scheduled_subscriptions array JSON string (ApiSubscription[]) +-- ARGV[3]: org_id +-- ARGV[4]: env +-- ARGV[5]: customer_id +-- ARGV[6]: entity_id -local productsJson = ARGV[1] -local orgId = ARGV[2] -local env = ARGV[3] -local customerId = ARGV[4] -local entityId = ARGV[5] +local subscriptionsJson = ARGV[1] +local scheduledSubscriptionsJson = ARGV[2] +local orgId = ARGV[3] +local env = ARGV[4] +local customerId = ARGV[5] +local entityId = ARGV[6] -- Build versioned cache key using shared utility local cacheKey = buildEntityCacheKey(orgId, env, customerId, entityId) @@ -22,12 +24,14 @@ if not baseJson then return "OK" -- Entity doesn't exist, return early end --- Decode the base entity and products +-- Decode the base entity and subscriptions local baseEntity = cjson.decode(baseJson) -local products = cjson.decode(productsJson) +local subscriptions = cjson.decode(subscriptionsJson) +local scheduledSubscriptions = cjson.decode(scheduledSubscriptionsJson) --- Update only the products array -baseEntity.products = products +-- Update the subscriptions fields +baseEntity.subscriptions = subscriptions +baseEntity.scheduled_subscriptions = scheduledSubscriptions -- Store updated base entity as JSON and extend TTL redis.call("SET", baseKey, cjson.encode(baseEntity)) diff --git a/server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua b/server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua index 6dd70bfcd..a8d266a6a 100644 --- a/server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua +++ b/server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua @@ -73,6 +73,27 @@ local function getCustomerObject(orgId, env, customerId, skipEntityMerge) -- Merge subscriptions by plan ID and normalized status baseCustomer.subscriptions = mergeSubscriptions(allSubscriptions) + + -- Collect all scheduled_subscriptions: start with customer's scheduled_subscriptions, then add all entity scheduled_subscriptions + local allScheduledSubscriptions = {} + if baseCustomer.scheduled_subscriptions then + for _, subscription in ipairs(baseCustomer.scheduled_subscriptions) do + table.insert(allScheduledSubscriptions, subscription) + end + end + + -- Add scheduled_subscriptions from each entity + for _, entityId in ipairs(entityIds) do + local entityBase = entityBaseData[entityId] + if entityBase and entityBase.scheduled_subscriptions then + for _, subscription in ipairs(entityBase.scheduled_subscriptions) do + table.insert(allScheduledSubscriptions, subscription) + end + end + end + + -- Merge scheduled_subscriptions by plan ID and normalized status + baseCustomer.scheduled_subscriptions = mergeSubscriptions(allScheduledSubscriptions) -- Merge invoices -- Build final customer object @@ -135,21 +156,26 @@ local function getEntityObject(orgId, env, customerId, entityId, skipCustomerMer -- Get entity subscriptions (start with entity's own subscriptions) local entitySubscriptions = baseEntity.subscriptions or {} + local entityScheduledSubscriptions = baseEntity.scheduled_subscriptions or {} if not skipCustomerMerge then - -- Get customer subscriptions + -- Get customer subscriptions and scheduled_subscriptions local customerSubscriptions = nil + local customerScheduledSubscriptions = nil local customerBaseJson = redis.call("GET", customerCacheKey) if customerBaseJson then local customerBase = cjson.decode(customerBaseJson) customerSubscriptions = customerBase.subscriptions + customerScheduledSubscriptions = customerBase.scheduled_subscriptions end -- Merge customer subscriptions into entity subscriptions (only add if not exists) baseEntity.subscriptions = mergeCustomerSubscriptionsIntoEntity(entitySubscriptions, customerSubscriptions) + baseEntity.scheduled_subscriptions = mergeCustomerSubscriptionsIntoEntity(entityScheduledSubscriptions, customerScheduledSubscriptions) else -- No merging - just use entity's own subscriptions baseEntity.subscriptions = entitySubscriptions + baseEntity.scheduled_subscriptions = entityScheduledSubscriptions end -- Build final entity object diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index c9631b569..af416b825 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -135,12 +135,14 @@ declare module "ioredis" { ): Promise; setSubscriptions( subscriptionsJson: string, + scheduledSubscriptionsJson: string, orgId: string, env: string, customerId: string, ): Promise; setEntityProducts( - productsJson: string, + subscriptionsJson: string, + scheduledSubscriptionsJson: string, orgId: string, env: string, customerId: string, diff --git a/server/src/internal/analytics/handlers/handleProductsUpdated.ts b/server/src/internal/analytics/handlers/handleProductsUpdated.ts index 5761f413c..14531aa37 100644 --- a/server/src/internal/analytics/handlers/handleProductsUpdated.ts +++ b/server/src/internal/analytics/handlers/handleProductsUpdated.ts @@ -139,7 +139,11 @@ export const handleProductsUpdated = async ({ if (ctx.apiVersion.lte(ApiVersion.V1_2)) { addToExpand({ ctx, - add: [CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan], + add: [ + CusExpand.BalancesFeature, + CusExpand.SubscriptionsPlan, + CusExpand.ScheduledSubscriptionsPlan, + ], }); } diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts index 6bdf020f8..2863405ba 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts @@ -33,7 +33,11 @@ export const setCachedApiCustomer = async ({ const ctxWithExpand = addToExpand({ ctx, - add: [CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan], + add: [ + CusExpand.BalancesFeature, + CusExpand.SubscriptionsPlan, + CusExpand.ScheduledSubscriptionsPlan, + ], }); // Build master api customer (customer-level features only) diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts index e3c423755..1fc017cb5 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts @@ -12,8 +12,8 @@ import { getApiSubscriptions } from "../apiCusUtils/getApiSubscription/getApiSub /** * Set customer subscriptions cache in Redis with all entities - * This function updates only the subscriptions array in the customer cache (customer-level subscriptions only) - * and individual entity caches (entity-level subscriptions only) + * This function updates subscriptions and scheduled_subscriptions arrays in the customer cache (customer-level only) + * and individual entity caches (entity-level only) */ export const setCachedApiSubs = async ({ ctx, @@ -29,7 +29,7 @@ export const setCachedApiSubs = async ({ // Build master api customer subscriptions (customer-level products only) const ctxWithExpand = addToExpand({ ctx, - add: [CusExpand.SubscriptionsPlan], + add: [CusExpand.SubscriptionsPlan, CusExpand.ScheduledSubscriptionsPlan], }); const { data: masterApiSubs } = await getApiSubscriptions({ ctx: ctxWithExpand, @@ -41,19 +41,28 @@ export const setCachedApiSubs = async ({ }, }); + // Split subscriptions by status + const activeSubscriptions = masterApiSubs.filter( + (s) => s.status === "active", + ); + const scheduledSubscriptions = masterApiSubs.filter( + (s) => s.status === "scheduled", + ); + // console.log(`Updating api subs for customer ${customerId}`, masterApiSubs); // Then write to Redis await tryRedisWrite(async () => { - // Update customer subscriptions + // Update customer subscriptions and scheduled_subscriptions await redis.setSubscriptions( - JSON.stringify(masterApiSubs), + JSON.stringify(activeSubscriptions), + JSON.stringify(scheduledSubscriptions), org.id, env, customerId, ); logger.info( - `Updated customer subscriptions cache for customer ${customerId} (${masterApiSubs.length} subscriptions)`, + `Updated customer subscriptions cache for customer ${customerId} (${activeSubscriptions.length} active, ${scheduledSubscriptions.length} scheduled)`, ); // Update entity subscriptions @@ -65,7 +74,7 @@ export const setCachedApiSubs = async ({ org, }); - const { data: entityProducts } = await getApiSubscriptions({ + const { data: entitySubscriptions } = await getApiSubscriptions({ ctx: ctxWithExpand, fullCus: { ...fullCus, @@ -74,15 +83,24 @@ export const setCachedApiSubs = async ({ }, }); + // Split entity subscriptions by status + const entityActiveSubscriptions = entitySubscriptions.filter( + (s) => s.status === "active", + ); + const entityScheduledSubscriptions = entitySubscriptions.filter( + (s) => s.status === "scheduled", + ); + await redis.setEntityProducts( - JSON.stringify(entityProducts), + JSON.stringify(entityActiveSubscriptions), + JSON.stringify(entityScheduledSubscriptions), org.id, env, customerId, entity.id, ); logger.info( - `Updated entity subscriptions cache for entity ${entity.id} (${entityProducts.length} subscriptions)`, + `Updated entity subscriptions cache for entity ${entity.id} (${entityActiveSubscriptions.length} active, ${entityScheduledSubscriptions.length} scheduled)`, ); } }); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index 4c9979933..7f2d40809 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -52,7 +52,12 @@ export const getApiCustomerBase = async ({ env: fullCus.env, metadata: fullCus.metadata, - subscriptions: apiSubscriptions, + // subscriptions: apiSubscriptions, + subscriptions: apiSubscriptions.filter((s) => s.status === "active"), + scheduled_subscriptions: apiSubscriptions.filter( + (s) => s.status === "scheduled", + ), + balances: apiBalances, invoices: diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts index 918f91922..476e7d2cb 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts @@ -29,6 +29,7 @@ export const getApiCustomerExpand = async ({ filter: [ CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan, + CusExpand.ScheduledSubscriptionsPlan, CusExpand.Invoices, ], }); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts index a3b2ec0d2..8e870afbe 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts @@ -5,6 +5,7 @@ import { CusProductStatus, cusProductToPlanStatus, cusProductToProduct, + expandIncludes, type FullCusProduct, type FullCustomer, isTrialing, @@ -61,9 +62,17 @@ export const getApiSubscription = async ({ const status = cusProductToPlanStatus({ status: cusProduct.status }); // Check if we should expand the plan object - const shouldExpandPlan = (ctx.expand ?? []).includes( - CusExpand.SubscriptionsPlan, - ); + + const shouldExpandPlan = + status === "scheduled" + ? expandIncludes({ + expand: ctx.expand, + includes: [CusExpand.ScheduledSubscriptionsPlan], + }) + : expandIncludes({ + expand: ctx.expand, + includes: [CusExpand.SubscriptionsPlan], + }); const apiPlan = shouldExpandPlan ? await getPlanResponse({ diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts index 94f464590..8ef7dce1e 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts @@ -66,7 +66,10 @@ export const getApiEntityBase = async ({ created_at: entity.created_at, env: fullCus.env, - subscriptions: apiSubscriptions, + subscriptions: apiSubscriptions.filter((s) => s.status === "active"), + scheduled_subscriptions: apiSubscriptions.filter( + (s) => s.status === "scheduled", + ), balances: apiBalances, }); diff --git a/server/src/internal/entities/handlers/handleGetEntity.ts b/server/src/internal/entities/handlers/handleGetEntity.ts index 9f528f611..50e52e514 100644 --- a/server/src/internal/entities/handlers/handleGetEntity.ts +++ b/server/src/internal/entities/handlers/handleGetEntity.ts @@ -24,6 +24,7 @@ export const handleGetEntity = createRoute({ entityId: entity_id, withAutumnId: with_autumn_id, }); + const duration = Date.now() - start; ctx.logger.debug(`[get-entity] duration: ${duration}ms`); diff --git a/server/src/utils/cacheUtils/normalizeFromSchema.ts b/server/src/utils/cacheUtils/normalizeFromSchema.ts index ea9c34758..2eee9aeb0 100644 --- a/server/src/utils/cacheUtils/normalizeFromSchema.ts +++ b/server/src/utils/cacheUtils/normalizeFromSchema.ts @@ -156,10 +156,18 @@ export const normalizeFromSchema = ({ }; for (const key in shape) { - normalized[key] = normalizeFromSchema({ - schema: shape[key], - data: normalized[key], - }); + // Hardcoded fix: scheduled_subscriptions should be an array, never null/undefined + if ( + key === "scheduled_subscriptions" && + (normalized[key] === undefined || normalized[key] === null) + ) { + normalized[key] = []; + } else { + normalized[key] = normalizeFromSchema({ + schema: shape[key], + data: normalized[key], + }); + } } return normalized as T; diff --git a/server/tests/attach/downgrade/downgrade1.test.ts b/server/tests/attach/downgrade/downgrade1.test.ts index 8701912af..8f117cdc9 100644 --- a/server/tests/attach/downgrade/downgrade1.test.ts +++ b/server/tests/attach/downgrade/downgrade1.test.ts @@ -1,7 +1,5 @@ import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import chalk from "chalk"; -import type Stripe from "stripe"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { @@ -9,6 +7,8 @@ import { expectNextCycleCorrect, } from "@tests/utils/expectUtils/expectScheduleUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; diff --git a/server/tests/attach/entities/entity5.test.ts b/server/tests/attach/entities/entity5.test.ts index ace3bfe6c..8d57fb9c3 100644 --- a/server/tests/attach/entities/entity5.test.ts +++ b/server/tests/attach/entities/entity5.test.ts @@ -13,6 +13,10 @@ import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { + expectProductAttached, + expectScheduledApiSub, +} from "../../utils/expectUtils/expectProductAttached"; const testCase = "aentity5"; @@ -111,10 +115,24 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing downgrade entity pro }); const entity = await autumn.entities.get(customerId, entity1.id); - const proProd = entity.products.find((p: any) => p.id === pro.id); - expect(proProd).toBeDefined(); - expect(proProd.status).toBe(CusProductStatus.Scheduled); + + expectProductAttached({ + customer: entity, + product: pro, + status: CusProductStatus.Scheduled, + }); + + await expectScheduledApiSub({ + customerId, + entityId: entity1.id, + productId: pro.id, + }); + // const entity = await autumn.entities.get(customerId, entity1.id); + // const proProd = entity.products.find((p: any) => p.id === pro.id); + // expect(proProd).toBeDefined(); + // expect(proProd.status).toBe(CusProductStatus.Scheduled); }); + return; test("should advance test clock and have pro attached to entity 1", async () => { await advanceTestClock({ diff --git a/server/tests/utils/expectUtils/expectProductAttached.ts b/server/tests/utils/expectUtils/expectProductAttached.ts index 3260ea7dc..707d2d4a2 100644 --- a/server/tests/utils/expectUtils/expectProductAttached.ts +++ b/server/tests/utils/expectUtils/expectProductAttached.ts @@ -1,4 +1,7 @@ import { + type ApiCustomer, + type ApiSubscription, + ApiVersion, type CreateFreeTrial, CusProductStatus, type Entitlement, @@ -6,6 +9,7 @@ import { } from "@autumn/shared"; import type { Customer, ProductItem } from "autumn-js"; import { expect } from "chai"; +import { AutumnInt } from "../../../src/external/autumn/autumnCli"; export const expectProductAttached = ({ customer, @@ -63,6 +67,31 @@ export const expectProductAttached = ({ } }; +export const expectScheduledApiSub = async ({ + customerId, + entityId, + productId, +}: { + customerId: string; + entityId?: string; + productId: string; +}) => { + const autumnV2 = new AutumnInt({ + version: ApiVersion.V2_0, + secretKey: process.env.UNIT_TEST_AUTUMN_SECRET_KEY, + }); + + const entity = entityId + ? await autumnV2.entities.get(customerId, entityId) + : await autumnV2.customers.get(customerId); + + const scheduledSub = entity.scheduled_subscriptions.find( + (s: ApiSubscription) => s.plan_id === productId, + ); + expect(scheduledSub, `scheduled subscription ${productId} is attached`).to + .exist; +}; + export const expectProductV1Attached = ({ customer, product, diff --git a/server/tests/utils/expectUtils/expectScheduleUtils.ts b/server/tests/utils/expectUtils/expectScheduleUtils.ts index d767ce23e..3a47e9852 100644 --- a/server/tests/utils/expectUtils/expectScheduleUtils.ts +++ b/server/tests/utils/expectUtils/expectScheduleUtils.ts @@ -9,10 +9,10 @@ import { type Organization, type ProductV2, } from "@autumn/shared"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; import { expect } from "chai"; import { addHours } from "date-fns"; import type Stripe from "stripe"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { AutumnInt } from "@/external/autumn/autumnCli.js"; import { findStripePriceFromPrices } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; @@ -22,7 +22,10 @@ import { isV4Usage } from "@/internal/products/prices/priceUtils/usagePriceUtils import { isFreeProductV2 } from "@/internal/products/productUtils/classifyProduct.js"; import { hoursToFinalizeInvoice } from "../constants.js"; import { advanceTestClock } from "../stripeUtils.js"; -import { expectProductAttached } from "./expectProductAttached.js"; +import { + expectProductAttached, + expectScheduledApiSub, +} from "./expectProductAttached.js"; import { expectSubItemsCorrect } from "./expectSubUtils.js"; export const expectNextCycleCorrect = async ({ @@ -124,40 +127,19 @@ export const expectDowngradeCorrect = async ({ isCanceled: true, }); - // const { fullCus } = await expectSubItemsCorrect({ - // stripeCli, - // customerId, - // product: curProduct, - // db, - // org, - // env, - // subCanceled: isFreeProductV2({ product: newProduct }), - // isCanceled: true, - // }); - const newProductIsFree = isFreeProductV2({ product: newProduct }); - if (newProductIsFree) { - // let res = await stripeCli.subscriptionSchedules.list({ - // customer: fullCus.processor?.id, - // }); - // let data = res.data.filter((s) => s.status != "canceled"); - // expect(data.length, "should have no sub schedules").to.equal(0); - // await expectSubScheduleCorrect({ - // stripeCli, - // customerId, - // productId: newProduct.id, - // db, - // org, - // env, - // }); - } expectProductAttached({ customer, product: newProduct, status: CusProductStatus.Scheduled, }); + await expectScheduledApiSub({ + customerId, + productId: newProduct.id, + }); + await expectSubToBeCorrect({ db, customerId, diff --git a/server/tests/utils/stripeUtils.ts b/server/tests/utils/stripeUtils.ts index 1d5db2aa1..11f30c2aa 100644 --- a/server/tests/utils/stripeUtils.ts +++ b/server/tests/utils/stripeUtils.ts @@ -26,7 +26,7 @@ export const completeCheckoutForm = async ( _isLocal?: boolean, ) => { const browser = await puppeteer.launch({ - headless: true, + headless: false, executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", args: ["--no-sandbox", "--disable-setuid-sandbox"], }); diff --git a/shared/api/customers/apiCustomer.ts b/shared/api/customers/apiCustomer.ts index e6bf309fd..17bf25ed3 100644 --- a/shared/api/customers/apiCustomer.ts +++ b/shared/api/customers/apiCustomer.ts @@ -29,7 +29,11 @@ export const ApiCustomerSchema = z.object({ stripe_id: z.string().nullable(), env: z.enum(AppEnv), metadata: z.record(z.any(), z.any()), + subscriptions: z.array(ApiSubscriptionSchema), + + scheduled_subscriptions: z.array(ApiSubscriptionSchema), + balances: z.record(z.string(), ApiBalanceSchema), ...ApiCusExpandSchema.shape, }); diff --git a/shared/api/customers/changes/V1.2_CustomerChange.ts b/shared/api/customers/changes/V1.2_CustomerChange.ts index 7ff2d14ce..e73aa5d86 100644 --- a/shared/api/customers/changes/V1.2_CustomerChange.ts +++ b/shared/api/customers/changes/V1.2_CustomerChange.ts @@ -54,14 +54,23 @@ export const V1_2_CustomerChange = defineVersionChange({ // Response: V2.0 → V1.2 transformResponse: ({ input, legacyData }) => { // Step 1: Transform plans V2.0 → V1.2 (products) - const v3CusProducts: ApiCusProductV3[] = input.subscriptions.map( - (subscription: ApiSubscription) => - transformSubscriptionToCusProductV3({ - input: subscription, - legacyData: legacyData?.cusProductLegacyData[subscription.plan_id], - }), + const v3CusProducts: ApiCusProductV3[] = [ + ...input.subscriptions, + ...input.scheduled_subscriptions, + ].map((subscription: ApiSubscription) => + transformSubscriptionToCusProductV3({ + input: subscription, + legacyData: legacyData?.cusProductLegacyData[subscription.plan_id], + }), ); + v3CusProducts.sort((a, b) => { + if (a.is_add_on === b.is_add_on) { + return 0; + } + return a.is_add_on ? 1 : -1; + }); + // Step 2: Transform features V2.0 → V1.2 const v3_features: Record = {}; for (const [featureId, feature] of Object.entries(input.balances)) { diff --git a/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts b/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts index 63614924e..725363dd8 100644 --- a/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts +++ b/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts @@ -53,6 +53,7 @@ export const V1_2_CustomerQueryChange = defineVersionChange({ const newExpand = [ ...existingExpand, CusExpand.SubscriptionsPlan, + CusExpand.ScheduledSubscriptionsPlan, CusExpand.BalancesFeature, ]; diff --git a/shared/api/entities/apiEntity.ts b/shared/api/entities/apiEntity.ts index 88b934bb1..0342d9c5f 100644 --- a/shared/api/entities/apiEntity.ts +++ b/shared/api/entities/apiEntity.ts @@ -12,6 +12,7 @@ export const ApiEntityV1Schema = ApiBaseEntitySchema.extend({ description: "Plans associated with this entity", example: [], }), + scheduled_subscriptions: z.array(ApiSubscriptionSchema), balances: z.record(z.string(), ApiBalanceSchema).optional().meta({ description: "Features associated with this entity", }), diff --git a/shared/api/entities/changes/V1.2_EntityChange.ts b/shared/api/entities/changes/V1.2_EntityChange.ts index 7515b10eb..a3576ec9d 100644 --- a/shared/api/entities/changes/V1.2_EntityChange.ts +++ b/shared/api/entities/changes/V1.2_EntityChange.ts @@ -62,6 +62,22 @@ export const V1_2_EntityChange = defineVersionChange({ ) : undefined; + const scheduledCusProducts: ApiCusProductV3[] | undefined = + input.scheduled_subscriptions + ? input.scheduled_subscriptions.map((subscription: ApiSubscription) => + transformSubscriptionToCusProductV3({ + input: subscription, + legacyData: + legacyData?.cusProductLegacyData[subscription.plan_id], + }), + ) + : undefined; + + const finalCusProducts = [ + ...(v0CusProducts || []), + ...(scheduledCusProducts || []), + ]; + // Step 2: Transform features V1 → V0 let v0_features: Record | undefined; if (input.balances) { @@ -83,7 +99,7 @@ export const V1_2_EntityChange = defineVersionChange({ feature_id: input.feature_id, created_at: input.created_at, env: input.env, - products: v0CusProducts, + products: finalCusProducts, features: v0_features, invoices: input.invoices?.map((invoice: ApiInvoiceV1) => diff --git a/shared/api/entities/entityOpModels.ts b/shared/api/entities/entityOpModels.ts index a0f1436e8..217652d3b 100644 --- a/shared/api/entities/entityOpModels.ts +++ b/shared/api/entities/entityOpModels.ts @@ -23,6 +23,7 @@ export const GetEntityQuerySchema = z.object({ z.enum([ CusExpand.Invoices, CusExpand.SubscriptionsPlan, + CusExpand.ScheduledSubscriptionsPlan, CusExpand.BalancesFeature, ]), ).default([]), diff --git a/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts b/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts index a7ef2f810..e8df5ce79 100644 --- a/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts +++ b/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts @@ -57,6 +57,7 @@ export const V1_2_EntityQueryChange = defineVersionChange({ ...existingExpand, CusExpand.SubscriptionsPlan, CusExpand.BalancesFeature, + CusExpand.ScheduledSubscriptionsPlan, ] as GetEntityQuery["expand"]; return { diff --git a/shared/models/cusModels/cusExpand.ts b/shared/models/cusModels/cusExpand.ts index 7328f66f2..b7ba41458 100644 --- a/shared/models/cusModels/cusExpand.ts +++ b/shared/models/cusModels/cusExpand.ts @@ -21,5 +21,6 @@ export enum CusExpand { // PlansPlan = "plans.plan", SubscriptionsPlan = "subscriptions.plan", + ScheduledSubscriptionsPlan = "scheduled_subscriptions.plan", BalancesFeature = "balances.feature", } diff --git a/shared/utils/expandUtils.ts b/shared/utils/expandUtils.ts index 3310f8410..54d4d654e 100644 --- a/shared/utils/expandUtils.ts +++ b/shared/utils/expandUtils.ts @@ -69,5 +69,16 @@ export const filterPlanAndFeatureExpand = < } } + const expandScheduledSubscriptionPlan = expandIncludes({ + expand, + includes: [CusExpand.ScheduledSubscriptionsPlan], + }); + + if (!expandScheduledSubscriptionPlan && target.scheduled_subscriptions) { + for (let i = 0; i < target.scheduled_subscriptions?.length; i++) { + target.scheduled_subscriptions[i].plan = undefined; + } + } + return target as T; };