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; };