diff --git a/bun.lock b/bun.lock index f7ca5f8e5..ce8c63a80 100644 --- a/bun.lock +++ b/bun.lock @@ -56,6 +56,7 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.60.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.202.0", + "@opentelemetry/instrumentation-ioredis": "^0.56.0", "@opentelemetry/resources": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-node": "^0.202.0", @@ -861,7 +862,7 @@ "@opentelemetry/instrumentation-http": ["@opentelemetry/instrumentation-http@0.202.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/instrumentation": "0.202.0", "@opentelemetry/semantic-conventions": "^1.29.0", "forwarded-parse": "2.1.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-oX+jyY2KBg4/nVH3vZhSWDbhywkHgE0fq3YinhUBx0jv+YUWC2UKA7qLkxr/CSzfKsFi/Km0NKV+llH17yYGKw=="], - "@opentelemetry/instrumentation-ioredis": ["@opentelemetry/instrumentation-ioredis@0.50.1", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.202.0", "@opentelemetry/redis-common": "^0.38.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-HKrWKOM23qNwqNjWfzkw7mePversmcH5ac6T1dUdiRyJVYaLr4qfydyYkgKIGWHOF2TKvQGobXo3CjvxABQWVw=="], + "@opentelemetry/instrumentation-ioredis": ["@opentelemetry/instrumentation-ioredis@0.56.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.208.0", "@opentelemetry/redis-common": "^0.38.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XSWeqsd3rKSsT3WBz/JKJDcZD4QYElZEa0xVdX8f9dh4h4QgXhKRLorVsVkK3uXFbC2sZKAS2Ds+YolGwD83Dg=="], "@opentelemetry/instrumentation-kafkajs": ["@opentelemetry/instrumentation-kafkajs@0.11.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.202.0", "@opentelemetry/semantic-conventions": "^1.30.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+i9VqVEPNObB1tkwcLV6zAafnve72h2Iwo48E11M/kVXMNXlgGhiYckYCmzba8c2u5XD/V98XZDrCIyO8CLCNA=="], @@ -3497,6 +3498,8 @@ "@jridgewell/source-map/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@opentelemetry/auto-instrumentations-node/@opentelemetry/instrumentation-ioredis": ["@opentelemetry/instrumentation-ioredis@0.50.1", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.202.0", "@opentelemetry/redis-common": "^0.38.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-HKrWKOM23qNwqNjWfzkw7mePversmcH5ac6T1dUdiRyJVYaLr4qfydyYkgKIGWHOF2TKvQGobXo3CjvxABQWVw=="], + "@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="], "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="], @@ -3557,6 +3560,8 @@ "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="], + "@opentelemetry/instrumentation-ioredis/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA=="], + "@opentelemetry/instrumentation-pg/@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="], "@opentelemetry/instrumentation-redis/@opentelemetry/redis-common": ["@opentelemetry/redis-common@0.37.0", "", {}, "sha512-tJwgE6jt32bLs/9J6jhQRKU2EZnsD8qaO13aoFyXwF6s4LhpT7YFHf3Z03MqdILk6BA2BFUhoyh7k9fj9i032A=="], @@ -4175,6 +4180,12 @@ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@opentelemetry/instrumentation-ioredis/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/instrumentation-ioredis/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A=="], + + "@opentelemetry/instrumentation-ioredis/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw=="], "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="], diff --git a/server/package.json b/server/package.json index 1c31cfcff..205550b41 100644 --- a/server/package.json +++ b/server/package.json @@ -48,6 +48,7 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.60.1", "@opentelemetry/exporter-trace-otlp-proto": "^0.202.0", + "@opentelemetry/instrumentation-ioredis": "^0.56.0", "@opentelemetry/resources": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-node": "^0.202.0", diff --git a/server/src/_luaScripts/cusLuaScripts/setInvoices.lua b/server/src/_luaScripts/cusLuaScripts/setInvoices.lua new file mode 100644 index 000000000..5621da767 --- /dev/null +++ b/server/src/_luaScripts/cusLuaScripts/setInvoices.lua @@ -0,0 +1,35 @@ +-- setInvoices.lua +-- Updates only the invoices array in the customer cache +-- ARGV[1]: serialized invoices array JSON string (ApiInvoiceV1[]) +-- ARGV[2]: org_id +-- ARGV[3]: env +-- ARGV[4]: customer_id + +local invoicesJson = ARGV[1] +local orgId = ARGV[2] +local env = ARGV[3] +local customerId = ARGV[4] + +-- Build versioned cache key using shared utility +local cacheKey = buildCustomerCacheKey(orgId, env, customerId) +local baseKey = cacheKey + +-- Get base customer JSON +local baseJson = redis.call("GET", baseKey) +if not baseJson then + return "OK" -- Customer doesn't exist, return early +end + +-- Decode the base customer and invoices +local baseCustomer = cjson.decode(baseJson) +local invoices = cjson.decode(invoicesJson) + +-- Update the invoices field +baseCustomer.invoices = invoices + +-- Store updated base customer as JSON and extend TTL +redis.call("SET", baseKey, cjson.encode(baseCustomer)) +redis.call("EXPIRE", baseKey, CACHE_TTL_SECONDS) + +return "OK" + diff --git a/server/src/_luaScripts/luaScripts.ts b/server/src/_luaScripts/luaScripts.ts index 455693efa..7c136fe8c 100644 --- a/server/src/_luaScripts/luaScripts.ts +++ b/server/src/_luaScripts/luaScripts.ts @@ -87,6 +87,13 @@ const setCustomerDetailsScript = readFileSync( ); export const SET_CUSTOMER_DETAILS_SCRIPT = `${CACHE_KEY_UTILS}\n${setCustomerDetailsScript}`; +// Prepend cache key utils to SET_INVOICES_SCRIPT +const setInvoicesScript = readFileSync( + join(__dirname, "cusLuaScripts/setInvoices.lua"), + "utf-8", +); +export const SET_INVOICES_SCRIPT = `${CACHE_KEY_UTILS}\n${setInvoicesScript}`; + // Prepend cache key utils to DELETE_CUSTOMER_SCRIPT const deleteCustomerScript = readFileSync( join(__dirname, "cusLuaScripts/deleteCustomer.lua"), diff --git a/server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua b/server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua index d80a35cb2..6dd70bfcd 100644 --- a/server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua +++ b/server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua @@ -73,8 +73,10 @@ local function getCustomerObject(orgId, env, customerId, skipEntityMerge) -- Merge subscriptions by plan ID and normalized status baseCustomer.subscriptions = mergeSubscriptions(allSubscriptions) - + + -- Merge invoices -- Build final customer object + baseCustomer.invoices = baseCustomer.invoices or nil baseCustomer._balanceFeatureIds = nil -- Remove tracking field baseCustomer._entityIds = nil -- Remove tracking field baseCustomer.balances = balances diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index addc34c27..4c5f2ac5d 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -1,4 +1,8 @@ -import type { Organization } from "@autumn/shared"; +import { + CusExpand, + type FullCustomer, + type Organization, +} from "@autumn/shared"; import chalk from "chalk"; import { Stripe } from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; @@ -7,6 +11,7 @@ import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; import type { AutumnContext } from "../../honoUtils/HonoEnv.js"; import { deleteCachedApiCustomer } from "../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; +import { setCachedApiInvoices } from "../../internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.js"; import { setCachedApiSubs } from "../../internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.js"; import type { Logger } from "../logtail/logtailUtils.js"; import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js"; @@ -42,6 +47,13 @@ const coreEvents = [ "checkout.session.completed", ]; +const updateInvoiceEvents = [ + "invoice.paid", + "invoice.updated", + "invoice.created", + "invoice.finalized", +]; + const handleStripeWebhookRefresh = async ({ eventType, data, @@ -55,7 +67,8 @@ const handleStripeWebhookRefresh = async ({ if ( coreEvents.includes(eventType) || - updateProductEvents.includes(eventType) + updateProductEvents.includes(eventType) || + updateInvoiceEvents.includes(eventType) ) { const stripeCusId = data.object.customer; if (!stripeCusId) { @@ -83,25 +96,40 @@ const handleStripeWebhookRefresh = async ({ return; } - console.log( - `Attempting to refresh cache for customer: ${cus.id}, env: ${env}`, - ); + // console.log( + // `Attempting to refresh cache for customer: ${cus.id}, env: ${env}`, + // ); - if (updateProductEvents.includes(eventType)) { - const fullCus = await CusService.getFull({ + let fullCus: FullCustomer | undefined; + if ( + updateProductEvents.includes(eventType) || + updateInvoiceEvents.includes(eventType) + ) { + fullCus = await CusService.getFull({ db, idOrInternalId: cus.id!, orgId: org.id, env, withEntities: true, withSubs: true, + expand: [CusExpand.Invoices], }); - await setCachedApiSubs({ - ctx, - fullCus, - customerId: cus.id!, - }); + if (updateProductEvents.includes(eventType)) { + await setCachedApiSubs({ + ctx, + fullCus, + customerId: cus.id!, + }); + } + + if (updateInvoiceEvents.includes(eventType)) { + await setCachedApiInvoices({ + ctx, + fullCus, + customerId: cus.id!, + }); + } } else { logger.info(`Attempting delete cached api customer! ${eventType}`); await deleteCachedApiCustomer({ diff --git a/server/src/honoMiddlewares/betterAuthMiddleware.ts b/server/src/honoMiddlewares/betterAuthMiddleware.ts index ea75142cb..c6d5f3eb0 100644 --- a/server/src/honoMiddlewares/betterAuthMiddleware.ts +++ b/server/src/honoMiddlewares/betterAuthMiddleware.ts @@ -1,9 +1,8 @@ -import { type AppEnv, AuthType, ErrCode } from "@autumn/shared"; +import { type AppEnv, AuthType, ErrCode, RecaseError } from "@autumn/shared"; import type { Context, Next } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { auth } from "@/utils/auth.js"; -import RecaseError from "@/utils/errorUtils.js"; /** * Better Auth middleware for dashboard/session authentication @@ -26,7 +25,6 @@ export const betterAuthMiddleware = async (c: Context, next: Next) => { // Step 2: Validate session exists if (!session) { - ctx.logger.info(`Unauthorized - no session found (${c.req.url})`); throw new RecaseError({ message: "Unauthorized - no session found", code: ErrCode.NoAuthHeader, @@ -39,7 +37,6 @@ export const betterAuthMiddleware = async (c: Context, next: Next) => { const userId = session?.user?.id; if (!orgId) { - ctx.logger.info("Unauthorized - no org id found"); throw new RecaseError({ message: "Unauthorized - no org id found", code: ErrCode.InvalidAuthHeader, @@ -48,7 +45,6 @@ export const betterAuthMiddleware = async (c: Context, next: Next) => { } if (!userId) { - ctx.logger.info("Unauthorized - no user id found"); throw new RecaseError({ message: "Unauthorized - no user id found", code: ErrCode.InvalidAuthHeader, @@ -68,7 +64,6 @@ export const betterAuthMiddleware = async (c: Context, next: Next) => { }); if (!data) { - ctx.logger.warn(`Org ${orgId} not found in DB`); throw new RecaseError({ message: "Org not found", code: ErrCode.OrgNotFound, diff --git a/server/src/honoMiddlewares/secretKeyMiddleware.ts b/server/src/honoMiddlewares/secretKeyMiddleware.ts index e36e213f2..62e55e5f2 100644 --- a/server/src/honoMiddlewares/secretKeyMiddleware.ts +++ b/server/src/honoMiddlewares/secretKeyMiddleware.ts @@ -1,8 +1,7 @@ -import { AuthType, ErrCode } from "@autumn/shared"; +import { AuthType, ErrCode, RecaseError } from "@autumn/shared"; import type { Context, Next } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; import { betterAuthMiddleware } from "./betterAuthMiddleware.js"; import { publicKeyMiddleware } from "./publicKeyMiddleware.js"; diff --git a/server/src/honoMiddlewares/traceMiddleware.ts b/server/src/honoMiddlewares/traceMiddleware.ts index 4c93d78df..2ff5b7665 100644 --- a/server/src/honoMiddlewares/traceMiddleware.ts +++ b/server/src/honoMiddlewares/traceMiddleware.ts @@ -3,7 +3,7 @@ import type { Context, Next } from "hono"; import { logger } from "@/external/logtail/logtailUtils.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -const tracer = trace.getTracer("hono"); +const tracer = trace.getTracer("express"); /** * Tracing middleware for OpenTelemetry spans diff --git a/server/src/init.ts b/server/src/init.ts index d07264f5d..c00599539 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -1,10 +1,11 @@ // Suppress BullMQ eviction policy warnings BEFORE any imports // Skip OpenTelemetry instrumentation in development for faster startup -if (process.env.NODE_ENV !== "development") { - await import("./instrumentation.js"); - await import("./sentry.js"); -} +// if (process.env.NODE_ENV !== "development") { +await import("./instrumentation.js"); +await import("./sentry.js"); + +// } import cluster from "node:cluster"; import http from "node:http"; diff --git a/server/src/instrumentation.ts b/server/src/instrumentation.ts index 7d69f1af3..2928609f9 100644 --- a/server/src/instrumentation.ts +++ b/server/src/instrumentation.ts @@ -26,10 +26,14 @@ if (process.env.AXIOM_TOKEN) { const sdk = new NodeSDK({ spanProcessor: new BatchSpanProcessor(traceExporter), resource: resource, - instrumentations: [getNodeAutoInstrumentations()], + instrumentations: [ + // Then add other auto-instrumentations + getNodeAutoInstrumentations(), + ], }); // Starting the OpenTelemetry SDK to begin collecting telemetry data console.log("Starting OpenTelemetry"); sdk.start(); + console.log("OpenTelemetry started with IORedis instrumentation"); } diff --git a/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts b/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts index 7f65fde10..19efc4cd8 100644 --- a/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts +++ b/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts @@ -1,5 +1,6 @@ import { ErrCode, + RecaseError, RewardCategory, type RewardRedemption, RewardTriggerEvent, @@ -13,7 +14,6 @@ import { RewardService } from "@/internal/rewards/RewardService.js"; import { triggerFreeProduct } from "@/internal/rewards/referralUtils/triggerFreeProduct.js"; import { triggerRedemption } from "@/internal/rewards/referralUtils.js"; import { getRewardCat } from "@/internal/rewards/rewardUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; import { generateId, notNullish } from "@/utils/genUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; @@ -200,20 +200,3 @@ export default async (req: any, res: any) => }); }, }); - -// res.status(200).json({ -// id: redemption.id, -// customer_id: customer.id, -// reward_id: reward_program.reward.id, -// referrer: { -// id: codeCustomer.id, -// name: codeCustomer.name, -// email: codeCustomer.email, -// created_at: codeCustomer.created_at, -// code: applications.referrer.cause, -// }, -// redeemer: { -// code: applications.redeemer.cause, -// ...applications.redeemer.meta, -// }, -// }); diff --git a/server/src/internal/balances/setUsage/handleSetUsage.ts b/server/src/internal/balances/setUsage/handleSetUsage.ts index 791386372..80cd25234 100644 --- a/server/src/internal/balances/setUsage/handleSetUsage.ts +++ b/server/src/internal/balances/setUsage/handleSetUsage.ts @@ -22,13 +22,8 @@ export const handleSetUsage = createRoute({ customerId: body.customer_id, entityId: body.entity_id, deductions: featureDeductions, - // eventInfo: { - // event_name: body.feature_id || body.event_name!, - // value: body.value ?? 1, - // properties: body.properties, - // timestamp: body.timestamp, - // idempotency_key: body.idempotency_key, - // }, + + refreshCache: true, }); const elapsed = Date.now() - start; diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts index ec587f168..add51960b 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts @@ -2,6 +2,8 @@ import { type ApiCustomer, ApiCustomerSchema, type AppEnv, + addToExpand, + CusExpand, type CustomerLegacyData, CustomerLegacyDataSchema, filterOutEntitiesFromCusProducts, @@ -92,7 +94,7 @@ export const getCachedApiCustomer = async ({ } // Cache miss or skipCache - fetch from DB - + // Include invoices: const fullCus = await CusService.getFull({ db, idOrInternalId: customerId, @@ -100,12 +102,16 @@ export const getCachedApiCustomer = async ({ env: env as AppEnv, withEntities: true, withSubs: true, + expand: [CusExpand.Invoices], }); // Build ApiCustomer (base only, no expand) to return - - const { apiCustomer, legacyData } = await getApiCustomerBase({ + const ctxWithExpand = addToExpand({ ctx, + add: [CusExpand.Invoices], + }); + const { apiCustomer, legacyData } = await getApiCustomerBase({ + ctx: ctxWithExpand, fullCus, withAutumnId: true, }); @@ -147,7 +153,12 @@ export const getCachedApiCustomer = async ({ }); return { - apiCustomer: filteredApiCustomer, + apiCustomer: { + ...filteredApiCustomer, + rewards: filteredApiCustomer.rewards ?? undefined, + referrals: filteredApiCustomer.referrals ?? undefined, + payment_method: filteredApiCustomer.payment_method ?? undefined, + }, legacyData, }; }; diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts new file mode 100644 index 000000000..9d6594e7e --- /dev/null +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts @@ -0,0 +1,60 @@ +import type { FullCustomer } from "@autumn/shared"; +import { SET_INVOICES_SCRIPT } from "@lua/luaScripts.js"; +import { redis } from "../../../../external/redis/initRedis.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js"; +import { InvoiceService } from "../../../invoices/InvoiceService.js"; +import { invoicesToResponse } from "../../../invoices/invoiceUtils.js"; + +/** + * Set customer invoices cache in Redis with all entities + * This function updates only the invoices array in the customer cache (customer-level invoices only) + * and individual entity caches (entity-level invoices only) + */ +export const setCachedApiInvoices = async ({ + ctx, + fullCus, + customerId, +}: { + ctx: AutumnContext; + fullCus: FullCustomer; + customerId: string; +}) => { + const { org, env, logger, db } = ctx; + + // Get customer-level invoices (no entity or null entity) + const invoices = fullCus.invoices + ? fullCus.invoices + : await InvoiceService.list({ + db, + internalCustomerId: fullCus.internal_id, + limit: 10, + }); + + // Filter to only customer-level invoices (exclude entity-specific) + const customerLevelInvoices = invoices.filter( + (invoice) => !invoice.internal_entity_id, + ); + + // Build master api customer invoices (customer-level only) + const masterApiInvoices = invoicesToResponse({ + invoices: customerLevelInvoices, + logger, + }); + + // Then write to Redis + await tryRedisWrite(async () => { + // Update customer invoices + await redis.eval( + SET_INVOICES_SCRIPT, + 0, // No KEYS, all params in ARGV + JSON.stringify(masterApiInvoices), + org.id, + env, + customerId, + ); + logger.info( + `Updated customer invoices cache for customer ${customerId} (${masterApiInvoices.length} invoices)`, + ); + }); +}; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts index fba977413..50b94a869 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts @@ -2,6 +2,7 @@ import { AffectedResource, type ApiCustomer, applyResponseVersionChanges, + CusExpand, type CustomerLegacyData, type FullCustomer, } from "@autumn/shared"; @@ -29,14 +30,11 @@ export const getApiCustomer = async ({ let baseCustomer: ApiCustomer; let cusLegacyData: CustomerLegacyData; if (!baseData) { - const start = Date.now(); const { apiCustomer, legacyData } = await getCachedApiCustomer({ ctx, customerId: customerId || "", }); - ctx.logger.info(`getCachedApiCustomer: ${Date.now() - start}ms`); - baseCustomer = apiCustomer; cusLegacyData = legacyData; } else { @@ -49,19 +47,21 @@ export const getApiCustomer = async ({ ...baseCustomer, entities: undefined, autumn_id: withAutumnId ? baseCustomer.autumn_id : undefined, + invoices: ctx.expand.includes(CusExpand.Invoices) + ? baseCustomer.invoices + : undefined, }; return { baseCustomer, cusLegacyData }; }; const getExpandParams = async () => { - const expandStart = Date.now(); const apiCusExpand = await getApiCustomerExpand({ ctx, customerId, fullCus: fullCus || undefined, }); - ctx.logger.info(`getApiCustomerExpand: ${Date.now() - expandStart}ms`); + return apiCusExpand; }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index c097b809e..4c9979933 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -1,11 +1,13 @@ import { type ApiCustomer, ApiCustomerSchema, + CusExpand, type CustomerLegacyData, type FullCustomer, } from "@autumn/shared"; import { z } from "zod/v4"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; +import { invoicesToResponse } from "../../../invoices/invoiceUtils.js"; import { getApiBalances } from "./getApiBalance/getApiBalances.js"; import { getApiSubscriptions } from "./getApiSubscription/getApiSubscriptions.js"; @@ -52,6 +54,14 @@ export const getApiCustomerBase = async ({ subscriptions: apiSubscriptions, balances: apiBalances, + + invoices: + fullCus.invoices && ctx.expand.includes(CusExpand.Invoices) + ? invoicesToResponse({ + invoices: fullCus.invoices, + logger: ctx.logger, + }) + : undefined, }); return { diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts index 552535b87..918f91922 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts @@ -7,13 +7,10 @@ import { filterExpand, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js"; -import { InvoiceService } from "../../../invoices/InvoiceService.js"; import { CusService } from "../../CusService.js"; import { getCusPaymentMethodRes } from "../cusResponseUtils/getCusPaymentMethodRes.js"; import { getCusReferrals } from "../cusResponseUtils/getCusReferrals.js"; import { getCusRewards } from "../cusResponseUtils/getCusRewards.js"; -import { getCusUpcomingInvoice } from "../cusResponseUtils/getCusUpcomingInvoice.js"; export const getApiCustomerExpand = async ({ ctx, @@ -29,7 +26,11 @@ export const getApiCustomerExpand = async ({ // Filter out balances.feature and subscriptions.plan const filteredExpand = filterExpand({ expand, - filter: [CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan], + filter: [ + CusExpand.BalancesFeature, + CusExpand.SubscriptionsPlan, + CusExpand.Invoices, + ], }); if (filteredExpand.length === 0) return {}; @@ -48,7 +49,11 @@ export const getApiCustomerExpand = async ({ const getCusTrialsUsed = () => { if (expand.includes(CusExpand.TrialsUsed)) { - return fullCus.trials_used; + return fullCus.trials_used?.map((t) => ({ + plan_id: t.product_id, + customer_id: t.customer_id, + fingerprint: t.fingerprint, + })); } return undefined; }; @@ -60,62 +65,46 @@ export const getApiCustomerExpand = async ({ return undefined; }; - const getInvoices = async () => { - if (!expand.includes(CusExpand.Invoices)) return undefined; - - const invoices = await InvoiceService.list({ - db, - internalCustomerId: fullCus.internal_id, - internalEntityId: fullCus.entity?.internal_id, - }); - - return invoicesToResponse({ - invoices, - logger, - }); - }; - const cusExpand = expand as CusExpand[]; - const [rewards, upcomingInvoice, referrals, paymentMethod, invoices] = - await Promise.all([ - getCusRewards({ - org, - env, - fullCus, - subIds: fullCus.customer_products.flatMap( - (cp: FullCusProduct) => cp.subscription_ids || [], - ), - expand: cusExpand, - }), - getCusUpcomingInvoice({ - db, - org, - env, - fullCus, - expand: cusExpand, - }), - getCusReferrals({ - db, - fullCus, - expand: cusExpand, - }), - getCusPaymentMethodRes({ - org, - env, - fullCus, - expand: cusExpand, - }), - getInvoices(), - ]); + const [rewards, referrals, paymentMethod] = await Promise.all([ + getCusRewards({ + org, + env, + fullCus, + subIds: fullCus.customer_products.flatMap( + (cp: FullCusProduct) => cp.subscription_ids || [], + ), + expand: cusExpand, + }), + + getCusReferrals({ + db, + fullCus, + expand: cusExpand, + }), + getCusPaymentMethodRes({ + org, + env, + fullCus, + expand: cusExpand, + }), + ]); return { - trials_used: getCusTrialsUsed(), - entities: getApiCusEntities(), - rewards, - upcoming_invoice: upcomingInvoice, - referrals, - payment_method: paymentMethod, - invoices, + trials_used: getCusTrialsUsed() ?? undefined, + entities: getApiCusEntities() ?? undefined, + rewards: rewards ?? undefined, + // upcoming_invoice: upcomingInvoice, + referrals: referrals ?? undefined, + payment_method: paymentMethod ?? undefined, }; }; + +// getCusUpcomingInvoice({ +// db, +// org, +// env, +// fullCus, +// expand: cusExpand, +// }), diff --git a/server/src/internal/customers/cusUtils/cusUtils.ts b/server/src/internal/customers/cusUtils/cusUtils.ts index 2486e8d95..45a9a1867 100644 --- a/server/src/internal/customers/cusUtils/cusUtils.ts +++ b/server/src/internal/customers/cusUtils/cusUtils.ts @@ -1,6 +1,6 @@ import { type ApiCustomer, - type ApiInvoice, + type ApiInvoiceV1, CusExpand, type Customer, type CustomerData, @@ -88,7 +88,7 @@ export const getCusInvoices = async ({ limit?: number; withItems?: boolean; features?: Feature[]; -}): Promise => { +}): Promise => { const finalInvoices = notNullish(invoices) ? invoices : await InvoiceService.list({ diff --git a/server/src/internal/customers/handlers/handleGetCustomerV2.ts b/server/src/internal/customers/handlers/handleGetCustomerV2.ts index 98fde489d..e91bd482e 100644 --- a/server/src/internal/customers/handlers/handleGetCustomerV2.ts +++ b/server/src/internal/customers/handlers/handleGetCustomerV2.ts @@ -32,11 +32,14 @@ export const handleGetCustomerV2 = createRoute({ expand.push(CusExpand.Invoices); } + const start = Date.now(); const customer = await getApiCustomer({ ctx, customerId, withAutumnId: with_autumn_id, }); + const duration = Date.now() - start; + console.debug(`[get-customer] getApiCustomer duration: ${duration}ms`); return c.json(customer); }, diff --git a/server/src/internal/customers/handlers/handlePostCustomerV2.ts b/server/src/internal/customers/handlers/handlePostCustomerV2.ts index 3e6b2b88a..9e9627fc0 100644 --- a/server/src/internal/customers/handlers/handlePostCustomerV2.ts +++ b/server/src/internal/customers/handlers/handlePostCustomerV2.ts @@ -35,6 +35,7 @@ export const handlePostCustomer = createRoute({ expand.push(CusExpand.Invoices); } + const start = Date.now(); const baseData = await getOrCreateApiCustomer({ ctx, customerId: createCusParams.id, @@ -53,6 +54,8 @@ export const handlePostCustomer = createRoute({ }, }, }); + const duration = Date.now() - start; + console.debug(`[post-customer] duration: ${duration}ms`); return c.json(apiCustomer); }, diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts index 25f8b7f13..07c25d8af 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts @@ -41,6 +41,7 @@ export const getApiEntity = async ({ }; // Get expand fields (not cacheable) + const apiEntityExpand = await getApiEntityExpand({ ctx, customerId, diff --git a/server/src/internal/entities/handlers/handleGetEntity.ts b/server/src/internal/entities/handlers/handleGetEntity.ts index fe5a95fd5..5aea4c3bc 100644 --- a/server/src/internal/entities/handlers/handleGetEntity.ts +++ b/server/src/internal/entities/handlers/handleGetEntity.ts @@ -17,12 +17,15 @@ export const handleGetEntity = createRoute({ const ctx = c.get("ctx"); const { with_autumn_id } = c.req.valid("query"); + const start = Date.now(); const apiEntity = await getApiEntity({ ctx, customerId: customer_id, entityId: entity_id, withAutumnId: with_autumn_id, }); + const duration = Date.now() - start; + console.debug(`[get-entity] duration: ${duration}ms`); return c.json(apiEntity); }, diff --git a/server/src/internal/features/FeatureService.ts b/server/src/internal/features/FeatureService.ts index 640147509..9851be630 100644 --- a/server/src/internal/features/FeatureService.ts +++ b/server/src/internal/features/FeatureService.ts @@ -1,7 +1,14 @@ -import { type AppEnv, ErrCode, type Feature, features } from "@autumn/shared"; +import { + type AppEnv, + ErrCode, + type Feature, + FeatureAlreadyExistsError, + features, + RecaseError, +} from "@autumn/shared"; import { and, eq } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import RecaseError from "@/utils/errorUtils.js"; + import { notNullish } from "@/utils/genUtils.js"; import { clearOrgCache } from "../orgs/orgUtils/clearOrgCache.js"; @@ -151,11 +158,11 @@ export class FeatureService { return insertedData as Feature[]; // DRIZZLE TYPE REFACTOR } catch (error: any) { if (error.code === "23505") { - const id = Array.isArray(data) ? data.map((f) => f.id) : data.id; - throw new RecaseError({ - message: `Feature ${id} already exists`, - code: ErrCode.DuplicateFeatureId, - statusCode: 400, + const id = Array.isArray(data) + ? data.map((f) => f.id)?.join(",") + : data.id; + throw new FeatureAlreadyExistsError({ + featureId: id, }); } } diff --git a/server/src/internal/invoices/InvoiceService.ts b/server/src/internal/invoices/InvoiceService.ts index e1b6f8766..9ec6c59b0 100644 --- a/server/src/internal/invoices/InvoiceService.ts +++ b/server/src/internal/invoices/InvoiceService.ts @@ -1,6 +1,5 @@ import { - type ApiInvoice, - ApiInvoiceItemSchema, + type ApiInvoiceV1, type Customer, type Feature, type Invoice, @@ -25,32 +24,33 @@ export const processInvoice = ({ invoice: Invoice; withItems?: boolean; features?: Feature[]; -}) => { +}): ApiInvoiceV1 => { return { - product_ids: invoice.product_ids, + // product_ids: invoice.product_ids, + plan_ids: invoice.product_ids, stripe_id: invoice.stripe_id, - status: invoice.status, + status: invoice.status ?? "", total: invoice.total, currency: invoice.currency, created_at: invoice.created_at, - // hosted_invoice_url: invoice.hosted_invoice_url, hosted_invoice_url: `${process.env.BETTER_AUTH_URL}/invoices/hosted_invoice_url/${invoice.id}`, - items: withItems - ? (invoice.items || []).map((i) => { - const feature = features?.find( - (f) => f.internal_id === i.internal_feature_id, - ); + // hosted_invoice_url: invoice.hosted_invoice_url, + // items: withItems + // ? (invoice.items || []).map((i) => { + // const feature = features?.find( + // (f) => f.internal_id === i.internal_feature_id, + // ); - return ApiInvoiceItemSchema.parse({ - description: i.description, - period_start: i.period_start, - period_end: i.period_end, - feature_id: feature?.id, - feature_name: feature?.name, - }); - }) - : undefined, - } as ApiInvoice; + // return ApiInvoiceItemSchema.parse({ + // description: i.description, + // period_start: i.period_start, + // period_end: i.period_end, + // feature_id: feature?.id, + // feature_name: feature?.name, + // }); + // }) + // : undefined, + }; }; export class InvoiceService { diff --git a/server/src/utils/cacheUtils/cacheUtils.ts b/server/src/utils/cacheUtils/cacheUtils.ts index 6bf72d397..bc55d2b11 100644 --- a/server/src/utils/cacheUtils/cacheUtils.ts +++ b/server/src/utils/cacheUtils/cacheUtils.ts @@ -1,6 +1,9 @@ +import { trace } from "@opentelemetry/api"; import { redis } from "@/external/redis/initRedis.js"; import { logger } from "../../external/logtail/logtailUtils.js"; +const tracer = trace.getTracer("redis"); + /** * Executes a Redis write operation with automatic fallback handling. * Returns the result of the operation if successful, null if Redis is unavailable or operation fails. @@ -12,20 +15,30 @@ import { logger } from "../../external/logtail/logtailUtils.js"; export const tryRedisWrite = async ( operation: () => Promise, ): Promise => { - if (redis.status !== "ready") { - logger.error("Redis not ready, skipping write"); - return null as T extends void ? true : T | null; - } + const span = tracer.startSpan("redis.write"); try { + if (redis.status !== "ready") { + logger.error("Redis not ready, skipping write"); + span.setStatus({ code: 2, message: "Redis not ready" }); + return null as T extends void ? true : T | null; + } + const result = await operation(); + span.setStatus({ code: 1 }); // OK // If operation returns void/undefined, return true; otherwise return the result return (result === undefined ? true : result) as T extends void ? true : T | null; } catch (error) { logger.error(`Redis write failed: ${error}`); + span.setStatus({ + code: 2, + message: error instanceof Error ? error.message : String(error), + }); return null as T extends void ? true : T | null; + } finally { + span.end(); } }; @@ -39,15 +52,26 @@ export const tryRedisWrite = async ( export const tryRedisRead = async ( operation: () => Promise, ): Promise => { - if (redis.status !== "ready") { - logger.error("Redis not ready, skipping read"); - return null; - } + const span = tracer.startSpan("redis.read"); try { - return await operation(); + if (redis.status !== "ready") { + logger.error("Redis not ready, skipping read"); + span.setStatus({ code: 2, message: "Redis not ready" }); + return null; + } + + const result = await operation(); + span.setStatus({ code: 1 }); // OK + return result; } catch (error) { logger.error(`Redis read failed: ${error}`); + span.setStatus({ + code: 2, + message: error instanceof Error ? error.message : String(error), + }); return null; + } finally { + span.end(); } }; diff --git a/server/tests/_temp/temp1.test.ts b/server/tests/_temp/temp1.test.ts index 1618fd2bc..a90afb940 100644 --- a/server/tests/_temp/temp1.test.ts +++ b/server/tests/_temp/temp1.test.ts @@ -7,12 +7,14 @@ import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; import { constructArrearItem, - constructArrearProratedItem, constructFeatureItem, } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { attachFailedPaymentMethod } from "../../src/external/stripe/stripeCusUtils.js"; +import { CusService } from "../../src/internal/customers/CusService.js"; import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0.js"; +import { advanceClockForInvoice } from "../utils/stripeUtils.js"; import { replaceItems } from "../utils/testProductUtils/testProductUtils.js"; // UNCOMMENT FROM HERE @@ -28,11 +30,7 @@ const pro = constructProduct({ featureId: TestFeature.Words, includedUsage: 300, }), - constructArrearProratedItem({ - featureId: TestFeature.Workflows, - includedUsage: 0, - pricePerUnit: 10, - }), + // constructFeatureItem({ // featureId: TestFeature.Words, // includedUsage: 100, @@ -61,25 +59,29 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => { prefix: customerId, }); - // testClockId = result.testClockId!; + testClockId = result.testClockId!; await autumn.attach({ customer_id: customerId, product_id: pro.id, }); - await autumn.entities.create(customerId, [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]); + const cus = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + await attachFailedPaymentMethod({ + stripeCli: ctx.stripeCli, + customer: cus!, + }); + + await advanceClockForInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId, + }); }); return; diff --git a/server/tests/attach/entities/entity1.test.ts b/server/tests/attach/entities/entity1.test.ts index a943538d9..62d8c5435 100644 --- a/server/tests/attach/entities/entity1.test.ts +++ b/server/tests/attach/entities/entity1.test.ts @@ -65,6 +65,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach to entity via }); const customer = await autumn.customers.get(customerId); + // console.log("customer products:", customer.products); expectProductAttached({ customer, diff --git a/server/tests/attach/migrations/migration3.test.ts b/server/tests/attach/migrations/migration3.test.ts index 6e5c05761..ad339a7c6 100644 --- a/server/tests/attach/migrations/migration3.test.ts +++ b/server/tests/attach/migrations/migration3.test.ts @@ -1,13 +1,16 @@ -import { BillingInterval, ProductItemInterval, type ProductV2 } from "@autumn/shared"; -import { beforeAll, describe, expect, test } from "bun:test"; -import chalk from "chalk"; -import { addDays } from "date-fns"; -import type Stripe from "stripe"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { beforeAll, describe, test } from "bun:test"; +import { + BillingInterval, + ProductItemInterval, + type ProductV2, +} from "@autumn/shared"; import { defaultApiVersion } from "@tests/constants.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { addDays } from "date-fns"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -34,8 +37,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro with tria const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); let testClockId: string; - const curUnix = new Date().getTime(); - beforeAll(async () => { await initProductsV0({ ctx, diff --git a/server/tests/merged/mergeUtils/expectSubCorrect.ts b/server/tests/merged/mergeUtils/expectSubCorrect.ts index 70c2fb9c5..146ec9095 100644 --- a/server/tests/merged/mergeUtils/expectSubCorrect.ts +++ b/server/tests/merged/mergeUtils/expectSubCorrect.ts @@ -9,8 +9,8 @@ import { type Organization, } from "@autumn/shared"; import { notNullish } from "@shared/utils/utils.js"; -import type Stripe from "stripe"; import { defaultApiVersion } from "@tests/constants.js"; +import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem.js"; @@ -117,9 +117,7 @@ const compareActualItems = async ({ console.log("--------------------------------"); } - expect(actualItem?.quantity).toBe( - (expectedItem as any).quantity, - ); + expect(actualItem?.quantity).toBe((expectedItem as any).quantity); } expect(actualItems.length).toBe(expectedItems.length); @@ -163,6 +161,7 @@ export const expectSubToBeCorrect = async ({ // 1. Only 1 sub ID available let cusProducts = fullCus.customer_products; + if (!subId) { const subIds = cusProductToSubIds({ cusProducts }); subId = subIds[0]; @@ -220,7 +219,8 @@ export const expectSubToBeCorrect = async ({ cusProduct.status === CusProductStatus.Scheduled && cusProductInPhase({ phaseStartMillis: unix, cusProduct }) ) { - return scheduleIndexes.push(index); + scheduleIndexes.push(index); + return; } if (cusProduct.status === CusProductStatus.Scheduled) return; @@ -228,9 +228,11 @@ export const expectSubToBeCorrect = async ({ if (cusProduct.product.is_add_on) { // 1. If it's canceled if (cusProduct.canceled && (cusProduct.ended_at || 0) > unix) { - return scheduleIndexes.push(index); + scheduleIndexes.push(index); + return; } else if (!cusProduct.canceled) { - return scheduleIndexes.push(index); + scheduleIndexes.push(index); + return; } return; @@ -246,7 +248,10 @@ export const expectSubToBeCorrect = async ({ : nullish(cp.internal_entity_id)), ); - if (!curScheduledProduct) return scheduleIndexes.push(index); + if (!curScheduledProduct) { + scheduleIndexes.push(index); + return; + } // If scheduled product NOT in phase, add main product to schedule if ( diff --git a/server/tests/merged/separate/separate2.test.ts b/server/tests/merged/separate/separate2.test.ts index 81bf4f75c..96bd594c1 100644 --- a/server/tests/merged/separate/separate2.test.ts +++ b/server/tests/merged/separate/separate2.test.ts @@ -161,6 +161,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing separate subscriptions beca product_id: premium.id, entity_id: id, }); + await expectSubToBeCorrect({ db, customerId, diff --git a/shared/api/customers/apiCustomer.ts b/shared/api/customers/apiCustomer.ts index 4a0da64ba..e6bf309fd 100644 --- a/shared/api/customers/apiCustomer.ts +++ b/shared/api/customers/apiCustomer.ts @@ -1,22 +1,22 @@ import { ApiBaseEntitySchema } from "@api/entities/apiBaseEntity.js"; import { ApiCusRewardsSchema } from "@api/others/apiDiscount.js"; -import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; +import { ApiInvoiceV1Schema } from "@api/others/apiInvoice/apiInvoiceV1.js"; import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; import { ApiCusReferralSchema } from "./components/apiCusReferral.js"; -import { ApiCusUpcomingInvoiceSchema } from "./components/apiCusUpcomingInvoice.js"; -import { ApiTrialsUsedSchema } from "./components/apiTrialsUsed.js"; +import { ApiTrialsUsedV1Schema } from "./components/apiTrialsUsed/apiTrialsUsedV1.js"; +// import { ApiCusUpcomingInvoiceSchema } from "./components/apiCusUpcomingInvoice.js"; import { ApiBalanceSchema } from "./cusFeatures/apiBalance.js"; import { ApiSubscriptionSchema } from "./cusPlans/apiSubscription.js"; export const ApiCusExpandSchema = z.object({ - invoices: z.array(ApiInvoiceSchema).optional(), + invoices: z.array(ApiInvoiceV1Schema).optional(), entities: z.array(ApiBaseEntitySchema).optional(), - trials_used: z.array(ApiTrialsUsedSchema).optional(), + trials_used: z.array(ApiTrialsUsedV1Schema).optional(), rewards: ApiCusRewardsSchema.nullish(), referrals: z.array(ApiCusReferralSchema).optional(), - upcoming_invoice: ApiCusUpcomingInvoiceSchema.nullish(), payment_method: z.any().nullish(), + // upcoming_invoice: ApiCusUpcomingInvoiceSchema.nullish(), }); export const ApiCustomerSchema = z.object({ diff --git a/shared/api/customers/changes/V1.2_CustomerChange.ts b/shared/api/customers/changes/V1.2_CustomerChange.ts index 62a91b350..7ff2d14ce 100644 --- a/shared/api/customers/changes/V1.2_CustomerChange.ts +++ b/shared/api/customers/changes/V1.2_CustomerChange.ts @@ -4,7 +4,11 @@ import { defineVersionChange, } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import type { z } from "zod/v4"; +import type { ApiInvoiceV1 } from "../../others/apiInvoice/apiInvoiceV1.js"; +import { transformInvoiceToV0 } from "../../others/apiInvoice/changes/V1.2_InvoiceChange.js"; import { ApiCustomerSchema } from "../apiCustomer.js"; +import type { ApiTrialsUsedV1 } from "../components/apiTrialsUsed/apiTrialsUsedV1.js"; +import { transformTrialsUsedToV0 } from "../components/apiTrialsUsed/changes/V1.2_TrialsUsedChange.js"; import { transformBalanceToCusFeatureV3 } from "../cusFeatures/changes/V1.2_CusFeatureChange.js"; import type { ApiCusFeatureV3 } from "../cusFeatures/previousVersions/apiCusFeatureV3.js"; import type { ApiSubscription } from "../cusPlans/apiSubscription.js"; @@ -82,13 +86,18 @@ export const V1_2_CustomerChange = defineVersionChange({ features: v3_features, // The others - invoices: input.invoices, - entities: input.entities, - trials_used: input.trials_used, - rewards: input.rewards, - upcoming_invoice: input.upcoming_invoice, - referrals: input.referrals, - payment_method: input.payment_method, + invoices: + input.invoices?.map((invoice: ApiInvoiceV1) => + transformInvoiceToV0({ input: invoice }), + ) ?? undefined, + entities: input.entities ?? undefined, + trials_used: + input.trials_used?.map((trial: ApiTrialsUsedV1) => + transformTrialsUsedToV0({ input: trial }), + ) ?? undefined, + rewards: input.rewards ?? undefined, + referrals: input.referrals ?? undefined, + payment_method: input.payment_method ?? undefined, } satisfies z.infer; }, }); diff --git a/shared/api/customers/components/apiTrialsUsed/apiTrialsUsedV1.ts b/shared/api/customers/components/apiTrialsUsed/apiTrialsUsedV1.ts new file mode 100644 index 000000000..4b006581b --- /dev/null +++ b/shared/api/customers/components/apiTrialsUsed/apiTrialsUsedV1.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const ApiTrialsUsedV1Schema = z.object({ + plan_id: z.string(), + customer_id: z.string(), + fingerprint: z.string().nullish(), +}); + +export type ApiTrialsUsedV1 = z.infer; diff --git a/shared/api/customers/components/apiTrialsUsed/changes/V1.2_TrialsUsedChange.ts b/shared/api/customers/components/apiTrialsUsed/changes/V1.2_TrialsUsedChange.ts new file mode 100644 index 000000000..f97de7194 --- /dev/null +++ b/shared/api/customers/components/apiTrialsUsed/changes/V1.2_TrialsUsedChange.ts @@ -0,0 +1,50 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { defineVersionChange } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import type { z } from "zod/v4"; +import { ApiTrialsUsedV1Schema } from "../apiTrialsUsedV1.js"; +import { ApiTrialsUsedV0Schema } from "../prevVersions/apiTrialsUsedV0.js"; + +/** + * Transform trials_used from V2.0 format to V1.2 format + * Exported so it can be reused in other transformations (e.g., V1_2_CustomerChange) + */ +export function transformTrialsUsedToV0({ + input, +}: { + input: z.infer; +}): z.infer { + return { + product_id: input.plan_id, + customer_id: input.customer_id, + fingerprint: input.fingerprint, + }; +} + +/** + * V1_2_TrialsUsedChange: Transforms trials_used response TO V1_2 format + * + * Applied when: targetVersion <= V1_2 + * + * Breaking changes introduced in V2.0: + * + * 1. Product renamed to Plan: + * - V2.0+: "plan_id" field + * - V1.2: "product_id" field + * + * Input: ApiTrialsUsedV1 (V2.0+ format) + * Output: ApiTrialsUsedV0 (V1.2 format) + */ +export const V1_2_TrialsUsedChange = defineVersionChange({ + newVersion: ApiVersion.V2_0, + oldVersion: ApiVersion.V1_Beta, + description: [ + "Products renamed to plans in SDK", + "TrialsUsed plan_id renamed to product_id for V1.2 compatibility", + ], + affectedResources: [], + newSchema: ApiTrialsUsedV1Schema, + oldSchema: ApiTrialsUsedV0Schema, + + // Response: V2.0 → V1.2 + transformResponse: transformTrialsUsedToV0, +}); diff --git a/shared/api/customers/components/apiTrialsUsed.ts b/shared/api/customers/components/apiTrialsUsed/prevVersions/apiTrialsUsedV0.ts similarity index 50% rename from shared/api/customers/components/apiTrialsUsed.ts rename to shared/api/customers/components/apiTrialsUsed/prevVersions/apiTrialsUsedV0.ts index 3a4fc6724..0f487b995 100644 --- a/shared/api/customers/components/apiTrialsUsed.ts +++ b/shared/api/customers/components/apiTrialsUsed/prevVersions/apiTrialsUsedV0.ts @@ -1,9 +1,9 @@ import { z } from "zod/v4"; -export const ApiTrialsUsedSchema = z.object({ +export const ApiTrialsUsedV0Schema = z.object({ product_id: z.string(), customer_id: z.string(), fingerprint: z.string().nullish(), }); -export type ApiTrialsUsed = z.infer; +export type ApiTrialsUsedV0 = z.infer; diff --git a/shared/api/customers/previousVersions/apiCustomerV0.ts b/shared/api/customers/previousVersions/apiCustomerV0.ts index b85450f91..4d5200e6d 100644 --- a/shared/api/customers/previousVersions/apiCustomerV0.ts +++ b/shared/api/customers/previousVersions/apiCustomerV0.ts @@ -1,9 +1,9 @@ import { ApiCusFeatureV0Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV0.js"; import { ApiCusProductV0Schema } from "@api/customers/cusPlans/previousVersions/apiCusProductV0.js"; -import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; +import { ApiInvoiceV0Schema } from "@api/others/apiInvoice/prevVersions/apiInvoiceV0.js"; import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; -import { ApiTrialsUsedSchema } from "../components/apiTrialsUsed.js"; +import { ApiTrialsUsedV0Schema } from "../components/apiTrialsUsed/prevVersions/apiTrialsUsedV0.js"; /** * ApiCustomerV0Schema - Customer response format for API V0_1 @@ -47,8 +47,8 @@ export const ApiCustomerV0Schema = z.object({ products: z.array(ApiCusProductV0Schema), add_ons: z.array(ApiCusProductV0Schema), entitlements: z.array(ApiCusFeatureV0Schema), - invoices: z.array(ApiInvoiceSchema), - trials_used: z.array(ApiTrialsUsedSchema).optional(), + invoices: z.array(ApiInvoiceV0Schema), + trials_used: z.array(ApiTrialsUsedV0Schema).optional(), }); export type ApiCustomerV0 = z.infer; diff --git a/shared/api/customers/previousVersions/apiCustomerV1.ts b/shared/api/customers/previousVersions/apiCustomerV1.ts index d12765264..5ab46f62d 100644 --- a/shared/api/customers/previousVersions/apiCustomerV1.ts +++ b/shared/api/customers/previousVersions/apiCustomerV1.ts @@ -1,8 +1,8 @@ import { ApiCusFeatureV1Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV1.js"; -import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; +import { ApiInvoiceV0Schema } from "@api/others/apiInvoice/prevVersions/apiInvoiceV0.js"; import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; -import { ApiTrialsUsedSchema } from "../components/apiTrialsUsed.js"; +import { ApiTrialsUsedV0Schema } from "../components/apiTrialsUsed/prevVersions/apiTrialsUsedV0.js"; import { ApiCusProductV1Schema } from "../cusPlans/previousVersions/apiCusProductV1.js"; /** @@ -50,8 +50,8 @@ export const ApiCustomerV1Schema = z.object({ products: z.array(ApiCusProductV1Schema), add_ons: z.array(ApiCusProductV1Schema), entitlements: z.array(ApiCusFeatureV1Schema), - invoices: z.array(ApiInvoiceSchema), - trials_used: z.array(ApiTrialsUsedSchema).optional(), + invoices: z.array(ApiInvoiceV0Schema), + trials_used: z.array(ApiTrialsUsedV0Schema).optional(), }); export type ApiCustomerV1 = z.infer; diff --git a/shared/api/customers/previousVersions/apiCustomerV2.ts b/shared/api/customers/previousVersions/apiCustomerV2.ts index e3e843295..c9989f908 100644 --- a/shared/api/customers/previousVersions/apiCustomerV2.ts +++ b/shared/api/customers/previousVersions/apiCustomerV2.ts @@ -1,12 +1,13 @@ import { ApiCusReferralSchema } from "@api/customers/components/apiCusReferral.js"; import { ApiCusUpcomingInvoiceSchema } from "@api/customers/components/apiCusUpcomingInvoice.js"; -import { ApiTrialsUsedSchema } from "@api/customers/components/apiTrialsUsed.js"; +import { ApiTrialsUsedV0Schema } from "@api/customers/components/apiTrialsUsed/prevVersions/apiTrialsUsedV0.js"; import { ApiCusFeatureV2Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV2.js"; import { ApiCusProductV2Schema } from "@api/customers/cusPlans/previousVersions/apiCusProductV2.js"; import { ApiBaseEntitySchema } from "@api/entities/apiBaseEntity.js"; import { ApiCusRewardsSchema } from "@api/models.js"; -import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; +import { ApiInvoiceV0Schema } from "@api/others/apiInvoice/prevVersions/apiInvoiceV0.js"; import { AppEnv } from "@models/genModels/genEnums.js"; + import { z } from "zod/v4"; /** @@ -43,8 +44,8 @@ export const ApiCustomerV2Schema = z.object({ products: z.array(ApiCusProductV2Schema), features: z.array(ApiCusFeatureV2Schema), - invoices: z.array(ApiInvoiceSchema).optional(), - trials_used: z.array(ApiTrialsUsedSchema).optional(), + invoices: z.array(ApiInvoiceV0Schema).optional(), + trials_used: z.array(ApiTrialsUsedV0Schema).optional(), rewards: ApiCusRewardsSchema.nullish(), metadata: z.record(z.any(), z.any()).default({}), diff --git a/shared/api/customers/previousVersions/apiCustomerV3.ts b/shared/api/customers/previousVersions/apiCustomerV3.ts index 0b4d1e035..74206cefe 100644 --- a/shared/api/customers/previousVersions/apiCustomerV3.ts +++ b/shared/api/customers/previousVersions/apiCustomerV3.ts @@ -1,11 +1,11 @@ import { ApiCusReferralSchema } from "@api/customers/components/apiCusReferral.js"; import { ApiCusUpcomingInvoiceSchema } from "@api/customers/components/apiCusUpcomingInvoice.js"; -import { ApiTrialsUsedSchema } from "@api/customers/components/apiTrialsUsed.js"; import { ApiBaseEntitySchema } from "@api/entities/apiEntity.js"; import { ApiCusRewardsSchema } from "@api/others/apiDiscount.js"; -import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; +import { ApiInvoiceV0Schema } from "../../others/apiInvoice/prevVersions/apiInvoiceV0.js"; +import { ApiTrialsUsedV0Schema } from "../components/apiTrialsUsed/prevVersions/apiTrialsUsedV0.js"; import { ApiCusFeatureV3Schema } from "../cusFeatures/previousVersions/apiCusFeatureV3.js"; import { ApiCusProductV3Schema } from "../cusPlans/previousVersions/apiCusProductV3.js"; @@ -121,13 +121,13 @@ const cusDescriptions = { }; export const ApiCusExpandV3Schema = z.object({ - invoices: z.array(ApiInvoiceSchema).optional().meta({ + invoices: z.array(ApiInvoiceV0Schema).optional().meta({ description: cusDescriptions.invoices, }), entities: z.array(ApiBaseEntitySchema).optional().meta({ description: cusDescriptions.entities, }), - trials_used: z.array(ApiTrialsUsedSchema).optional().meta({ + trials_used: z.array(ApiTrialsUsedV0Schema).optional().meta({ description: cusDescriptions.trials_used, }), rewards: ApiCusRewardsSchema.nullish().meta({ diff --git a/shared/api/entities/apiEntity.ts b/shared/api/entities/apiEntity.ts index 6ef3d79a4..88b934bb1 100644 --- a/shared/api/entities/apiEntity.ts +++ b/shared/api/entities/apiEntity.ts @@ -1,6 +1,6 @@ import { ApiBalanceSchema } from "@api/customers/cusFeatures/apiBalance.js"; import { ApiSubscriptionSchema } from "@api/customers/cusPlans/apiSubscription.js"; -import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; +import { ApiInvoiceV1Schema } from "@api/others/apiInvoice/apiInvoiceV1.js"; import { z } from "zod/v4"; import { ApiBaseEntitySchema } from "./apiBaseEntity.js"; @@ -15,7 +15,7 @@ export const ApiEntityV1Schema = ApiBaseEntitySchema.extend({ balances: z.record(z.string(), ApiBalanceSchema).optional().meta({ description: "Features associated with this entity", }), - invoices: z.array(ApiInvoiceSchema).optional().meta({ + invoices: z.array(ApiInvoiceV1Schema).optional().meta({ description: "Invoices for this entity (only included when expand=invoices)", }), diff --git a/shared/api/entities/changes/V1.2_EntityChange.ts b/shared/api/entities/changes/V1.2_EntityChange.ts index 326d6c6af..7515b10eb 100644 --- a/shared/api/entities/changes/V1.2_EntityChange.ts +++ b/shared/api/entities/changes/V1.2_EntityChange.ts @@ -9,6 +9,8 @@ import type { ApiCusFeatureV3 } from "../../customers/cusFeatures/previousVersio import type { ApiSubscription } from "../../customers/cusPlans/apiSubscription.js"; import { transformSubscriptionToCusProductV3 } from "../../customers/cusPlans/changes/V1.2_CusPlanChange.js"; import type { ApiCusProductV3 } from "../../customers/cusPlans/previousVersions/apiCusProductV3.js"; +import type { ApiInvoiceV1 } from "../../others/apiInvoice/apiInvoiceV1.js"; +import { transformInvoiceToV0 } from "../../others/apiInvoice/changes/V1.2_InvoiceChange.js"; import { ApiEntityV1Schema } from "../apiEntity.js"; import { EntityLegacyDataSchema } from "../entityLegacyData.js"; import { ApiEntityV0Schema } from "../prevVersions/apiEntityV0.js"; @@ -83,7 +85,10 @@ export const V1_2_EntityChange = defineVersionChange({ env: input.env, products: v0CusProducts, features: v0_features, - invoices: input.invoices, + invoices: + input.invoices?.map((invoice: ApiInvoiceV1) => + transformInvoiceToV0({ input: invoice }), + ) ?? undefined, } satisfies z.infer; }, }); diff --git a/shared/api/entities/prevVersions/apiEntityV0.ts b/shared/api/entities/prevVersions/apiEntityV0.ts index 3ee166f31..26f105fb5 100644 --- a/shared/api/entities/prevVersions/apiEntityV0.ts +++ b/shared/api/entities/prevVersions/apiEntityV0.ts @@ -1,6 +1,6 @@ import { ApiCusFeatureV3Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV3.js"; import { ApiCusProductV3Schema } from "@api/customers/cusPlans/previousVersions/apiCusProductV3.js"; -import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; +import { ApiInvoiceV0Schema } from "@api/others/apiInvoice/prevVersions/apiInvoiceV0.js"; import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; @@ -98,7 +98,7 @@ export const ApiEntityV0Schema = z.object({ features: z.record(z.string(), ApiCusFeatureV3Schema).optional().meta({ description: entityDescriptions.features, }), - invoices: z.array(ApiInvoiceSchema).optional().meta({ + invoices: z.array(ApiInvoiceV0Schema).optional().meta({ description: entityDescriptions.invoices, }), }); diff --git a/shared/api/errors/classes/featureErrClasses.ts b/shared/api/errors/classes/featureErrClasses.ts index 08df3d37e..3299dbd8e 100644 --- a/shared/api/errors/classes/featureErrClasses.ts +++ b/shared/api/errors/classes/featureErrClasses.ts @@ -5,9 +5,9 @@ import { FeatureErrorCode } from "../codes/featureErrCodes.js"; * Product not found error */ export class FeatureAlreadyExistsError extends RecaseError { - constructor(opts: { productId: string; version?: string }) { + constructor(opts: { featureId: string }) { super({ - message: `Product ${opts.productId} ${opts.version ? ` (version ${opts.version})` : ""} not found`, + message: `Feature ${opts.featureId} already exists`, code: FeatureErrorCode.FeatureAlreadyExists, statusCode: 404, }); diff --git a/shared/api/models.ts b/shared/api/models.ts index cead3c70b..c0fbadeb6 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -39,7 +39,7 @@ export * from "./features/prevVersions/featureV0OpModels.js"; // Others export * from "./others/apiDiscount.js"; -export * from "./others/apiInvoice.js"; +export * from "./others/apiInvoice/apiInvoiceV1.js"; // Product export * from "./products/apiFreeTrial.js"; export * from "./products/apiPlan.js"; @@ -81,6 +81,8 @@ export * from "./features/apiFeatureV1.js"; export * from "./features/featureV1OpModels.js"; export * from "./features/prevVersions/apiFeatureV0.js"; export * from "./features/prevVersions/featureV0OpModels.js"; +export * from "./others/apiInvoice/apiInvoiceItem.js"; +export * from "./others/apiInvoice/apiInvoiceV1.js"; // Models export * from "./platform/platformModels.js"; export * from "./products/planLegacyData.js"; diff --git a/shared/api/others/apiInvoice.ts b/shared/api/others/apiInvoice.ts deleted file mode 100644 index 612791dba..000000000 --- a/shared/api/others/apiInvoice.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { z } from "zod/v4"; - -export const ApiInvoiceItemSchema = z.object({ - description: z.string().meta({ - description: "Description of the invoice line item", - example: "Pro Plan - Monthly Subscription", - }), - period_start: z.number().meta({ - description: "Timestamp when the billing period starts", - example: 1759247877000, - }), - period_end: z.number().meta({ - description: "Timestamp when the billing period ends", - example: 1761839877000, - }), - - feature_id: z.string().optional().meta({ - description: "The ID of the feature associated with this line item", - example: "feature_123", - }), - feature_name: z.string().optional().meta({ - description: "The name of the feature associated with this line item", - example: "API Calls", - }), -}); - -export const ApiInvoiceSchema = z.object({ - product_ids: z.array(z.string()).meta({ - description: "Array of product IDs included in this invoice", - example: ["pro_plan", "addon_feature"], - }), - stripe_id: z.string().meta({ - description: "The Stripe invoice ID", - example: "in_1A2B3C4D5E6F7G8H", - }), - status: z.string().meta({ - description: "The status of the invoice", - example: "paid", - }), - total: z.number().meta({ - description: "The total amount of the invoice", - example: 2999, - }), - currency: z.string().meta({ - description: "The currency code for the invoice", - example: "usd", - }), - created_at: z.number().meta({ - description: "Timestamp when the invoice was created", - example: 1759247877000, - }), - hosted_invoice_url: z.string().nullish().meta({ - description: "URL to the Stripe-hosted invoice page", - example: "https://invoice.stripe.com/i/acct_123/test_456", - }), - // period_start: z.number().nullish(), - // period_end: z.number().nullish(), -}); - -export type ApiInvoice = z.infer; diff --git a/shared/api/others/apiInvoice/apiInvoiceItem.ts b/shared/api/others/apiInvoice/apiInvoiceItem.ts new file mode 100644 index 000000000..cf76797eb --- /dev/null +++ b/shared/api/others/apiInvoice/apiInvoiceItem.ts @@ -0,0 +1,25 @@ +import { z } from "zod/v4"; + +export const ApiInvoiceItemSchema = z.object({ + description: z.string().meta({ + description: "Description of the invoice line item", + example: "Pro Plan - Monthly Subscription", + }), + period_start: z.number().meta({ + description: "Timestamp when the billing period starts", + example: 1759247877000, + }), + period_end: z.number().meta({ + description: "Timestamp when the billing period ends", + example: 1761839877000, + }), + + feature_id: z.string().optional().meta({ + description: "The ID of the feature associated with this line item", + example: "feature_123", + }), + feature_name: z.string().optional().meta({ + description: "The name of the feature associated with this line item", + example: "API Calls", + }), +}); diff --git a/shared/api/others/apiInvoice/apiInvoiceV1.ts b/shared/api/others/apiInvoice/apiInvoiceV1.ts new file mode 100644 index 000000000..0741fde29 --- /dev/null +++ b/shared/api/others/apiInvoice/apiInvoiceV1.ts @@ -0,0 +1,34 @@ +import { z } from "zod/v4"; + +export const ApiInvoiceV1Schema = z.object({ + plan_ids: z.array(z.string()).meta({ + description: "Array of plan IDs included in this invoice", + example: ["pro_plan", "addon_feature"], + }), + stripe_id: z.string().meta({ + description: "The Stripe invoice ID", + example: "in_1A2B3C4D5E6F7G8H", + }), + status: z.string().meta({ + description: "The status of the invoice", + example: "paid", + }), + total: z.number().meta({ + description: "The total amount of the invoice", + example: 2999, + }), + currency: z.string().meta({ + description: "The currency code for the invoice", + example: "usd", + }), + created_at: z.number().meta({ + description: "Timestamp when the invoice was created", + example: 1759247877000, + }), + hosted_invoice_url: z.string().nullish().meta({ + description: "URL to the Stripe-hosted invoice page", + example: "https://invoice.stripe.com/i/acct_123/test_456", + }), +}); + +export type ApiInvoiceV1 = z.infer; diff --git a/shared/api/others/apiInvoice/changes/V1.2_InvoiceChange.ts b/shared/api/others/apiInvoice/changes/V1.2_InvoiceChange.ts new file mode 100644 index 000000000..6d88fd918 --- /dev/null +++ b/shared/api/others/apiInvoice/changes/V1.2_InvoiceChange.ts @@ -0,0 +1,57 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import type { z } from "zod/v4"; +import { ApiInvoiceV1Schema } from "../apiInvoiceV1.js"; +import { ApiInvoiceV0Schema } from "../prevVersions/apiInvoiceV0.js"; + +/** + * Transform invoice from V2.0 format to V1.2 format + * Exported so it can be reused in other transformations (e.g., V1_2_CustomerChange) + */ +export function transformInvoiceToV0({ + input, +}: { + input: z.infer; +}): z.infer { + return { + product_ids: input.plan_ids, + stripe_id: input.stripe_id, + status: input.status, + total: input.total, + currency: input.currency, + created_at: input.created_at, + hosted_invoice_url: input.hosted_invoice_url, + }; +} + +/** + * V1_2_InvoiceChange: Transforms invoice response TO V1_2 format + * + * Applied when: targetVersion <= V1_2 + * + * Breaking changes introduced in V2.0: + * + * 1. Product renamed to Plan: + * - V2.0+: "plan_ids" field contains array of plan IDs + * - V1.2: "product_ids" field contains array of product IDs + * + * Input: ApiInvoiceV1 (V2.0+ format) + * Output: ApiInvoiceV0 (V1.2 format) + */ +export const V1_2_InvoiceChange = defineVersionChange({ + newVersion: ApiVersion.V2_0, + oldVersion: ApiVersion.V1_Beta, + description: [ + "Products renamed to plans in SDK", + "Invoice plan_ids renamed to product_ids for V1.2 compatibility", + ], + affectedResources: [AffectedResource.Invoice], + newSchema: ApiInvoiceV1Schema, + oldSchema: ApiInvoiceV0Schema, + + // Response: V2.0 → V1.2 + transformResponse: transformInvoiceToV0, +}); diff --git a/shared/api/others/apiInvoice/prevVersions/apiInvoiceV0.ts b/shared/api/others/apiInvoice/prevVersions/apiInvoiceV0.ts new file mode 100644 index 000000000..e98282c37 --- /dev/null +++ b/shared/api/others/apiInvoice/prevVersions/apiInvoiceV0.ts @@ -0,0 +1,34 @@ +import { z } from "zod/v4"; + +export const ApiInvoiceV0Schema = z.object({ + product_ids: z.array(z.string()).meta({ + description: "Array of product IDs included in this invoice", + example: ["pro_plan", "addon_feature"], + }), + stripe_id: z.string().meta({ + description: "The Stripe invoice ID", + example: "in_1A2B3C4D5E6F7G8H", + }), + status: z.string().meta({ + description: "The status of the invoice", + example: "paid", + }), + total: z.number().meta({ + description: "The total amount of the invoice", + example: 2999, + }), + currency: z.string().meta({ + description: "The currency code for the invoice", + example: "usd", + }), + created_at: z.number().meta({ + description: "Timestamp when the invoice was created", + example: 1759247877000, + }), + hosted_invoice_url: z.string().nullish().meta({ + description: "URL to the Stripe-hosted invoice page", + example: "https://invoice.stripe.com/i/acct_123/test_456", + }), +}); + +export type ApiInvoiceV0 = z.infer; diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index c225f459b..fb14aa30f 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -4,6 +4,8 @@ import { V0_1_CustomerChange } from "@api/customers/changes/V0.1_CustomerChange. import { V0_2_CustomerChange } from "@api/customers/changes/V0.2_CustomerChange.js"; import { V0_2_InvoicesAlwaysExpanded } from "@api/customers/changes/V0.2_InvoicesAlwaysExpanded.js"; import { V1_1_FeaturesArrayToObject } from "@api/customers/changes/V1.1_FeaturesArrayToObject.js"; +// Import trials used changes +import { V1_2_TrialsUsedChange } from "@api/customers/components/apiTrialsUsed/changes/V1.2_TrialsUsedChange.js"; // Import customer product changes @@ -15,6 +17,8 @@ import { V1_2_EntityQueryChange } from "@api/entities/requestChanges/V1.2_Entity // Import feature changes import { V1_2_FeatureChange } from "@api/features/changes/V1.2_FeatureChange.js"; import { V1_2_CreateFeatureChange } from "@api/features/changes/V1.2_FeatureParamsChange.js"; +// Import invoice changes +import { V1_2_InvoiceChange } from "@api/others/apiInvoice/changes/V1.2_InvoiceChange.js"; // Import product changes import { V1_2_ProductChanges } from "@api/products/changes/V1.2_ProductChanges.js"; import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange.js"; @@ -31,6 +35,8 @@ export const V2_CHANGES: VersionChangeConstructor[] = [ V1_2_EntityChange, // Transforms Entity TO V0 format from V1 format V1_2_EntityQueryChange, // Transforms Entity Query TO V2.0 format (adds expand options) V1_2_ProductChanges, // Transforms Product TO V1.2 format from V2 Plan format + V1_2_InvoiceChange, // Transforms Invoice TO V1.2 format from V2 format (plan_ids → product_ids) + V1_2_TrialsUsedChange, // Transforms TrialsUsed TO V1.2 format from V2 format (plan_id → product_id) V1_2_CheckChange, // Transforms Check TO V1.2 format from V0.2 format V1_2_CheckQueryChange, // Transforms Check Query TO V2.0 format (adds expand options) V1_2_TrackChange, // Transforms Track TO V1.2 format from V0.2 format