From 1afb085483496c0a547d0fcfa754d1bf3325fe94 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:03:15 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=F0=9F=90=9B=20final=20fixes=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../revenueCat/handleRCOAuthCallback.ts | 7 - .../handleGetRevenuecatMappings.ts} | 2 +- .../handlers/handleGetRevenuecatProducts.ts | 32 ++ .../handleSaveRevenuecatMappings.ts} | 2 +- .../{services => misc}/RCMappingService.ts | 71 +++-- .../misc/handleGetRevenueCatProducts.ts | 59 ---- .../revenueCat/misc/initRevenuecatCli.ts | 54 ++++ .../revenueCat/misc/revenueCatMiddleware.ts | 34 +- .../{revcatTypes.ts => revenuecatTypes.ts} | 0 ...okRouter.ts => revenuecatWebhookRouter.ts} | 32 +- .../handleCancellation.ts | 13 +- .../handleExpiration.ts | 4 +- .../handleInitialPurchase.ts | 7 +- .../handleNonRenewingPurchase.ts | 9 +- .../handleRenewal.ts | 12 +- .../handleUncancellation.ts | 6 +- server/src/initHono.ts | 6 +- .../add-product/createFullCusProduct.ts | 7 - .../migrationSteps/migrateCustomer.ts | 161 +--------- .../migrateRevenuecatCustomer.ts | 114 +++++++ .../migrationSteps/migrateStripeCustomer.ts | 54 ++++ .../orgs/handlers/handleRevenueCatConfig.ts | 16 +- server/src/internal/orgs/orgRouter.ts | 6 +- .../external-psps/revenuecat/rc1.test.ts | 291 +++++++++++++++--- shared/db/schema.ts | 4 +- .../cusProductModels/cusProductModels.ts | 2 - .../cusProductModels/cusProductTable.ts | 4 - .../models/processorModels/processorModels.ts | 2 +- .../revenuecatMappingsTable.ts} | 11 +- vite/src/components/v2/icons/AutumnIcons.tsx | 28 ++ .../components/RevenueCatMappingSheet.tsx | 5 +- vite/src/views/main-sidebar/MainSidebar.tsx | 5 +- 32 files changed, 678 insertions(+), 382 deletions(-) delete mode 100644 server/src/external/revenueCat/handleRCOAuthCallback.ts rename server/src/external/revenueCat/{misc/handleGetRCMappings.ts => handlers/handleGetRevenuecatMappings.ts} (82%) create mode 100644 server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts rename server/src/external/revenueCat/{misc/handleSaveRCMappings.ts => handlers/handleSaveRevenuecatMappings.ts} (94%) rename server/src/external/revenueCat/{services => misc}/RCMappingService.ts (53%) delete mode 100644 server/src/external/revenueCat/misc/handleGetRevenueCatProducts.ts create mode 100644 server/src/external/revenueCat/misc/initRevenuecatCli.ts rename server/src/external/revenueCat/{revcatTypes.ts => revenuecatTypes.ts} (100%) rename server/src/external/revenueCat/{rcWebhookRouter.ts => revenuecatWebhookRouter.ts} (70%) rename server/src/external/revenueCat/{handlers => webhookHandlers}/handleCancellation.ts (89%) rename server/src/external/revenueCat/{handlers => webhookHandlers}/handleExpiration.ts (96%) rename server/src/external/revenueCat/{handlers => webhookHandlers}/handleInitialPurchase.ts (95%) rename server/src/external/revenueCat/{handlers => webhookHandlers}/handleNonRenewingPurchase.ts (92%) rename server/src/external/revenueCat/{handlers => webhookHandlers}/handleRenewal.ts (93%) rename server/src/external/revenueCat/{handlers => webhookHandlers}/handleUncancellation.ts (93%) create mode 100644 server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts create mode 100644 server/src/internal/migrations/migrationSteps/migrateStripeCustomer.ts rename shared/models/processorModels/{revcatModels/revcatMappingsTable.ts => revenuecatModels/revenuecatMappingsTable.ts} (67%) diff --git a/server/src/external/revenueCat/handleRCOAuthCallback.ts b/server/src/external/revenueCat/handleRCOAuthCallback.ts deleted file mode 100644 index d7bd164ee..000000000 --- a/server/src/external/revenueCat/handleRCOAuthCallback.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createRoute } from "@/honoMiddlewares/routeHandler"; - -export const handleRevenueCatOauthCallback = createRoute({ - handler(c) { - return c.json({ success: true }); - }, -}); diff --git a/server/src/external/revenueCat/misc/handleGetRCMappings.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatMappings.ts similarity index 82% rename from server/src/external/revenueCat/misc/handleGetRCMappings.ts rename to server/src/external/revenueCat/handlers/handleGetRevenuecatMappings.ts index 9a98cde49..f95a0b82e 100644 --- a/server/src/external/revenueCat/misc/handleGetRCMappings.ts +++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatMappings.ts @@ -1,5 +1,5 @@ import { createRoute } from "@/honoMiddlewares/routeHandler"; -import { RCMappingService } from "../services/RCMappingService"; +import { RCMappingService } from "../misc/RCMappingService"; export const handleGetRCMappings = createRoute({ handler: async (c) => { diff --git a/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts new file mode 100644 index 000000000..f97ce011e --- /dev/null +++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts @@ -0,0 +1,32 @@ +import { AppEnv } from "@shared/index"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { initRevenuecatCli } from "../misc/initRevenuecatCli"; + +export const handleGetRevenueCatProducts = createRoute({ + handler: async (c) => { + const { org, env } = c.get("ctx"); + const revenueCatConfig = org.processor_configs?.revenuecat; + + if (!revenueCatConfig) { + return c.json({ products: [] }, 404); + } + + const projectId = + env === AppEnv.Live + ? revenueCatConfig.project_id + : revenueCatConfig.sandbox_project_id; + const apiKey = + env === AppEnv.Live + ? revenueCatConfig.api_key + : revenueCatConfig.sandbox_api_key; + + if (!projectId || !apiKey) { + return c.json({ products: [] }, 404); + } + + const rcCli = initRevenuecatCli({ projectId, apiKey }); + const products = await rcCli.listProducts(); + + return c.json(products); + }, +}); diff --git a/server/src/external/revenueCat/misc/handleSaveRCMappings.ts b/server/src/external/revenueCat/handlers/handleSaveRevenuecatMappings.ts similarity index 94% rename from server/src/external/revenueCat/misc/handleSaveRCMappings.ts rename to server/src/external/revenueCat/handlers/handleSaveRevenuecatMappings.ts index 8dd6ad9e5..67609fa70 100644 --- a/server/src/external/revenueCat/misc/handleSaveRCMappings.ts +++ b/server/src/external/revenueCat/handlers/handleSaveRevenuecatMappings.ts @@ -1,6 +1,6 @@ import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler"; -import { RCMappingService } from "../services/RCMappingService"; +import { RCMappingService } from "../misc/RCMappingService"; const MappingSchema = z.object({ autumn_product_id: z.string(), diff --git a/server/src/external/revenueCat/services/RCMappingService.ts b/server/src/external/revenueCat/misc/RCMappingService.ts similarity index 53% rename from server/src/external/revenueCat/services/RCMappingService.ts rename to server/src/external/revenueCat/misc/RCMappingService.ts index 3791e77dc..ee643acfb 100644 --- a/server/src/external/revenueCat/services/RCMappingService.ts +++ b/server/src/external/revenueCat/misc/RCMappingService.ts @@ -1,4 +1,8 @@ -import { type AppEnv, type RevcatMapping, revcatMappings } from "@shared/index"; +import { + type AppEnv, + type RevenuecatMapping, + revenuecatMappings, +} from "@shared/index"; import { and, arrayContains, eq } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle"; @@ -10,22 +14,22 @@ export class RCMappingService { db, orgId, env, - revcatProductId, + revenuecatProductId, }: { db: DrizzleCli; orgId: string; env: AppEnv; - revcatProductId: string; + revenuecatProductId: string; }): Promise { const [mapping] = await db - .select({ autumn_product_id: revcatMappings.autumn_product_id }) - .from(revcatMappings) + .select({ autumn_product_id: revenuecatMappings.autumn_product_id }) + .from(revenuecatMappings) .where( and( - eq(revcatMappings.org_id, orgId), - eq(revcatMappings.env, env), - arrayContains(revcatMappings.revenuecat_product_ids, [ - revcatProductId, + eq(revenuecatMappings.org_id, orgId), + eq(revenuecatMappings.env, env), + arrayContains(revenuecatMappings.revenuecat_product_ids, [ + revenuecatProductId, ]), ), ) @@ -45,21 +49,30 @@ export class RCMappingService { }) { return db .select() - .from(revcatMappings) + .from(revenuecatMappings) .where( - and(eq(revcatMappings.org_id, orgId), eq(revcatMappings.env, env)), + and( + eq(revenuecatMappings.org_id, orgId), + eq(revenuecatMappings.env, env), + ), ); } - static async upsert({ db, data }: { db: DrizzleCli; data: RevcatMapping }) { + static async upsert({ + db, + data, + }: { + db: DrizzleCli; + data: RevenuecatMapping; + }) { return db - .insert(revcatMappings) + .insert(revenuecatMappings) .values(data) .onConflictDoUpdate({ target: [ - revcatMappings.org_id, - revcatMappings.env, - revcatMappings.autumn_product_id, + revenuecatMappings.org_id, + revenuecatMappings.env, + revenuecatMappings.autumn_product_id, ], set: { revenuecat_product_ids: data.revenuecat_product_ids }, }) @@ -79,12 +92,12 @@ export class RCMappingService { }) { const mapping = await db .select() - .from(revcatMappings) + .from(revenuecatMappings) .where( and( - eq(revcatMappings.org_id, orgId), - eq(revcatMappings.env, env), - eq(revcatMappings.autumn_product_id, autumnProductId), + eq(revenuecatMappings.org_id, orgId), + eq(revenuecatMappings.env, env), + eq(revenuecatMappings.autumn_product_id, autumnProductId), ), ); return mapping; @@ -101,16 +114,16 @@ export class RCMappingService { orgId: string; env: AppEnv; autumnProductId: string; - data: Partial; + data: Partial; }) { const mapping = await db - .update(revcatMappings) + .update(revenuecatMappings) .set(data) .where( and( - eq(revcatMappings.org_id, orgId), - eq(revcatMappings.env, env), - eq(revcatMappings.autumn_product_id, autumnProductId), + eq(revenuecatMappings.org_id, orgId), + eq(revenuecatMappings.env, env), + eq(revenuecatMappings.autumn_product_id, autumnProductId), ), ); return mapping; @@ -128,12 +141,12 @@ export class RCMappingService { autumnProductId: string; }) { const mapping = await db - .delete(revcatMappings) + .delete(revenuecatMappings) .where( and( - eq(revcatMappings.org_id, orgId), - eq(revcatMappings.env, env), - eq(revcatMappings.autumn_product_id, autumnProductId), + eq(revenuecatMappings.org_id, orgId), + eq(revenuecatMappings.env, env), + eq(revenuecatMappings.autumn_product_id, autumnProductId), ), ); return mapping; diff --git a/server/src/external/revenueCat/misc/handleGetRevenueCatProducts.ts b/server/src/external/revenueCat/misc/handleGetRevenueCatProducts.ts deleted file mode 100644 index dd5585a43..000000000 --- a/server/src/external/revenueCat/misc/handleGetRevenueCatProducts.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { AppEnv } from "@shared/index"; -import { createRoute } from "@/honoMiddlewares/routeHandler"; -import type { RevenueCatProductsResponse } from "../revcatTypes"; - -export type Resp = { - products: { id: string; name: string }[]; -}; - -export const handleGetRevenueCatProducts = createRoute({ - handler: async (c) => { - const { org, env } = c.get("ctx"); - const revenueCatConfig = org.processor_configs?.revenuecat; - - if (!revenueCatConfig) { - return c.json({ products: [] }, 404); - } - - const projectId = - env === AppEnv.Live - ? revenueCatConfig.project_id - : revenueCatConfig.sandbox_project_id; - const apiKey = - env === AppEnv.Live - ? revenueCatConfig.api_key - : revenueCatConfig.sandbox_api_key; - - const url = new URL( - `https://api.revenuecat.com/v2/projects/${projectId}/products`, - ); - url.searchParams.set("limit", "20"); - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - }); - - const data = (await response.json()) as RevenueCatProductsResponse; - - // Group products by store_identifier and combine names - const productMap = new Map(); - for (const product of data.items) { - const existing = productMap.get(product.store_identifier); - if (existing) { - existing.push(product.display_name); - } else { - productMap.set(product.store_identifier, [product.display_name]); - } - } - - return c.json({ - products: Array.from(productMap.entries()).map(([id, names]) => ({ - id, - name: names.join(", "), - })), - } satisfies Resp); - }, -}); diff --git a/server/src/external/revenueCat/misc/initRevenuecatCli.ts b/server/src/external/revenueCat/misc/initRevenuecatCli.ts new file mode 100644 index 000000000..0e1d3cc89 --- /dev/null +++ b/server/src/external/revenueCat/misc/initRevenuecatCli.ts @@ -0,0 +1,54 @@ +import { decryptData } from "@server/utils/encryptUtils.js"; +import type { RevenueCatProductsResponse } from "../revenuecatTypes"; + +export type ListRevenuecatProductsResponse = { + products: { id: string; name: string }[]; +}; + +export const initRevenuecatCli = ({ + projectId, + apiKey, +}: { + projectId: string; + apiKey: string; +}) => { + let resolvedApiKey = apiKey; + + resolvedApiKey = decryptData(apiKey); + + return { + listProducts: async () => { + const url = new URL( + `https://api.revenuecat.com/v2/projects/${projectId}/products`, + ); + url.searchParams.set("limit", "20"); + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${resolvedApiKey}`, + "Content-Type": "application/json", + }, + }); + + const data = (await response.json()) as RevenueCatProductsResponse; + + // Group products by store_identifier and combine names + const productMap = new Map(); + for (const product of data.items) { + const existing = productMap.get(product.store_identifier); + if (existing) { + existing.push(product.display_name); + } else { + productMap.set(product.store_identifier, [product.display_name]); + } + } + + return { + products: Array.from(productMap.entries()).map(([id, names]) => ({ + id, + name: names.join(", "), + })), + } satisfies ListRevenuecatProductsResponse; + }, + }; +}; diff --git a/server/src/external/revenueCat/misc/revenueCatMiddleware.ts b/server/src/external/revenueCat/misc/revenueCatMiddleware.ts index 8b3ce22e9..6f7192870 100644 --- a/server/src/external/revenueCat/misc/revenueCatMiddleware.ts +++ b/server/src/external/revenueCat/misc/revenueCatMiddleware.ts @@ -1,33 +1,38 @@ -import { AppEnv } from "@shared/models/genModels/genEnums"; +import type { AppEnv } from "@shared/models/genModels/genEnums"; import type { Organization } from "@shared/models/orgModels/orgTable"; import chalk from "chalk"; import type { Context, Next } from "hono"; import type { Logger } from "@/external/logtail/logtailUtils"; import type { HonoEnv } from "@/honoUtils/HonoEnv"; -import { FeatureService } from "@/internal/features/FeatureService"; import { OrgService } from "@/internal/orgs/OrgService"; -export const revcatSeederMiddleware = async ( +export const revenuecatSeederMiddleware = async ( c: Context, next: Next, ) => { const { orgId, env } = c.req.param(); const ctx = c.get("ctx"); - if (!ctx.org && orgId) { - ctx.org = await OrgService.get({ db: ctx.db, orgId }); + const result = await OrgService.getWithFeatures({ + db: ctx.db, + orgId, + env: env as AppEnv, + }); + + if (!result) { + throw new Error("Organization with features not found"); } + const { org, features } = result; + + if (!ctx.org && orgId) { + ctx.org = org; + } if (ctx.env !== env) { ctx.env = env as AppEnv; } - if (!ctx.features && orgId) { - ctx.features = await FeatureService.list({ - db: ctx.db, - orgId, - env: ctx.env ?? AppEnv.Sandbox, - }); + ctx.features = features; } await next(); @@ -43,11 +48,14 @@ export const logRevCatWebhook = ({ event: { type: string; id: string }; }) => { logger.info( - `${chalk.magentaBright("REVCAT").padEnd(18)} ${event.type.padEnd(30)} ${org.slug} | ${event.id}`, + `${chalk.magentaBright("REVCAT").padEnd(18)} ${event.type.padEnd(30)} ${org.slug} | ${event.id ?? "no_event_id"}`, ); }; -export const revcatLogMiddleware = async (c: Context, next: Next) => { +export const revenuecatLogMiddleware = async ( + c: Context, + next: Next, +) => { const { logger, org } = c.get("ctx"); const body = await c.req.json(); diff --git a/server/src/external/revenueCat/revcatTypes.ts b/server/src/external/revenueCat/revenuecatTypes.ts similarity index 100% rename from server/src/external/revenueCat/revcatTypes.ts rename to server/src/external/revenueCat/revenuecatTypes.ts diff --git a/server/src/external/revenueCat/rcWebhookRouter.ts b/server/src/external/revenueCat/revenuecatWebhookRouter.ts similarity index 70% rename from server/src/external/revenueCat/rcWebhookRouter.ts rename to server/src/external/revenueCat/revenuecatWebhookRouter.ts index 68fc4435d..047a739a1 100644 --- a/server/src/external/revenueCat/rcWebhookRouter.ts +++ b/server/src/external/revenueCat/revenuecatWebhookRouter.ts @@ -5,26 +5,28 @@ import type { WebhookInitialPurchase, WebhookNonRenewingPurchase, WebhookRenewal, + WebhookUnCancellation, } from "@puzzmo/revenue-cat-webhook-types"; import { AppEnv } from "@shared/models/genModels/genEnums"; import { type Context, Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv"; -import { handleCancellation } from "./handlers/handleCancellation"; -import { handleExpiration } from "./handlers/handleExpiration"; -import { handleInitialPurchase } from "./handlers/handleInitialPurchase"; -import { handleNonRenewingPurchase } from "./handlers/handleNonRenewingPurchase"; -import { handleRenewal } from "./handlers/handleRenewal"; import { - revcatLogMiddleware, - revcatSeederMiddleware, + revenuecatLogMiddleware, + revenuecatSeederMiddleware, } from "./misc/revenueCatMiddleware"; +import { handleCancellation } from "./webhookHandlers/handleCancellation"; +import { handleExpiration } from "./webhookHandlers/handleExpiration"; +import { handleInitialPurchase } from "./webhookHandlers/handleInitialPurchase"; +import { handleNonRenewingPurchase } from "./webhookHandlers/handleNonRenewingPurchase"; +import { handleRenewal } from "./webhookHandlers/handleRenewal"; +import { handleUncancellation } from "./webhookHandlers/handleUncancellation"; -export const rcWebhookRouter = new Hono(); +export const revenuecatWebhookRouter = new Hono(); -rcWebhookRouter.post( +revenuecatWebhookRouter.post( "/:orgId/:env", - revcatSeederMiddleware, - revcatLogMiddleware, + revenuecatSeederMiddleware, + revenuecatLogMiddleware, async (c: Context) => { const ctx = c.get("ctx"); const { db, logger, org, features } = ctx; @@ -97,6 +99,14 @@ rcWebhookRouter.post( ctx, }); break; + case "UNCANCELLATION": + await handleUncancellation({ + event: body.event as WebhookUnCancellation, + db, + org, + env, + }); + break; } return c.json({ success: true }, 200); diff --git a/server/src/external/revenueCat/handlers/handleCancellation.ts b/server/src/external/revenueCat/webhookHandlers/handleCancellation.ts similarity index 89% rename from server/src/external/revenueCat/handlers/handleCancellation.ts rename to server/src/external/revenueCat/webhookHandlers/handleCancellation.ts index 6faa7a1a4..6a150f3de 100644 --- a/server/src/external/revenueCat/handlers/handleCancellation.ts +++ b/server/src/external/revenueCat/webhookHandlers/handleCancellation.ts @@ -9,7 +9,7 @@ import { } from "@shared/index"; import type { DrizzleCli } from "@/db/initDrizzle"; import type { Logger } from "@/external/logtail/logtailUtils"; -import { RCMappingService } from "@/external/revenueCat/services/RCMappingService"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; import { CusService } from "@/internal/customers/CusService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts"; @@ -30,14 +30,15 @@ export const handleCancellation = async ({ logger: Logger; features: Feature[]; }) => { - const { product_id, original_app_user_id, app_user_id } = event; + const { product_id, original_app_user_id, app_user_id, expiration_at_ms } = + event; // Look up Autumn product ID from RevenueCat mapping const autumnProductId = await RCMappingService.getAutumnProductId({ db, orgId: org.id, env, - revcatProductId: product_id, + revenuecatProductId: product_id, }); if (!autumnProductId) { @@ -114,16 +115,16 @@ export const handleCancellation = async ({ cusProductId: curSameProduct.id, updates: { canceled_at: Date.now(), - ended_at: event.expiration_at_ms, + ended_at: expiration_at_ms, }, }); logger.info( - `Marked cus_product ${curSameProduct.id} as cancelled, will expire at ${event.expiration_at_ms}`, + `Marked cus_product ${curSameProduct.id} as cancelled, will expire at ${expiration_at_ms}`, ); await deleteCachedApiCustomer({ - customerId: event.original_app_user_id ?? event.app_user_id, + customerId: original_app_user_id ?? app_user_id, orgId: org.id, env, }); diff --git a/server/src/external/revenueCat/handlers/handleExpiration.ts b/server/src/external/revenueCat/webhookHandlers/handleExpiration.ts similarity index 96% rename from server/src/external/revenueCat/handlers/handleExpiration.ts rename to server/src/external/revenueCat/webhookHandlers/handleExpiration.ts index c8b72c9f0..35bd769c0 100644 --- a/server/src/external/revenueCat/handlers/handleExpiration.ts +++ b/server/src/external/revenueCat/webhookHandlers/handleExpiration.ts @@ -10,7 +10,7 @@ import { } from "@shared/index"; import type { DrizzleCli } from "@/db/initDrizzle"; import type { Logger } from "@/external/logtail/logtailUtils"; -import { RCMappingService } from "@/external/revenueCat/services/RCMappingService"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { CusService } from "@/internal/customers/CusService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; @@ -44,7 +44,7 @@ export const handleExpiration = async ({ db, orgId: org.id, env, - revcatProductId: product_id, + revenuecatProductId: product_id, }); if (!autumnProductId) { diff --git a/server/src/external/revenueCat/handlers/handleInitialPurchase.ts b/server/src/external/revenueCat/webhookHandlers/handleInitialPurchase.ts similarity index 95% rename from server/src/external/revenueCat/handlers/handleInitialPurchase.ts rename to server/src/external/revenueCat/webhookHandlers/handleInitialPurchase.ts index 877c8f585..83ffbdec1 100644 --- a/server/src/external/revenueCat/handlers/handleInitialPurchase.ts +++ b/server/src/external/revenueCat/webhookHandlers/handleInitialPurchase.ts @@ -13,7 +13,7 @@ import { import type { DrizzleCli } from "@/db/initDrizzle"; import { createStripeCli } from "@/external/connect/createStripeCli"; import type { Logger } from "@/external/logtail/logtailUtils.js"; -import { RCMappingService } from "@/external/revenueCat/services/RCMappingService"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct"; import { CusService } from "@/internal/customers/CusService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; @@ -48,7 +48,7 @@ export const handleInitialPurchase = async ({ db, orgId: org.id, env, - revcatProductId: product_id, + revenuecatProductId: product_id, }); if (!autumnProductId) { @@ -161,9 +161,6 @@ export const handleInitialPurchase = async ({ logger, scenario, processorType: ProcessorType.RevenueCat, - externalSubIds: [ - { type: ProcessorType.RevenueCat, id: original_transaction_id }, - ], attachParams: attachToInsertParams( { customer, diff --git a/server/src/external/revenueCat/handlers/handleNonRenewingPurchase.ts b/server/src/external/revenueCat/webhookHandlers/handleNonRenewingPurchase.ts similarity index 92% rename from server/src/external/revenueCat/handlers/handleNonRenewingPurchase.ts rename to server/src/external/revenueCat/webhookHandlers/handleNonRenewingPurchase.ts index 2bf0422eb..133d1b780 100644 --- a/server/src/external/revenueCat/handlers/handleNonRenewingPurchase.ts +++ b/server/src/external/revenueCat/webhookHandlers/handleNonRenewingPurchase.ts @@ -11,7 +11,7 @@ import { import type { DrizzleCli } from "@/db/initDrizzle"; import { createStripeCli } from "@/external/connect/createStripeCli"; import type { Logger } from "@/external/logtail/logtailUtils.js"; -import { RCMappingService } from "@/external/revenueCat/services/RCMappingService"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct"; import { CusService } from "@/internal/customers/CusService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; @@ -40,12 +40,12 @@ export const handleNonRenewingPurchase = async ({ db, orgId: org.id, env, - revcatProductId: event.product_id, + revenuecatProductId: event.product_id, }); if (!autumnProductId) { logger.error("No Autumn product mapped to RevenueCat product", { - revcatProductId: event.product_id, + revenuecatProductId: event.product_id, }); return; } @@ -110,9 +110,6 @@ export const handleNonRenewingPurchase = async ({ logger, scenario, processorType: ProcessorType.RevenueCat, - externalSubIds: [ - { type: ProcessorType.RevenueCat, id: event.original_transaction_id }, - ], attachParams: attachToInsertParams( { customer, diff --git a/server/src/external/revenueCat/handlers/handleRenewal.ts b/server/src/external/revenueCat/webhookHandlers/handleRenewal.ts similarity index 93% rename from server/src/external/revenueCat/handlers/handleRenewal.ts rename to server/src/external/revenueCat/webhookHandlers/handleRenewal.ts index 166ab17e5..f8ae20909 100644 --- a/server/src/external/revenueCat/handlers/handleRenewal.ts +++ b/server/src/external/revenueCat/webhookHandlers/handleRenewal.ts @@ -13,7 +13,7 @@ import { import type { DrizzleCli } from "@/db/initDrizzle"; import { createStripeCli } from "@/external/connect/createStripeCli"; import type { Logger } from "@/external/logtail/logtailUtils"; -import { RCMappingService } from "@/external/revenueCat/services/RCMappingService"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct"; import { CusService } from "@/internal/customers/CusService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; @@ -41,17 +41,18 @@ export const handleRenewal = async ({ logger: Logger; features: Feature[]; }) => { + const { product_id, app_user_id, original_transaction_id } = event; // Look up Autumn product ID from RevenueCat mapping const autumnProductId = await RCMappingService.getAutumnProductId({ db, orgId: org.id, env, - revcatProductId: event.product_id, + revenuecatProductId: product_id, }); if (!autumnProductId) { throw new RecaseError({ - message: `No Autumn product mapped to RevenueCat product: ${event.product_id}`, + message: `No Autumn product mapped to RevenueCat product: ${product_id}`, code: ErrCode.ProductNotFound, statusCode: 404, }); @@ -66,7 +67,7 @@ export const handleRenewal = async ({ }), CusService.getFull({ db, - idOrInternalId: event.app_user_id, + idOrInternalId: app_user_id, orgId: org.id, env, }), @@ -179,9 +180,6 @@ export const handleRenewal = async ({ logger, scenario, processorType: ProcessorType.RevenueCat, - externalSubIds: [ - { type: ProcessorType.RevenueCat, id: event.original_transaction_id }, - ], attachParams: attachToInsertParams( { customer, diff --git a/server/src/external/revenueCat/handlers/handleUncancellation.ts b/server/src/external/revenueCat/webhookHandlers/handleUncancellation.ts similarity index 93% rename from server/src/external/revenueCat/handlers/handleUncancellation.ts rename to server/src/external/revenueCat/webhookHandlers/handleUncancellation.ts index cff061f28..3cf57a2f8 100644 --- a/server/src/external/revenueCat/handlers/handleUncancellation.ts +++ b/server/src/external/revenueCat/webhookHandlers/handleUncancellation.ts @@ -8,7 +8,7 @@ import { RecaseError, } from "@shared/index"; import type { DrizzleCli } from "@/db/initDrizzle"; -import { RCMappingService } from "@/external/revenueCat/services/RCMappingService"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; import { CusService } from "@/internal/customers/CusService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; @@ -32,7 +32,7 @@ export const handleUncancellation = async ({ db, orgId: org.id, env, - revcatProductId: product_id, + revenuecatProductId: product_id, }); if (!autumnProductId) { @@ -108,7 +108,7 @@ export const handleUncancellation = async ({ }); await deleteCachedApiCustomer({ - customerId: event.original_app_user_id ?? event.app_user_id, + customerId: original_app_user_id ?? app_user_id, orgId: org.id, env, }); diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 4b5e21587..30e6a23ac 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -1,8 +1,7 @@ import { getRequestListener } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; -import { handleRevenueCatOauthCallback } from "./external/revenueCat/handleRCOAuthCallback.js"; -import { rcWebhookRouter } from "./external/revenueCat/rcWebhookRouter.js"; +import { revenuecatWebhookRouter } from "./external/revenueCat/revenuecatWebhookRouter.js"; import { vercelWebhookRouter } from "./external/vercel/vercelWebhookRouter.js"; import { handleConnectWebhook } from "./external/webhooks/connectWebhookRouter.js"; import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js"; @@ -69,7 +68,6 @@ export const createHonoApp = () => { // Health check endpoint for AWS/ECS load balancer app.get("/stripe/oauth_callback", handleOAuthCallback); - app.get("/revenuecat/oauth_callback", ...handleRevenueCatOauthCallback); // Step 1: Base middleware - sets up ctx (db, logger, etc.) app.use("*", baseMiddleware); @@ -95,7 +93,7 @@ export const createHonoApp = () => { // Webhook routes app.post("/webhooks/connect/:env", handleConnectWebhook); app.route("/webhooks/vercel", vercelWebhookRouter); - app.route("/webhooks/revenuecat", rcWebhookRouter); + app.route("/webhooks/revenuecat", revenuecatWebhookRouter); // API Middleware app.route("/v1", apiRouter); diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 23e7ad6ed..3d8c86972 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -6,7 +6,6 @@ import { type Customer, type CustomerEntitlement, type CustomerPrice, - type ExternalSubID, type FeatureOptions, type FreeTrial, type FullCusProduct, @@ -73,7 +72,6 @@ export const initCusProduct = ({ createdAt, collectionMethod, subscriptionIds, - externalSubIds, subscriptionScheduleIds, isCustom, entityId, @@ -85,7 +83,6 @@ export const initCusProduct = ({ customer: Customer; product: FullProduct; // subscriptionId: string | undefined | null; - externalSubIds?: ExternalSubID[]; // subscriptionScheduleId?: string | null; // lastInvoiceId?: string | null; cusProdId: string; @@ -142,7 +139,6 @@ export const initCusProduct = ({ canceled_at: canceledAt, collection_method: collectionMethod || CollectionMethod.ChargeAutomatically, subscription_ids: subscriptionIds, - external_sub_ids: externalSubIds || [], scheduled_ids: subscriptionScheduleIds, is_custom: isCustom || false, quantity: quantity || 1, @@ -282,7 +278,6 @@ export const createFullCusProduct = async ({ canceledAt = null, createdAt = null, subscriptionIds = [], - externalSubIds = [], subscriptionScheduleIds = [], anchorToUnix, carryExistingUsages = false, @@ -305,7 +300,6 @@ export const createFullCusProduct = async ({ canceledAt?: number | null; createdAt?: number | null; subscriptionIds?: string[]; - externalSubIds?: ExternalSubID[]; subscriptionScheduleIds?: string[]; keepResetIntervals?: boolean; anchorToUnix?: number; @@ -457,7 +451,6 @@ export const createFullCusProduct = async ({ : CollectionMethod.ChargeAutomatically, subscriptionIds, subscriptionScheduleIds, - externalSubIds, isCustom: attachParams.isCustom || false, internalEntityId: attachParams.internalEntityId, entityId: attachParams.entityId, diff --git a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts index ac595a8f3..7a8a77c3c 100644 --- a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts +++ b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts @@ -1,28 +1,19 @@ import { type AppEnv, - AttachScenario, - CusProductStatus, type Feature, type FullCusProduct, - type FullCustomer, type FullProduct, type MigrationJob, type Organization, ProcessorType, } from "@autumn/shared"; -import type { Stripe } from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { CusService } from "@/internal/customers/CusService.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { attachToInsertParams } from "@/internal/products/productUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; import type { Logger } from "../../../external/logtail/logtailUtils.js"; -import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; -import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; -import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js"; -import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js"; +import { migrateRevenueCatCustomer } from "./migrateRevenuecatCustomer.js"; +import { migrateStripeCustomer } from "./migrateStripeCustomer.js"; export const migrateCustomer = async ({ db, @@ -110,151 +101,3 @@ export const migrateCustomer = async ({ return false; } }; - -export const migrateStripeCustomer = async ({ - req, - stripeCli, - fullCus, - cusProduct, - toProduct, - fromProduct, - customerId, - orgId, - env, -}: { - req: ExtendedRequest; - stripeCli: Stripe; - fullCus: FullCustomer; - cusProduct: FullCusProduct; - toProduct: FullProduct; - fromProduct: FullProduct; - customerId: string; - orgId: string; - env: AppEnv; -}) => { - const attachParams = await migrationToAttachParams({ - req, - stripeCli, - customer: fullCus, - cusProduct, - newProduct: toProduct, - }); - - await runMigrationAttach({ - ctx: req as unknown as AutumnContext, - attachParams, - fromProduct, - }); - - await deleteCachedApiCustomer({ - customerId, - orgId, - env, - }); -}; - -export const migrateRevenueCatCustomer = async ({ - req, - fullCus, - cusProduct, - toProduct, - customerId, - orgId, - env, -}: { - req: ExtendedRequest; - fullCus: FullCustomer; - cusProduct: FullCusProduct; - toProduct: FullProduct; - customerId: string; - orgId: string; - env: AppEnv; -}) => { - const { logger } = req; - - // Debug: Log the old cusProduct dates - logger.info(`[RC Migration] Old cusProduct dates:`, { - cusProductId: cusProduct.id, - created_at: cusProduct.created_at, - created_at_date: cusProduct.created_at - ? new Date(cusProduct.created_at).toISOString() - : null, - starts_at: cusProduct.starts_at, - starts_at_date: cusProduct.starts_at - ? new Date(cusProduct.starts_at).toISOString() - : null, - }); - - await CusProductService.update({ - db: req.db, - cusProductId: cusProduct.id, - updates: { - status: CusProductStatus.Expired, - ended_at: Date.now(), - }, - }); - - const createdAtToPass = cusProduct.created_at; - const startsAtToPass = cusProduct.starts_at; - const anchorToPass = cusProduct.created_at; - - // Debug: Log what we're passing to createFullCusProduct - logger.info(`[RC Migration] Passing to createFullCusProduct:`, { - createdAt: createdAtToPass, - createdAt_date: createdAtToPass - ? new Date(createdAtToPass).toISOString() - : null, - startsAt: startsAtToPass, - startsAt_date: startsAtToPass - ? new Date(startsAtToPass).toISOString() - : null, - anchorToUnix: anchorToPass, - anchorToUnix_date: anchorToPass - ? new Date(anchorToPass).toISOString() - : null, - createdAt_type: typeof createdAtToPass, - }); - - await createFullCusProduct({ - db: req.db, - logger: req.logger, - scenario: AttachScenario.New, - processorType: ProcessorType.RevenueCat, - externalSubIds: [ - { - type: ProcessorType.RevenueCat, - id: cusProduct.external_sub_ids?.[0]?.id ?? "", - }, - ], - // Preserve the original created_at, starts_at, and billing cycle anchor - createdAt: createdAtToPass, - startsAt: startsAtToPass, - anchorToUnix: anchorToPass, - carryExistingUsages: true, - attachParams: attachToInsertParams( - { - customer: fullCus, - products: [toProduct], - prices: toProduct.prices, - entitlements: toProduct.entitlements, - entities: fullCus.entities || [], - org: req.org, - stripeCli: createStripeCli({ org: req.org, env: req.env }), - paymentMethod: null, - freeTrial: null, - optionsList: cusProduct.options || [], - cusProducts: fullCus.customer_products, - replaceables: [], - features: req.features, - fromMigration: true, - }, - toProduct, - ), - }); - - await deleteCachedApiCustomer({ - customerId, - orgId, - env, - }); -}; diff --git a/server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts b/server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts new file mode 100644 index 000000000..e1f371656 --- /dev/null +++ b/server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts @@ -0,0 +1,114 @@ +import { + type AppEnv, + AttachScenario, + CusProductStatus, + type FullCusProduct, + type FullCustomer, + type FullProduct, + ProcessorType, +} from "@autumn/shared"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; +import { attachToInsertParams } from "@/internal/products/productUtils.js"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; +import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; + +export const migrateRevenueCatCustomer = async ({ + req, + fullCus, + cusProduct, + toProduct, + customerId, + orgId, + env, +}: { + req: ExtendedRequest; + fullCus: FullCustomer; + cusProduct: FullCusProduct; + toProduct: FullProduct; + customerId: string; + orgId: string; + env: AppEnv; +}) => { + const { logger } = req; + + // Debug: Log the old cusProduct dates + logger.info(`[RC Migration] Old cusProduct dates:`, { + cusProductId: cusProduct.id, + created_at: cusProduct.created_at, + created_at_date: cusProduct.created_at + ? new Date(cusProduct.created_at).toISOString() + : null, + starts_at: cusProduct.starts_at, + starts_at_date: cusProduct.starts_at + ? new Date(cusProduct.starts_at).toISOString() + : null, + }); + + await CusProductService.update({ + db: req.db, + cusProductId: cusProduct.id, + updates: { + status: CusProductStatus.Expired, + ended_at: Date.now(), + }, + }); + + const createdAtToPass = cusProduct.created_at; + const startsAtToPass = cusProduct.starts_at; + const anchorToPass = cusProduct.created_at; + + // Debug: Log what we're passing to createFullCusProduct + logger.info(`[RC Migration] Passing to createFullCusProduct:`, { + createdAt: createdAtToPass, + createdAt_date: createdAtToPass + ? new Date(createdAtToPass).toISOString() + : null, + startsAt: startsAtToPass, + startsAt_date: startsAtToPass + ? new Date(startsAtToPass).toISOString() + : null, + anchorToUnix: anchorToPass, + anchorToUnix_date: anchorToPass + ? new Date(anchorToPass).toISOString() + : null, + createdAt_type: typeof createdAtToPass, + }); + + await createFullCusProduct({ + db: req.db, + logger: req.logger, + scenario: AttachScenario.New, + processorType: ProcessorType.RevenueCat, + // Preserve the original created_at, starts_at, and billing cycle anchor + createdAt: createdAtToPass, + anchorToUnix: anchorToPass, + carryExistingUsages: true, + attachParams: attachToInsertParams( + { + customer: fullCus, + products: [toProduct], + prices: toProduct.prices, + entitlements: toProduct.entitlements, + entities: fullCus.entities || [], + org: req.org, + stripeCli: createStripeCli({ org: req.org, env: req.env }), + paymentMethod: null, + freeTrial: null, + optionsList: cusProduct.options || [], + cusProducts: fullCus.customer_products, + replaceables: [], + features: req.features, + fromMigration: true, + }, + toProduct, + ), + }); + + await deleteCachedApiCustomer({ + customerId, + orgId, + env, + }); +}; diff --git a/server/src/internal/migrations/migrationSteps/migrateStripeCustomer.ts b/server/src/internal/migrations/migrationSteps/migrateStripeCustomer.ts new file mode 100644 index 000000000..cf1b674f5 --- /dev/null +++ b/server/src/internal/migrations/migrationSteps/migrateStripeCustomer.ts @@ -0,0 +1,54 @@ +import { + type AppEnv, + type FullCusProduct, + type FullCustomer, + type FullProduct, +} from "@autumn/shared"; +import type { Stripe } from "stripe"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; +import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; +import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js"; +import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js"; + +export const migrateStripeCustomer = async ({ + req, + stripeCli, + fullCus, + cusProduct, + toProduct, + fromProduct, + customerId, + orgId, + env, +}: { + req: ExtendedRequest; + stripeCli: Stripe; + fullCus: FullCustomer; + cusProduct: FullCusProduct; + toProduct: FullProduct; + fromProduct: FullProduct; + customerId: string; + orgId: string; + env: AppEnv; +}) => { + const attachParams = await migrationToAttachParams({ + req, + stripeCli, + customer: fullCus, + cusProduct, + newProduct: toProduct, + }); + + await runMigrationAttach({ + ctx: req as unknown as AutumnContext, + attachParams, + fromProduct, + }); + + await deleteCachedApiCustomer({ + customerId, + orgId, + env, + }); +}; diff --git a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts index 39ec10f38..ff19ec644 100644 --- a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts +++ b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts @@ -8,6 +8,7 @@ import { import { createSvixApp } from "@server/external/svix/svixHelpers.js"; import { createSvixCli } from "@server/external/svix/svixUtils.js"; import { createRoute } from "@server/honoMiddlewares/routeHandler.js"; +import { decryptData, encryptData } from "@server/utils/encryptUtils.js"; import { mask } from "@server/utils/genUtils.js"; import type { ApplicationOut } from "svix"; import { OrgService } from "../OrgService.js"; @@ -44,7 +45,7 @@ export const getRevenueCatConfigDisplay = ({ }; } - const apiKey = + const apiKeyEncrypted = env === AppEnv.Live ? revenueCatConfig.api_key : revenueCatConfig.sandbox_api_key; @@ -53,6 +54,15 @@ export const getRevenueCatConfigDisplay = ({ ? revenueCatConfig.webhook_secret : revenueCatConfig.sandbox_webhook_secret; + let apiKey: string | undefined; + if (apiKeyEncrypted) { + try { + apiKey = decryptData(apiKeyEncrypted); + } catch { + apiKey = apiKeyEncrypted; + } + } + return { connected: !!apiKey && !!webhookSecret, api_key: mask(apiKey, 3, 2), @@ -134,9 +144,9 @@ export const handleUpsertRevenueCatConfig = createRoute({ revenuecat: { ...existingRevenueCatConfig, // Live fields - ...(body.api_key ? { api_key: body.api_key } : {}), + ...(body.api_key ? { api_key: encryptData(body.api_key) } : {}), ...(body.sandbox_api_key - ? { sandbox_api_key: body.sandbox_api_key } + ? { sandbox_api_key: encryptData(body.sandbox_api_key) } : {}), ...(body.project_id ? { project_id: body.project_id } : {}), ...(body.sandbox_project_id diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts index 5ef578d51..6561fc4b8 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; -import { handleGetRCMappings } from "@/external/revenueCat/misc/handleGetRCMappings.js"; -import { handleGetRevenueCatProducts } from "@/external/revenueCat/misc/handleGetRevenueCatProducts.js"; -import { handleSaveRCMappings } from "@/external/revenueCat/misc/handleSaveRCMappings.js"; +import { handleGetRCMappings } from "@/external/revenueCat/handlers/handleGetRevenuecatMappings.js"; +import { handleGetRevenueCatProducts } from "@/external/revenueCat/handlers/handleGetRevenuecatProducts.js"; +import { handleSaveRCMappings } from "@/external/revenueCat/handlers/handleSaveRevenuecatMappings.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleDeleteOrg } from "./handlers/crudHandlers/handleDeleteOrg.js"; import { handleGetOrg } from "./handlers/crudHandlers/handleGetOrg.js"; diff --git a/server/tests/external-psps/revenuecat/rc1.test.ts b/server/tests/external-psps/revenuecat/rc1.test.ts index 86c826bda..d3ffd2cc4 100644 --- a/server/tests/external-psps/revenuecat/rc1.test.ts +++ b/server/tests/external-psps/revenuecat/rc1.test.ts @@ -1,11 +1,19 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, AppEnv, type FullProduct } from "@autumn/shared"; +import { + ApiVersion, + AppEnv, + CusProductStatus, + customers, +} from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; +import { eq } from "drizzle-orm"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { RCMappingService } from "@/external/revenueCat/services/RCMappingService.js"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; +import { encryptData } from "@/utils/encryptUtils.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; @@ -17,6 +25,7 @@ const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345"; // RevenueCat product IDs (what RC sends in webhooks) const RC_PRO_MONTHLY_ID = "com.app.pro_monthly"; const RC_PRO_YEARLY_ID = "com.app.pro_yearly"; +const RC_ADD_ON_ID = "com.app.add_on_pack"; // Autumn product definitions const messagesFeature = constructFeatureItem({ @@ -44,12 +53,87 @@ const proYearly = constructProduct({ isDefault: false, }); -// Store created products for use in tests -const createdProducts: FullProduct[] = []; +const addOnPack = constructProduct({ + id: `${testCase}-add-on`, + type: "one_off", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], + isAddOn: true, + isDefault: false, +}); describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { const customerId = `${testCase}-customer`; const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + let proMonthlyCusProductId: string | null = null; + let internalCustomerId: string | null = null; + + const fetchLatestActiveCusProductId = async () => { + if (!internalCustomerId) { + throw new Error("internalCustomerId not set"); + } + + const cusProducts = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.PastDue, + CusProductStatus.Scheduled, + ], + }); + + const activeSorted = cusProducts + .filter((cp) => cp.status === CusProductStatus.Active) + .sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0)); + + expect( + activeSorted.length > 0, + "Expected at least one active cus_product for customer", + ).toBe(true); + + // Return the latest active cus_product id for the customer + return activeSorted[activeSorted.length - 1]!.id; + }; + + const fetchLatestCusProductIdAnyStatus = async () => { + if (!internalCustomerId) { + throw new Error("internalCustomerId not set"); + } + + const cusProducts = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: undefined, + }); + + if (cusProducts.length === 0) { + return null; + } + + const sorted = [...cusProducts].sort( + (a, b) => (a.created_at ?? 0) - (b.created_at ?? 0), + ); + + return sorted[sorted.length - 1]!.id; + }; + + const getBaselineCusProductId = () => { + if (!proMonthlyCusProductId) { + throw new Error( + "Baseline CusProduct ID was not set from initial purchase", + ); + } + + return proMonthlyCusProductId; + }; + const updateBaselineCusProductId = (cusProductId: string) => { + proMonthlyCusProductId = cusProductId; + }; beforeAll(async () => { // 1. Configure org with RevenueCat processor config @@ -64,8 +148,8 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { processor_configs: { ...ctx.org.processor_configs, revenuecat: { - api_key: "mock_rc_api_key_live", - sandbox_api_key: "mock_rc_api_key_sandbox", + api_key: encryptData("mock_rc_api_key_live"), + sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), project_id: "mock_project_live", sandbox_project_id: "mock_project_sandbox", webhook_secret: RC_WEBHOOK_SECRET, @@ -76,40 +160,52 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { }); } - // 2. Create Autumn products - await initProductsV0({ - ctx, - products: [proMonthly, proYearly], - prefix: testCase, - }); + // 2-4. Create products, mappings, and customer concurrently + await Promise.all([ + initProductsV0({ + ctx, + products: [proMonthly, proYearly, addOnPack], + prefix: testCase, + }), + RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: proMonthly.id, + revenuecat_product_ids: [RC_PRO_MONTHLY_ID], + }, + }), + RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: addOnPack.id, + revenuecat_product_ids: [RC_ADD_ON_ID], + }, + }), + RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: AppEnv.Sandbox, + autumn_product_id: proYearly.id, + revenuecat_product_ids: [RC_PRO_YEARLY_ID], + }, + }), + initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }), + ]); - // 3. Create RevenueCat -> Autumn product mappings - await RCMappingService.upsert({ - db: ctx.db, - data: { - org_id: ctx.org.id, - env: AppEnv.Sandbox, - autumn_product_id: proMonthly.id, - revenuecat_product_ids: [RC_PRO_MONTHLY_ID], - }, - }); - - await RCMappingService.upsert({ - db: ctx.db, - data: { - org_id: ctx.org.id, - env: AppEnv.Sandbox, - autumn_product_id: proYearly.id, - revenuecat_product_ids: [RC_PRO_YEARLY_ID], - }, - }); - - // 4. Create test customer - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), }); + expect(dbCustomer).toBeDefined(); + internalCustomerId = dbCustomer!.internal_id; }); test("should create customer with pro monthly product", async () => { @@ -147,6 +243,8 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { expect(customer.id).toBe(customerId); expect(customer.products).toHaveLength(1); expect(customer.products[0].id).toBe(proMonthly.id); + + proMonthlyCusProductId = await fetchLatestActiveCusProductId(); }); test("should upgrade customer to pro yearly product upon renewal", async () => { @@ -178,7 +276,7 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { respData, `Response data should be success: true. Recieved: ${JSON.stringify(respData)}`, ).toEqual({ success: true }); - + await fetchLatestActiveCusProductId(); const customer = await autumnV1.customers.get(customerId); expect(customer).toBeDefined(); expect(customer.id).toBe(customerId); @@ -221,6 +319,11 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { expect(customer.id).toBe(customerId); expect(customer.products).toHaveLength(1); expect(customer.products[0].id).toBe(proMonthly.id); + + const currentCusProductId = await fetchLatestActiveCusProductId(); + console.log("currentCusProductId", currentCusProductId); + expect(currentCusProductId).not.toBe(getBaselineCusProductId()); + updateBaselineCusProductId(currentCusProductId); }); test("should go to cancelling state upon cancellation", async () => { @@ -234,6 +337,7 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { product_id: RC_PRO_MONTHLY_ID, app_user_id: customerId, original_transaction_id: "1234567890", + expiration_at_ms: Date.now() + 1000 * 60 * 60 * 24 * 30, }, }), headers: { @@ -261,6 +365,50 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { const canceledAt = customer.products[0].canceled_at ?? 0; expect(typeof canceledAt).toBe("number"); expect(Math.abs(Date.now() - canceledAt)).toBeLessThanOrEqual(3000); + + const currentCusProductId = await fetchLatestActiveCusProductId(); + expect(currentCusProductId).toBe(getBaselineCusProductId()); + }); + + test("should uncancel customer after cancellation event", async () => { + const response = await fetch( + `http://localhost:8080/webhooks/revenuecat/${ctx.org.id}/${ctx.env}`, + { + method: "POST", + body: JSON.stringify({ + event: { + type: "UNCANCELLATION", + product_id: RC_PRO_MONTHLY_ID, + app_user_id: customerId, + }, + }), + headers: { + "Content-Type": "application/json", + Authorization: RC_WEBHOOK_SECRET, + }, + }, + ); + + const respData = await response.json(); + + expect( + response.status, + `Response status should be 200, got ${response.status}`, + ).toBe(200); + expect( + respData, + `Response data should be success: true. Recieved: ${JSON.stringify(respData)}`, + ).toEqual({ success: true }); + + const customer = await autumnV1.customers.get(customerId); + expect(customer).toBeDefined(); + expect(customer.id).toBe(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(proMonthly.id); + expect(customer.products[0].canceled_at).toBeNull(); + + const currentCusProductId = await fetchLatestActiveCusProductId(); + expect(currentCusProductId).toBe(getBaselineCusProductId()); }); test("should go to expired state upon expiration", async () => { @@ -297,5 +445,70 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { expect(customer).toBeDefined(); expect(customer.id).toBe(customerId); expect(customer.products).toHaveLength(0); + + const latestCusProductId = await fetchLatestCusProductIdAnyStatus(); + // After expiration, there may no longer be a cus_product row at all. In that + // case, we just assert there are no cus_products for this customer anymore. + if (latestCusProductId === null) { + const allCusProducts = await CusProductService.list({ + db: ctx.db, + internalCustomerId: internalCustomerId!, + inStatuses: undefined, + }); + expect(allCusProducts.length).toBe(0); + } else { + expect(latestCusProductId).toBe(getBaselineCusProductId()); + } + }); + + test("should attach add-on product after expiration via non-renewing purchase", async () => { + const response = await fetch( + `http://localhost:8080/webhooks/revenuecat/${ctx.org.id}/${ctx.env}`, + { + method: "POST", + body: JSON.stringify({ + event: { + type: "NON_RENEWING_PURCHASE", + product_id: RC_ADD_ON_ID, + app_user_id: customerId, + original_transaction_id: "add_on_tx_12345", + }, + }), + headers: { + "Content-Type": "application/json", + Authorization: RC_WEBHOOK_SECRET, + }, + }, + ); + const respData = await response.json(); + + expect( + response.status, + `Response status should be 200, got ${response.status}`, + ).toBe(200); + expect( + respData, + `Response data should be success: true. Recieved: ${JSON.stringify(respData)}`, + ).toEqual({ success: true }); + + const customer = await autumnV1.customers.get(customerId); + expect(customer).toBeDefined(); + expect(customer.id).toBe(customerId); + expect(customer.products).toHaveLength(1); + expect(customer.products[0].id).toBe(addOnPack.id); + + const addOnCusProducts = await CusProductService.getByProductId({ + db: ctx.db, + productId: addOnPack.id, + orgId: ctx.org.id, + env: ctx.env, + limit: 1, + }); + expect( + addOnCusProducts.length > 0, + `CusProduct for add-on product ${addOnPack.id} should exist`, + ).toBe(true); + const addOnCusProductId = addOnCusProducts[0]!.id; + expect(typeof addOnCusProductId).toBe("string"); }); }); diff --git a/shared/db/schema.ts b/shared/db/schema.ts index 76c4f2008..54c2a5353 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -39,7 +39,7 @@ import { migrationJobs } from "../models/migrationModels/migrationJobTable.js"; import { organizationsRelations } from "../models/orgModels/orgRelations.js"; import { organizations } from "../models/orgModels/orgTable.js"; import { metadata } from "../models/otherModels/metadataTable.js"; -import { revcatMappings } from "../models/processorModels/revcatModels/revcatMappingsTable.js"; +import { revenuecatMappings } from "../models/processorModels/revenuecatModels/revenuecatMappingsTable.js"; import { vercelResources } from "../models/processorModels/vercelModels/vercelResourcesTable.js"; // Product Relations import { entitlementsRelations } from "../models/productModels/entModels/entRelations.js"; @@ -104,7 +104,7 @@ export { replaceables, rollovers, vercelResources, - revcatMappings, + revenuecatMappings as revcatMappings, // Auth user, session, diff --git a/shared/models/cusProductModels/cusProductModels.ts b/shared/models/cusProductModels/cusProductModels.ts index f3ba48130..873f9e42b 100644 --- a/shared/models/cusProductModels/cusProductModels.ts +++ b/shared/models/cusProductModels/cusProductModels.ts @@ -1,5 +1,4 @@ import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; -import { ExternalSubIDSchema } from "@models/genModels/processorSchemas.js"; import { z } from "zod/v4"; import { CustomerSchema } from "../cusModels/cusModels.js"; import { ProcessorType } from "../genModels/genEnums.js"; @@ -51,7 +50,6 @@ export const CusProductSchema = z.object({ // Fixed-cycle configuration subscription_ids: z.array(z.string()).nullish(), - external_sub_ids: z.array(ExternalSubIDSchema).nullish(), scheduled_ids: z.array(z.string()).nullish(), processor: z .object({ diff --git a/shared/models/cusProductModels/cusProductTable.ts b/shared/models/cusProductModels/cusProductTable.ts index d4909ffd7..98a9694cd 100644 --- a/shared/models/cusProductModels/cusProductTable.ts +++ b/shared/models/cusProductModels/cusProductTable.ts @@ -1,4 +1,3 @@ -import type { ExternalSubID } from "@models/genModels/processorSchemas.js"; import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; import { boolean, @@ -44,9 +43,6 @@ export const customerProducts = pgTable( "charge_automatically", ), subscription_ids: text("subscription_ids").array(), - external_sub_ids: jsonb("external_sub_ids") - .array() - .$type(), scheduled_ids: text("scheduled_ids").array(), quantity: numeric({ mode: "number" }).default(1), diff --git a/shared/models/processorModels/processorModels.ts b/shared/models/processorModels/processorModels.ts index 2d0f79a98..b7761620b 100644 --- a/shared/models/processorModels/processorModels.ts +++ b/shared/models/processorModels/processorModels.ts @@ -1,3 +1,3 @@ // Vercel + RevenueCat -export * from "./revcatModels/revcatMappingsTable.js"; +export * from "./revenuecatModels/revenuecatMappingsTable.js"; export * from "./vercelModels/vercelResourcesTable.js"; diff --git a/shared/models/processorModels/revcatModels/revcatMappingsTable.ts b/shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts similarity index 67% rename from shared/models/processorModels/revcatModels/revcatMappingsTable.ts rename to shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts index 43cfa1b7e..4e0da9b4a 100644 --- a/shared/models/processorModels/revcatModels/revcatMappingsTable.ts +++ b/shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts @@ -2,8 +2,9 @@ import type { AppEnv } from "@models/genModels/genEnums"; import { organizations } from "@models/orgModels/orgTable.js"; import { foreignKey, pgTable, primaryKey, text } from "drizzle-orm/pg-core"; -export const revcatMappings = pgTable( - "revcat_mappings", +// biome-ignore lint/suspicious/noExplicitAny: Drizzle table typing workaround for Bun TS2742 error +export const revenuecatMappings: any = pgTable( + "revenuecat_mappings", { org_id: text("org_id").notNull(), env: text("env").$type().notNull(), @@ -16,14 +17,14 @@ export const revcatMappings = pgTable( (table) => [ primaryKey({ columns: [table.org_id, table.env, table.autumn_product_id], - name: "revcat_mappings_pkey", + name: "revenuecat_mappings_pkey", }), foreignKey({ columns: [table.org_id], foreignColumns: [organizations.id], - name: "revcat_mappings_org_id_fkey", + name: "revenuecat_mappings_org_id_fkey", }).onDelete("cascade"), ], ); -export type RevcatMapping = typeof revcatMappings.$inferSelect; +export type RevenuecatMapping = typeof revenuecatMappings.$inferSelect; diff --git a/vite/src/components/v2/icons/AutumnIcons.tsx b/vite/src/components/v2/icons/AutumnIcons.tsx index 1d40ef629..8449d7bcb 100644 --- a/vite/src/components/v2/icons/AutumnIcons.tsx +++ b/vite/src/components/v2/icons/AutumnIcons.tsx @@ -364,3 +364,31 @@ export const FreeTrialIcon = ({ ); }; + +export const RevenueCatIcon = ({ + size = 32, + color = "currentColor", +}: { + size?: number; + color?: string; +}) => { + return ( + + + + + ); +}; diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatMappingSheet.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatMappingSheet.tsx index 75425bbcd..3a1041808 100644 --- a/vite/src/views/developer/configure-revenuecat/components/RevenueCatMappingSheet.tsx +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatMappingSheet.tsx @@ -93,7 +93,10 @@ function MappingRow({ {/* Select to add more products */} {availableProducts.length === 0 ? ( -
All products mapped
+
+ All products mapped or none available. Please ensure you have created + products in RevenueCat before mapping. +
) : (