diff --git a/bun.lock b/bun.lock index 98fee6c9e..da9accfa3 100644 --- a/bun.lock +++ b/bun.lock @@ -65,7 +65,6 @@ "@opentelemetry/sdk-trace-base": "^2.0.1", "@opentelemetry/sdk-trace-node": "^2.0.1", "@opentelemetry/semantic-conventions": "^1.34.0", - "@puzzmo/revenue-cat-webhook-types": "^1.1.0", "@react-email/components": "^0.0.42", "@sentry/bun": "catalog:", "@supabase/supabase-js": "^2.46.2", @@ -76,7 +75,6 @@ "@upstash/redis": "^1.35.6", "@vercel/sdk": "^1.17.0", "ai": "^4.3.10", - "arctic": "^3.7.0", "autumn-js": "^0.1.8", "axios": "^1.8.3", "better-auth": "^1.2.9", @@ -967,16 +965,6 @@ "@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="], - "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], - - "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], - - "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], - - "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], - - "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], - "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], @@ -1011,8 +999,6 @@ "@puppeteer/browsers": ["@puppeteer/browsers@2.11.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ=="], - "@puzzmo/revenue-cat-webhook-types": ["@puzzmo/revenue-cat-webhook-types@1.1.0", "", {}, "sha512-ChEa8v4dHUlZyQ7mt5i8x2lBcZ7STRSe3D+YO+DhXicJI0Thjcy23Ehbn9l071bxgmbeH7OErOphJrCLpq/SEg=="], - "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -1667,8 +1653,6 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - "arctic": ["arctic@3.7.0", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="], - "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -3205,11 +3189,8 @@ "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], -<<<<<<< HEAD -======= "@autumn/server/@clickhouse/client": ["@clickhouse/client@1.14.0", "", { "dependencies": { "@clickhouse/client-common": "1.14.0" } }, "sha512-co2spjR7wZoZ3Ck0H/jv76bpiuO3oJHtOmq9/gxFiod2DcT9NFg01u/hXcG8MJFnEJuMB6e3vGqS6IOnLwHqRw=="], ->>>>>>> main "@autumn/vite/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], @@ -3692,8 +3673,6 @@ "@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.1", "@opentelemetry/core": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-UhdbPF19pMpBtCWYP5lHbTogLWx9N0EBxtdagvkn5YtsAnCBZzL7SjktG+ZmupRgifsHMjwUaCCaVmqGfSADmA=="], - "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], - "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg=="], @@ -3984,11 +3963,8 @@ "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], -<<<<<<< HEAD -======= "@autumn/server/@clickhouse/client/@clickhouse/client-common": ["@clickhouse/client-common@1.14.0", "", {}, "sha512-CyUcv2iCkZ1A++vmOSufYRpHR3aAWVfbrWed7ATzf0yyx/BW/2SEqlL07vBpSRa3BIkQe/DSOHVv8JkWZpUOwQ=="], ->>>>>>> main "@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], diff --git a/package.json b/package.json index ba8f31aab..64a5ee006 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "scripts": { "dev": "bun scripts/dev.ts", "vite:build": "bun -F @autumn/vite build:bun", - "d": "ENV_FILE=.env infisical run --watch --env=dev -- bun scripts/dev.ts", + "d": "ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts", "p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts", "setup": "node scripts/setup/setup.js", "setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts", diff --git a/scripts/testGroups/g2.sh b/scripts/testGroups/g2.sh index f16849227..68024a63d 100755 --- a/scripts/testGroups/g2.sh +++ b/scripts/testGroups/g2.sh @@ -3,6 +3,7 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_COMPACT \ + 'server/tests/attach/entities' \ 'server/tests/attach/basic' \ 'server/tests/attach/upgrade' \ 'server/tests/attach/downgrade' \ @@ -14,9 +15,5 @@ BUN_PARALLEL_COMPACT \ 'server/tests/billing/cancel' \ 'server/tests/billing/cancel/add-ons' \ 'server/tests/renew' \ - --max=6 + --max=6 \ -BUN_PARALLEL_COMPACT \ - 'server/tests/attach/entities' \ - --max=6 - # 'server/tests/external-psps/revenuecat' \ diff --git a/server/experiments/revenuecat.ts b/server/experiments/revenuecat.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/package.json b/server/package.json index 8e022a8b4..74abde3f0 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "email": "email dev -p 3001", - "d": "ENV_FILE=.env infisical run --watch --env=dev -- bun dev", + "d": "ENV_FILE=.env infisical run --env=dev -- bun dev", "p": "ENV_FILE=.env.prod infisical run --env=prod -- bun dev", "w": "ENV_FILE=.env infisical run --env=dev -- bun workers:dev", "c": "ENV_FILE=.env infisical run --env=dev -- bun cron", @@ -55,7 +55,6 @@ "@opentelemetry/sdk-trace-base": "^2.0.1", "@opentelemetry/sdk-trace-node": "^2.0.1", "@opentelemetry/semantic-conventions": "^1.34.0", - "@puzzmo/revenue-cat-webhook-types": "^1.1.0", "@react-email/components": "^0.0.42", "@sentry/bun": "catalog:", "@supabase/supabase-js": "^2.46.2", @@ -66,7 +65,6 @@ "@upstash/redis": "^1.35.6", "@vercel/sdk": "^1.17.0", "ai": "^4.3.10", - "arctic": "^3.7.0", "autumn-js": "^0.1.8", "axios": "^1.8.3", "better-auth": "^1.2.9", diff --git a/server/src/external/revenueCat/handlers/handleGetRevenuecatMappings.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatMappings.ts deleted file mode 100644 index f95a0b82e..000000000 --- a/server/src/external/revenueCat/handlers/handleGetRevenuecatMappings.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createRoute } from "@/honoMiddlewares/routeHandler"; -import { RCMappingService } from "../misc/RCMappingService"; - -export const handleGetRCMappings = createRoute({ - handler: async (c) => { - const { db, org, env } = c.get("ctx"); - - const mappings = await RCMappingService.getAll({ - db, - orgId: org.id, - env, - }); - - return c.json({ mappings }); - }, -}); diff --git a/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts deleted file mode 100644 index f97ce011e..000000000 --- a/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/handlers/handleSaveRevenuecatMappings.ts b/server/src/external/revenueCat/handlers/handleSaveRevenuecatMappings.ts deleted file mode 100644 index 67609fa70..000000000 --- a/server/src/external/revenueCat/handlers/handleSaveRevenuecatMappings.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from "zod/v4"; -import { createRoute } from "@/honoMiddlewares/routeHandler"; -import { RCMappingService } from "../misc/RCMappingService"; - -const MappingSchema = z.object({ - autumn_product_id: z.string(), - revenuecat_product_ids: z.array(z.string()), -}); - -export const handleSaveRCMappings = createRoute({ - body: z.object({ - mappings: z.array(MappingSchema), - }), - handler: async (c) => { - const { db, org, env } = c.get("ctx"); - const { mappings } = c.req.valid("json"); - - // Process each mapping - for (const mapping of mappings) { - if (mapping.revenuecat_product_ids.length > 0) { - // Upsert mapping if it has products - await RCMappingService.upsert({ - db, - data: { - org_id: org.id, - env, - autumn_product_id: mapping.autumn_product_id, - revenuecat_product_ids: mapping.revenuecat_product_ids, - }, - }); - } else { - // Delete mapping if no products assigned - await RCMappingService.delete({ - db, - orgId: org.id, - env, - autumnProductId: mapping.autumn_product_id, - }); - } - } - - // Return updated mappings - const updatedMappings = await RCMappingService.getAll({ - db, - orgId: org.id, - env, - }); - - return c.json({ mappings: updatedMappings }); - }, -}); diff --git a/server/src/external/revenueCat/misc/RCMappingService.ts b/server/src/external/revenueCat/misc/RCMappingService.ts deleted file mode 100644 index ee643acfb..000000000 --- a/server/src/external/revenueCat/misc/RCMappingService.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - type AppEnv, - type RevenuecatMapping, - revenuecatMappings, -} from "@shared/index"; -import { and, arrayContains, eq } from "drizzle-orm"; -import type { DrizzleCli } from "@/db/initDrizzle"; - -export class RCMappingService { - /** - * Find the Autumn product ID that maps to a given RevenueCat product ID - */ - static async getAutumnProductId({ - db, - orgId, - env, - revenuecatProductId, - }: { - db: DrizzleCli; - orgId: string; - env: AppEnv; - revenuecatProductId: string; - }): Promise { - const [mapping] = await db - .select({ autumn_product_id: revenuecatMappings.autumn_product_id }) - .from(revenuecatMappings) - .where( - and( - eq(revenuecatMappings.org_id, orgId), - eq(revenuecatMappings.env, env), - arrayContains(revenuecatMappings.revenuecat_product_ids, [ - revenuecatProductId, - ]), - ), - ) - .limit(1); - - return mapping?.autumn_product_id ?? null; - } - - static async getAll({ - db, - orgId, - env, - }: { - db: DrizzleCli; - orgId: string; - env: AppEnv; - }) { - return db - .select() - .from(revenuecatMappings) - .where( - and( - eq(revenuecatMappings.org_id, orgId), - eq(revenuecatMappings.env, env), - ), - ); - } - - static async upsert({ - db, - data, - }: { - db: DrizzleCli; - data: RevenuecatMapping; - }) { - return db - .insert(revenuecatMappings) - .values(data) - .onConflictDoUpdate({ - target: [ - revenuecatMappings.org_id, - revenuecatMappings.env, - revenuecatMappings.autumn_product_id, - ], - set: { revenuecat_product_ids: data.revenuecat_product_ids }, - }) - .returning(); - } - - static async get({ - db, - orgId, - env, - autumnProductId, - }: { - db: DrizzleCli; - orgId: string; - env: AppEnv; - autumnProductId: string; - }) { - const mapping = await db - .select() - .from(revenuecatMappings) - .where( - and( - eq(revenuecatMappings.org_id, orgId), - eq(revenuecatMappings.env, env), - eq(revenuecatMappings.autumn_product_id, autumnProductId), - ), - ); - return mapping; - } - - static async update({ - db, - orgId, - env, - autumnProductId, - data, - }: { - db: DrizzleCli; - orgId: string; - env: AppEnv; - autumnProductId: string; - data: Partial; - }) { - const mapping = await db - .update(revenuecatMappings) - .set(data) - .where( - and( - eq(revenuecatMappings.org_id, orgId), - eq(revenuecatMappings.env, env), - eq(revenuecatMappings.autumn_product_id, autumnProductId), - ), - ); - return mapping; - } - - static async delete({ - db, - orgId, - env, - autumnProductId, - }: { - db: DrizzleCli; - orgId: string; - env: AppEnv; - autumnProductId: string; - }) { - const mapping = await db - .delete(revenuecatMappings) - .where( - and( - 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/getRevenuecatWebhookSecret.ts b/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts deleted file mode 100644 index 45e504c66..000000000 --- a/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AppEnv, type Organization } from "@autumn/shared"; - -export const getRevenuecatWebhookSecret = ({ - org, - env, -}: { - org: Organization; - env: AppEnv; -}) => { - return env === AppEnv.Sandbox - ? org.processor_configs?.revenuecat?.sandbox_webhook_secret - : org.processor_configs?.revenuecat?.webhook_secret; -}; diff --git a/server/src/external/revenueCat/misc/initRevenuecatCli.ts b/server/src/external/revenueCat/misc/initRevenuecatCli.ts deleted file mode 100644 index 0e1d3cc89..000000000 --- a/server/src/external/revenueCat/misc/initRevenuecatCli.ts +++ /dev/null @@ -1,54 +0,0 @@ -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/resolveRevenuecatResources.ts b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts deleted file mode 100644 index 37c103717..000000000 --- a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - ErrCode, - type FullCusProduct, - type FullCustomer, - type FullProduct, - ProcessorType, - RecaseError, -} from "@shared/index"; -import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { CusService } from "@/internal/customers/CusService"; -import { ProductService } from "@/internal/products/ProductService"; -import { getOrCreateCustomer } from "../../../internal/customers/cusUtils/getOrCreateCustomer"; - -/** - * Resolves a RevenueCat product ID to an Autumn product and fetches the customer. - * Throws if product mapping, product, customer is not found, or customer has non-RevenueCat products. - */ -export const resolveRevenuecatResources = async ({ - ctx, - revenuecatProductId, - customerId, - autoCreateCustomer = false, -}: { - ctx: AutumnContext; - revenuecatProductId: string; - customerId: string; - autoCreateCustomer?: boolean; -}): Promise<{ - product: FullProduct; - customer: FullCustomer; - cusProducts: FullCusProduct[]; -}> => { - const { db, org, env } = ctx; - - // Look up Autumn product ID from RevenueCat mapping - const autumnProductId = await RCMappingService.getAutumnProductId({ - db, - orgId: org.id, - env, - revenuecatProductId, - }); - - if (!autumnProductId) { - throw new RecaseError({ - message: `No Autumn product mapped to RevenueCat product: ${revenuecatProductId}`, - code: ErrCode.ProductNotFound, - statusCode: 404, - }); - } - - const [product, customer] = await Promise.all([ - ProductService.getFull({ - db, - orgId: org.id, - env, - idOrInternalId: autumnProductId, - }), - autoCreateCustomer - ? getOrCreateCustomer({ - ctx, - customerId, - }) - : CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - }), - ]); - - if ( - customer.customer_products.some( - (cp) => cp.processor?.type !== ProcessorType.RevenueCat, - ) - ) { - throw new RecaseError({ - message: - "Customer already has a product from a different processor than RevenueCat.", - }); - } - - const cusProducts = customer.customer_products.filter( - (cp) => cp.processor?.type === ProcessorType.RevenueCat, - ); - - return { product, customer, cusProducts }; -}; diff --git a/server/src/external/revenueCat/misc/revenueCatMiddleware.ts b/server/src/external/revenueCat/misc/revenueCatMiddleware.ts deleted file mode 100644 index 6f7192870..000000000 --- a/server/src/external/revenueCat/misc/revenueCatMiddleware.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 { OrgService } from "@/internal/orgs/OrgService"; - -export const revenuecatSeederMiddleware = async ( - c: Context, - next: Next, -) => { - const { orgId, env } = c.req.param(); - const ctx = c.get("ctx"); - - 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 = features; - } - - await next(); -}; - -export const logRevCatWebhook = ({ - logger, - org, - event, -}: { - logger: Logger; - org: Organization; - event: { type: string; id: string }; -}) => { - logger.info( - `${chalk.magentaBright("REVCAT").padEnd(18)} ${event.type.padEnd(30)} ${org.slug} | ${event.id ?? "no_event_id"}`, - ); -}; - -export const revenuecatLogMiddleware = async ( - c: Context, - next: Next, -) => { - const { logger, org } = c.get("ctx"); - const body = await c.req.json(); - - logRevCatWebhook({ logger, org, event: body.event }); - - await next(); -}; diff --git a/server/src/external/revenueCat/revenuecatTypes.ts b/server/src/external/revenueCat/revenuecatTypes.ts deleted file mode 100644 index 49dc0095d..000000000 --- a/server/src/external/revenueCat/revenuecatTypes.ts +++ /dev/null @@ -1,156 +0,0 @@ -// RevenueCat Webhook Event Types - -export type RevCatExperiment = { - experiment_id: string; - experiment_variant: string; - enrolled_at_ms: number; -}; - -export type RevCatSubscriberAttribute = { - updated_at_ms: number; - value: string; -}; - -export type RevCatEvent = { - event_timestamp_ms: number; - product_id: string; - period_type: "NORMAL" | "INTRO" | "TRIAL"; - purchased_at_ms: number; - expiration_at_ms: number; - environment: "PRODUCTION" | "SANDBOX"; - entitlement_id: string | null; - entitlement_ids: string[]; - presented_offering_id: string | null; - transaction_id: string; - original_transaction_id: string; - is_family_share: boolean; - country_code: string; - app_user_id: string; - aliases: string[]; - original_app_user_id: string; - currency: string; - price: number; - price_in_purchased_currency: number; - subscriber_attributes: { - [key: string]: RevCatSubscriberAttribute; - }; - store: - | "APP_STORE" - | "PLAY_STORE" - | "STRIPE" - | "MAC_APP_STORE" - | "AMAZON" - | "PROMOTIONAL" - | "UNKNOWN_STORE"; - takehome_percentage: number; - tax_percentage: number; - commission_percentage: number; - offer_code: string | null; - type: - | "INITIAL_PURCHASE" - | "RENEWAL" - | "NON_RENEWING_PURCHASE" - | "PRODUCT_CHANGE" - | "CANCELLATION" - | "UNCANCELLATION" - | "BILLING_ISSUE" - | "SUBSCRIPTION_PAUSED" - | "SUBSCRIPTION_PAUSED_DENIED" - | "SUBSCRIPTION_REACTIVATED" - | "REFUND" - | "RENEWAL_EXTENDED" - | "EXPIRATION" - | "RENEWAL_OVERRIDE" - | "REVENUE_RECOGNITION" - | "TRANSFER" - | "UNKNOWN"; - id: string; - app_id: string; - experiments: RevCatExperiment[]; -}; - -export type RevCatWebhookPayload = { - event: RevCatEvent; - api_version: string; -}; - -export type RevenueCatOfferings = { - object: "list"; - items: RevenueCatOffering[]; - next_page: string | null; - url: string; -}; - -export type RevenueCatOffering = { - object: "offering"; - id: string; - lookup_key: string | null; - display_name: string; - is_current: boolean; - created_at: number; - project_id: string; - metadata: { - [key: string]: string; - }; - packages: RevenueCatOfferingPackageList; -}; - -export type RevenueCatOfferingPackageList = { - object: "list"; - items: RevenueCatOfferingPackage[]; - next_page: string | null; - url: string; -}; - -export type RevenueCatOfferingPackage = { - object: "package"; - id: string; - lookup_key: string | null; - display_name: string; - position: number; - created_at: number; - products: RevenueCatOfferingProductList; -}; - -export type RevenueCatOfferingProductList = { - object: "list"; - items: RevenueCatOfferingProductItem[]; - next_page: string | null; - url: string; -}; - -export type RevenueCatOfferingProductItem = { - product: Record; - eligibility_criteria: string; -}; - -// RevenueCat Products API Types - -export type RevenueCatProductSubscription = { - duration: string; - grace_period_duration?: string; - trial_duration?: string; -}; - -export type RevenueCatProductOneTime = { - is_consumable: boolean; -}; - -export type RevenueCatProduct = { - object: "product"; - id: string; - store_identifier: string; - type: "subscription" | "one_time"; - subscription?: RevenueCatProductSubscription; - one_time?: RevenueCatProductOneTime; - created_at: number; - app_id: string; - display_name: string; -}; - -export type RevenueCatProductsResponse = { - object: "list"; - items: RevenueCatProduct[]; - next_page: string | null; - url: string; -}; diff --git a/server/src/external/revenueCat/revenuecatWebhookRouter.ts b/server/src/external/revenueCat/revenuecatWebhookRouter.ts deleted file mode 100644 index 03430ed78..000000000 --- a/server/src/external/revenueCat/revenuecatWebhookRouter.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { - Webhook, - WebhookBillingIssue, - WebhookCancellation, - WebhookExpiration, - WebhookInitialPurchase, - WebhookNonRenewingPurchase, - WebhookRenewal, - WebhookUnCancellation, -} from "@puzzmo/revenue-cat-webhook-types"; -import { type Context, Hono } from "hono"; -import type { HonoEnv } from "@/honoUtils/HonoEnv"; -import { getRevenuecatWebhookSecret } from "./misc/getRevenuecatWebhookSecret"; -import { - revenuecatLogMiddleware, - revenuecatSeederMiddleware, -} from "./misc/revenueCatMiddleware"; -import { handleRenewal } from "./webhookHandlers/handleRevenucatRenewal"; -import { handleBillingIssue } from "./webhookHandlers/handleRevenuecatBillingIssue"; -import { handleCancellation } from "./webhookHandlers/handleRevenuecatCancellation"; -import { handleExpiration } from "./webhookHandlers/handleRevenuecatExpiration"; -import { handleInitialPurchase } from "./webhookHandlers/handleRevenuecatInitialPurchase"; -import { handleNonRenewingPurchase } from "./webhookHandlers/handleRevenuecatNonRenewingPurchase"; -import { handleUncancellation } from "./webhookHandlers/handleRevenuecatUncancellation"; - -export const revenuecatWebhookRouter = new Hono(); - -revenuecatWebhookRouter.post( - "/:orgId/:env", - revenuecatSeederMiddleware, - revenuecatLogMiddleware, - async (c: Context) => { - const ctx = c.get("ctx"); - const { logger, org, env } = ctx; - const Authorization = c.req.header("Authorization"); - const body = (await c.req.json()) as Webhook; - - try { - const webhookSecret = getRevenuecatWebhookSecret({ org, env }); - - if (Authorization !== webhookSecret) { - logger.error("Invalid authorization for RevenueCat webhook", { - Authorization, - webhookSecret, - }); - return c.json({ error: "Unauthorized" }, 401); - } - - switch (body.event.type) { - case "INITIAL_PURCHASE": - await handleInitialPurchase({ - event: body.event as WebhookInitialPurchase, - ctx, - }); - break; - case "NON_RENEWING_PURCHASE": - await handleNonRenewingPurchase({ - event: body.event as WebhookNonRenewingPurchase, - ctx, - }); - break; - case "RENEWAL": - await handleRenewal({ - event: body.event as WebhookRenewal, - ctx, - }); - break; - case "CANCELLATION": - await handleCancellation({ - event: body.event as WebhookCancellation, - ctx, - }); - break; - case "EXPIRATION": - await handleExpiration({ - event: body.event as WebhookExpiration, - ctx, - }); - break; - case "UNCANCELLATION": - await handleUncancellation({ - event: body.event as WebhookUnCancellation, - ctx, - }); - break; - case "BILLING_ISSUE": - await handleBillingIssue({ - event: body.event as WebhookBillingIssue, - ctx, - }); - break; - } - - return c.json({ success: true }, 200); - } catch (error) { - logger.error(`error handling revenuecat webhook ${error}`); - return c.json({ error: "Internal server error" }, 200); // don't retry webhooks. - } - }, -); diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts deleted file mode 100644 index 2ebc713f2..000000000 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { WebhookRenewal } from "@puzzmo/revenue-cat-webhook-types"; -import { - ACTIVE_STATUSES, - AttachScenario, - CusProductStatus, - cusProductToPrices, - ProcessorType, -} from "@shared/index"; -import { createStripeCli } from "@/external/connect/createStripeCli"; -import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; -import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; -import { - attachToInsertParams, - isProductUpgrade, -} from "@/internal/products/productUtils"; -import { isMainProduct } from "@/internal/products/productUtils/classifyProduct"; - -export const handleRenewal = async ({ - event, - ctx, -}: { - event: WebhookRenewal; - ctx: AutumnContext; -}) => { - const { db, org, env, logger, features } = ctx; - const { product_id, app_user_id } = event; - - const { product, customer, cusProducts } = await resolveRevenuecatResources({ - ctx, - revenuecatProductId: product_id, - customerId: app_user_id, - }); - - const { curSameProduct, curMainProduct } = getExistingCusProducts({ - product, - cusProducts, - }); - - const now = Date.now(); - - // If same product exists and is active, this is just a renewal - nothing to do - if (curSameProduct && ACTIVE_STATUSES.includes(curSameProduct.status)) { - logger.info( - `Renewal for existing active product ${product.id}, no action needed`, - ); - return { success: true }; - } else if (curSameProduct && curSameProduct.status === CusProductStatus.PastDue) { - logger.info( - `Renewal for existing past due product ${product.id}, marking as active`, - ); - await CusProductService.update({ - db, - cusProductId: curSameProduct.id, - updates: { - status: CusProductStatus.Active, - }, - }); - logger.info(`Marked past due product as active: ${curSameProduct.id}`); - await deleteCachedApiCustomer({ - customerId: customer.id ?? "", - orgId: org.id, - env, - }); - return { success: true }; - } - - // Check if this is an upgrade (renewing to a different/better product) - const isNewProductMain = isMainProduct({ product, prices: product.prices }); - let scenario = AttachScenario.New; - - if (curMainProduct && isNewProductMain) { - const curPrices = cusProductToPrices({ cusProduct: curMainProduct }); - const newPrices = product.prices; - - const isUpgrade = isProductUpgrade({ - prices1: curPrices, - prices2: newPrices, - }); - - scenario = isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade; - - logger.info( - `Renewal with ${isUpgrade ? "upgrade" : "downgrade"}: ${curMainProduct.product.id} -> ${product.id}`, - ); - - // Expire old cus_product - await CusProductService.update({ - db, - cusProductId: curMainProduct.id, - updates: { - status: CusProductStatus.Expired, - ended_at: now, - }, - }); - - logger.info(`Expired old cus_product: ${curMainProduct.id}`); - } else if (curSameProduct) { - // Reactivate the same product if it was expired/cancelled - await CusProductService.update({ - db, - cusProductId: curSameProduct.id, - updates: { - status: CusProductStatus.Active, - canceled_at: null, - ended_at: null, - canceled: false, - }, - }); - - logger.info(`Reactivated cus_product: ${curSameProduct.id}`); - - await deleteCachedApiCustomer({ - customerId: customer.id ?? "", - orgId: org.id, - env, - }); - return { success: true }; - } - - // Create new cus_product for upgrade or new product - await createFullCusProduct({ - db, - logger, - scenario, - processorType: ProcessorType.RevenueCat, - attachParams: attachToInsertParams( - { - customer, - products: [product], - prices: product.prices, - entitlements: product.entitlements, - entities: customer.entities || [], - org, - stripeCli: createStripeCli({ org, env }), - now, - paymentMethod: null, - freeTrial: null, - optionsList: [], - cusProducts, - replaceables: [], - features, - }, - product, - ), - }); - - logger.info( - `Created cus_product for ${product.id} with scenario: ${scenario} (renewal)`, - ); - - await deleteCachedApiCustomer({ - customerId: customer.id ?? "", - orgId: org.id, - env, - }); - - return { success: true }; -}; diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatBillingIssue.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatBillingIssue.ts deleted file mode 100644 index a97bc90e3..000000000 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatBillingIssue.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { WebhookBillingIssue } from "@puzzmo/revenue-cat-webhook-types"; -import { RecaseError } from "@shared/api/errors/base/RecaseError"; -import { ErrCode } from "@shared/enums/ErrCode"; -import { CusProductStatus } from "@shared/models/cusProductModels/cusProductEnums"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { - ACTIVE_STATUSES, - CusProductService, -} from "@/internal/customers/cusProducts/CusProductService"; -import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; -import { resolveRevenuecatResources } from "../misc/resolveRevenuecatResources"; - -export const handleBillingIssue = async ({ - event, - ctx, -}: { - event: WebhookBillingIssue; - ctx: AutumnContext; -}) => { - const { db, logger, org, env } = ctx; - const { product_id, app_user_id } = event; - - const { product, customer, cusProducts } = await resolveRevenuecatResources({ - ctx, - revenuecatProductId: product_id, - customerId: app_user_id, - }); - - const { curSameProduct } = getExistingCusProducts({ - product, - cusProducts, - }); - - if (!curSameProduct) { - throw new RecaseError({ - message: "Cus product not found", - code: ErrCode.CusProductNotFound, - statusCode: 404, - }); - } - - if (curSameProduct.status === CusProductStatus.PastDue) { - logger.info( - `Billing issue for existing past due product ${product.id}, no action needed`, - ); - return { success: true }; - } - - if (ACTIVE_STATUSES.includes(curSameProduct.status)) { - await CusProductService.update({ - db, - cusProductId: curSameProduct.id, - updates: { - status: CusProductStatus.PastDue, - }, - }); - - await deleteCachedApiCustomer({ - customerId: customer.id ?? "", - orgId: org.id, - env, - }); - - return { success: true }; - } - - throw new RecaseError({ - message: "Cus product is not in a valid status to be billed", - code: ErrCode.NoActiveCusProducts, - statusCode: 400, - }); -}; diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatCancellation.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatCancellation.ts deleted file mode 100644 index 5479614a5..000000000 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatCancellation.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { WebhookCancellation } from "@puzzmo/revenue-cat-webhook-types"; -import { ErrCode, RecaseError } from "@shared/index"; -import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; -import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; - -export const handleCancellation = async ({ - event, - ctx, -}: { - event: WebhookCancellation; - ctx: AutumnContext; -}) => { - const { db, org, env, logger } = ctx; - const { product_id, original_app_user_id, app_user_id, expiration_at_ms } = - event; - - const { product, cusProducts } = await resolveRevenuecatResources({ - ctx, - revenuecatProductId: product_id, - customerId: original_app_user_id ?? app_user_id, - }); - - const { curSameProduct } = getExistingCusProducts({ - product, - cusProducts, - }); - - if (!curSameProduct) { - throw new RecaseError({ - message: "Cus product not found", - code: ErrCode.CusProductNotFound, - statusCode: 404, - }); - } - - await CusProductService.update({ - db, - cusProductId: curSameProduct.id, - updates: { - canceled_at: Date.now(), - canceled: true, - ended_at: expiration_at_ms, - }, - }); - - logger.info( - `Marked cus_product ${curSameProduct.id} as cancelled, will expire at ${expiration_at_ms}`, - ); - - await deleteCachedApiCustomer({ - customerId: original_app_user_id ?? app_user_id, - orgId: org.id, - env, - }); -}; diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatExpiration.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatExpiration.ts deleted file mode 100644 index a8a37dd4e..000000000 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatExpiration.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { WebhookExpiration } from "@puzzmo/revenue-cat-webhook-types"; -import { CusProductStatus, ErrCode, RecaseError } from "@shared/index"; -import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; -import { activateDefaultProduct } from "@/internal/customers/cusProducts/cusProductUtils"; -import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; -import { isOneOff } from "@/internal/products/productUtils"; - -export const handleExpiration = async ({ - event, - ctx, -}: { - event: WebhookExpiration; - ctx: AutumnContext; -}) => { - const { db, org, env, logger } = ctx; - const { product_id, original_app_user_id, app_user_id } = event; - - const { product, customer, cusProducts } = await resolveRevenuecatResources({ - ctx, - revenuecatProductId: product_id, - customerId: original_app_user_id ?? app_user_id, - }); - - const { curSameProduct } = getExistingCusProducts({ - product, - cusProducts, - }); - - if (!curSameProduct) { - throw new RecaseError({ - message: "Cus product not found", - code: ErrCode.CusProductNotFound, - statusCode: 404, - }); - } - - // Expire the cus_product - await CusProductService.update({ - db, - cusProductId: curSameProduct.id, - updates: { - status: CusProductStatus.Expired, - ended_at: event.expiration_at_ms, - canceled: !!curSameProduct.canceled_at, - }, - }); - - logger.info(`Expired cus_product: ${curSameProduct.id}`); - - // Activate default product if this was a main product - const isMain = !product.is_add_on; - const isOneOffProduct = isOneOff(product.prices); - - if (isMain && !isOneOffProduct) { - await activateDefaultProduct({ - ctx, - productGroup: product.group, - fullCus: customer, - curCusProduct: curSameProduct, - }); - } - - await deleteCachedApiCustomer({ - customerId: event.original_app_user_id ?? event.app_user_id, - orgId: org.id, - env, - }); -}; diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts deleted file mode 100644 index 7f92ffc93..000000000 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { WebhookInitialPurchase } from "@puzzmo/revenue-cat-webhook-types"; -import { - AttachScenario, - CusProductStatus, - cusProductToPrices, - ErrCode, - ProcessorType, - RecaseError, -} from "@shared/index"; -import { createStripeCli } from "@/external/connect/createStripeCli"; -import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; -import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; -import { - attachToInsertParams, - isProductUpgrade, -} from "@/internal/products/productUtils"; -import { isMainProduct } from "@/internal/products/productUtils/classifyProduct"; - -export const handleInitialPurchase = async ({ - event, - ctx, -}: { - event: WebhookInitialPurchase; - ctx: AutumnContext; -}) => { - const { db, org, env, logger, features } = ctx; - const { product_id, app_user_id } = event; - - const { product, customer, cusProducts } = await resolveRevenuecatResources({ - ctx, - revenuecatProductId: product_id, - customerId: app_user_id, - autoCreateCustomer: true, - }); - - const { curSameProduct, curMainProduct } = getExistingCusProducts({ - product, - cusProducts, - }); - - // If same product already exists, skip - if (curSameProduct) { - throw new RecaseError({ - message: `[handleInitialPurchase] Customer ${customer.id} already has product ${product.id}`, - code: ErrCode.CustomerAlreadyHasProduct, - statusCode: 400, - }); - } - - const now = Date.now(); - let scenario = AttachScenario.New; - - // Handle upgrade/downgrade (only when both are main products) - const isNewProductMain = isMainProduct({ product, prices: product.prices }); - - if (curMainProduct && isNewProductMain) { - const curPrices = cusProductToPrices({ cusProduct: curMainProduct }); - const newPrices = product.prices; - - const isUpgrade = isProductUpgrade({ - prices1: curPrices, - prices2: newPrices, - }); - - scenario = isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade; - - logger.info( - `${isUpgrade ? "Upgrade" : "Downgrade"} detected: ${curMainProduct.product.id} -> ${product.id}`, - ); - - // Expire old cus_product - await CusProductService.update({ - db, - cusProductId: curMainProduct.id, - updates: { - status: CusProductStatus.Expired, - ended_at: now, - }, - }); - - logger.info(`Expired old cus_product: ${curMainProduct.id}`); - } - - // Create new cus_product - await createFullCusProduct({ - db, - logger, - scenario, - processorType: ProcessorType.RevenueCat, - attachParams: attachToInsertParams( - { - customer, - products: [product], - prices: product.prices, - entitlements: product.entitlements, - entities: customer.entities || [], - org, - stripeCli: createStripeCli({ org, env }), - now, - paymentMethod: null, - freeTrial: null, - optionsList: [], - cusProducts, - replaceables: [], - features, - }, - product, - ), - }); - - logger.info( - `Created cus_product for ${product.id} with scenario: ${scenario}`, - ); - - await deleteCachedApiCustomer({ - customerId: customer.id ?? "", - orgId: org.id, - env, - }); -}; diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts deleted file mode 100644 index 8b58e899e..000000000 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { WebhookNonRenewingPurchase } from "@puzzmo/revenue-cat-webhook-types"; -import { - AttachScenario, - ErrCode, - ProcessorType, - RecaseError, -} from "@shared/index"; -import { createStripeCli } from "@/external/connect/createStripeCli"; -import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; -import { attachToInsertParams } from "@/internal/products/productUtils"; -import { oneOffOrAddOn } from "@/internal/products/productUtils/classifyProduct"; - -export const handleNonRenewingPurchase = async ({ - event, - ctx, -}: { - event: WebhookNonRenewingPurchase; - ctx: AutumnContext; -}) => { - const { db, org, env, logger, features } = ctx; - - const { product, customer, cusProducts } = await resolveRevenuecatResources({ - ctx, - revenuecatProductId: event.product_id, - customerId: event.app_user_id, - }); - - if (!oneOffOrAddOn({ product, prices: product.prices })) { - throw new RecaseError({ - message: "Non-renewing purchase is not a one-off or add-on", - code: ErrCode.InvalidProductItem, - statusCode: 400, - }); - } - - const now = Date.now(); - const scenario = AttachScenario.New; - - // Create new cus_product - await createFullCusProduct({ - db, - logger, - scenario, - processorType: ProcessorType.RevenueCat, - attachParams: attachToInsertParams( - { - customer, - products: [product], - prices: product.prices, - entitlements: product.entitlements, - entities: customer.entities || [], - org, - stripeCli: createStripeCli({ org, env }), - now, - paymentMethod: null, - freeTrial: null, - optionsList: [], - cusProducts, - replaceables: [], - features, - }, - product, - ), - }); - - logger.info( - `Created cus_product for ${product.id} with scenario: ${scenario}`, - ); - - await deleteCachedApiCustomer({ - customerId: customer.id ?? "", - orgId: org.id, - env, - }); -}; diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatUncancellation.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatUncancellation.ts deleted file mode 100644 index e60fd0842..000000000 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatUncancellation.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { WebhookUnCancellation } from "@puzzmo/revenue-cat-webhook-types"; -import { - CusProductStatus, - ErrCode, - ProcessorType, - RecaseError, -} from "@shared/index"; -import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; - -export const handleUncancellation = async ({ - event, - ctx, -}: { - event: WebhookUnCancellation; - ctx: AutumnContext; -}) => { - const { db, org, env } = ctx; - const { product_id, original_app_user_id, app_user_id } = event; - - const { product, cusProducts } = await resolveRevenuecatResources({ - ctx, - revenuecatProductId: product_id, - customerId: original_app_user_id ?? app_user_id, - }); - - const cusProduct = cusProducts.find( - (cp) => - cp.internal_product_id === product.internal_id && - cp.processor?.type === ProcessorType.RevenueCat, - ); - - if (cusProduct) { - await CusProductService.update({ - db, - cusProductId: cusProduct.id, - updates: { - canceled_at: null, - canceled: false, - ended_at: null, - status: CusProductStatus.Active, - }, - }); - - await deleteCachedApiCustomer({ - customerId: original_app_user_id ?? app_user_id, - orgId: org.id, - env, - }); - } else { - throw new RecaseError({ - message: "Cus product not found", - code: ErrCode.CusProductNotFound, - statusCode: 404, - }); - } -}; diff --git a/server/src/initHono.ts b/server/src/initHono.ts index c17440dd2..1a5005e0c 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -1,7 +1,6 @@ import { getRequestListener } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; -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"; @@ -94,7 +93,6 @@ export const createHonoApp = () => { // Webhook routes app.post("/webhooks/connect/:env", handleConnectWebhook); app.route("/webhooks/vercel", vercelWebhookRouter); - 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 4633ee6f7..25391c474 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -1,5 +1,4 @@ import { - ACTIVE_STATUSES, type ApiVersion, CollectionMethod, type CusProduct, @@ -79,7 +78,6 @@ export const initCusProduct = ({ internalEntityId, apiVersion, quantity, - processor = ProcessorType.Stripe, }: { customer: Customer; product: FullProduct; @@ -102,7 +100,6 @@ export const initCusProduct = ({ internalEntityId?: string; apiVersion?: ApiVersion; quantity?: number; - processor?: ProcessorType; }) => { const isFuture = startsAt && startsAt > Date.now(); @@ -127,7 +124,7 @@ export const initCusProduct = ({ : CusProductStatus.Active, processor: { - type: processor ?? ProcessorType.Stripe, + type: ProcessorType.Stripe, // subscription_id: subscriptionId, // subscription_schedule_id: subscriptionScheduleId, // last_invoice_id: lastInvoiceId, @@ -240,6 +237,7 @@ export const getExistingCusProduct = async ({ internalEntityId, }: { db: DrizzleCli; + cusProducts?: FullCusProduct[]; product: FullProduct; internalCustomerId: string; @@ -286,7 +284,6 @@ export const createFullCusProduct = async ({ scenario = "default", sendWebhook = true, logger, - processorType = ProcessorType.Stripe, }: { db: DrizzleCli; attachParams: InsertCusProductParams; @@ -309,7 +306,6 @@ export const createFullCusProduct = async ({ scenario?: string; sendWebhook?: boolean; logger: any; - processorType?: ProcessorType; }) => { disableFreeTrial = attachParams.disableFreeTrial || disableFreeTrial; @@ -323,7 +319,6 @@ export const createFullCusProduct = async ({ product, internalCustomerId: customer.internal_id, internalEntityId: attachParams.internalEntityId, - // processorType, }); freeTrial = disableFreeTrial ? null : freeTrial; @@ -347,8 +342,7 @@ export const createFullCusProduct = async ({ notNullish(existingCusProduct) && !attachParams.isCustom && !existingCusProduct.is_custom && - product.version === existingCusProduct.product.version && - ACTIVE_STATUSES.includes(existingCusProduct.status) + product.version === existingCusProduct.product.version ) { await updateOneTimeCusProduct({ db, @@ -441,7 +435,6 @@ export const createFullCusProduct = async ({ const cusProd = initCusProduct({ cusProdId, customer, - processor: processorType ?? ProcessorType.Stripe, product, startsAt, optionsList, diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts index cb29aa61a..bd35d6565 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -3,9 +3,7 @@ import { AttachBranch, type AttachConfig, BillingType, - cusProductToProcessorType, ErrCode, - ProcessorType, RecaseError, type UsagePriceConfig, } from "@autumn/shared"; @@ -160,23 +158,6 @@ export const handleCustomPaymentMethodErrors = ({ } }; -export const handleExternalPSPErrors = ({ - attachParams, -}: { - attachParams: AttachParams; -}) => { - if ( - attachParams.customer.customer_products.some( - (cp) => cusProductToProcessorType(cp) !== ProcessorType.Stripe, - ) - ) { - throw new RecaseError({ - message: - "This customer is billed outside of Stripe, please use the origin platform to manage their billing.", - }); - } -}; - export const handleAttachErrors = async ({ attachParams, attachBody, @@ -196,10 +177,6 @@ export const handleAttachErrors = async ({ attachParams, }); - handleExternalPSPErrors({ - attachParams, - }); - if (branch === AttachBranch.MultiAttach) { await handleMultiAttachErrors({ attachParams, diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts index ae9b8bed2..03ad189f1 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts @@ -1,9 +1,6 @@ import type { AttachBranch } from "@autumn/shared"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { - handleCustomPaymentMethodErrors, - handleExternalPSPErrors, -} from "../handleAttachErrors.js"; +import { handleCustomPaymentMethodErrors } from "../handleAttachErrors.js"; export const handleCheckoutErrors = ({ attachParams, @@ -16,10 +13,6 @@ export const handleCheckoutErrors = ({ attachParams, }); - handleExternalPSPErrors({ - attachParams, - }); - // if (attachParams.setupPayment) { // // Make sure only usage prices are added? // } diff --git a/server/src/internal/customers/cancel/cancelRouter.ts b/server/src/internal/customers/cancel/cancelRouter.ts index e951995cc..1ab2bf49f 100644 --- a/server/src/internal/customers/cancel/cancelRouter.ts +++ b/server/src/internal/customers/cancel/cancelRouter.ts @@ -1,13 +1,11 @@ import { CusProductNotFoundError, - cusProductToProcessorType, ErrCode, type FullCusProduct, - ProcessorType, - RecaseError, } from "@autumn/shared"; import { Router } from "express"; import { CusService } from "@/internal/customers/CusService.js"; +import RecaseError from "@/utils/errorUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; @@ -65,7 +63,6 @@ cancelRouter.post("", async (req, res) => const cusProductIdMatch = customer_product_id ? cusProduct.id === customer_product_id : true; - return productIdMatch && entityMatch && cusProductIdMatch; }); @@ -77,12 +74,6 @@ cancelRouter.post("", async (req, res) => }); } - if (cusProductToProcessorType(cusProduct) === ProcessorType.RevenueCat) { - throw new RecaseError({ - message: `Cannot cancel '${cusProduct.product.name}' because it is managed by RevenueCat.`, - }); - } - await handleCancelProduct({ ctx: req as unknown as AutumnContext, cusProduct, diff --git a/server/src/internal/customers/cusProducts/CusProductService.ts b/server/src/internal/customers/cusProducts/CusProductService.ts index 1387f4cb6..72048e1bc 100644 --- a/server/src/internal/customers/cusProducts/CusProductService.ts +++ b/server/src/internal/customers/cusProducts/CusProductService.ts @@ -184,7 +184,7 @@ export class CusProductService { internalCustomerId: string; withCustomer?: boolean; inStatuses?: string[]; - }): Promise { + }) { const cusProducts = await db.query.customerProducts.findMany({ where: and( eq(customerProducts.internal_customer_id, internalCustomerId), diff --git a/server/src/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.ts b/server/src/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.ts index e962cb7a5..d6308b1b4 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.ts @@ -11,12 +11,10 @@ export const getExistingCusProducts = ({ product, cusProducts, internalEntityId, - // processorType = ProcessorType.Stripe, }: { product: Product; cusProducts: FullCusProduct[]; internalEntityId?: string | null; - // processorType?: ProcessorType; }) => { if (!cusProducts || cusProducts.length === 0 || !product) { return { @@ -26,10 +24,7 @@ export const getExistingCusProducts = ({ }; } - const curMainProduct = cusProducts.find((cp: FullCusProduct) => { - // const sameProcessor = cp.processor?.type - // ? cp.processor.type === processorType - // : true; + const curMainProduct = cusProducts.find((cp: any) => { const sameGroup = cp.product.group === product.group; const isMain = !cp.product.is_add_on; const isActive = diff --git a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts index 7a8a77c3c..0994193dd 100644 --- a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts +++ b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts @@ -1,19 +1,21 @@ -import { - type AppEnv, - type Feature, - type FullCusProduct, - type FullProduct, - type MigrationJob, - type Organization, - ProcessorType, +import type { + AppEnv, + Feature, + FullCusProduct, + FullProduct, + MigrationJob, + Organization, } from "@autumn/shared"; + import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { CusService } from "@/internal/customers/CusService.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; import type { Logger } from "../../../external/logtail/logtailUtils.js"; -import { migrateRevenueCatCustomer } from "./migrateRevenuecatCustomer.js"; -import { migrateStripeCustomer } from "./migrateStripeCustomer.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 migrateCustomer = async ({ db, @@ -66,29 +68,25 @@ export const migrateCustomer = async ({ ); for (const cusProduct of filteredCusProducts) { - if (cusProduct.processor?.type === ProcessorType.RevenueCat) { - await migrateRevenueCatCustomer({ - req, - fullCus, - cusProduct, - toProduct, - customerId, - orgId, - env, - }); - } else { - await migrateStripeCustomer({ - req, - stripeCli, - fullCus, - cusProduct, - toProduct, - fromProduct, - customerId, - orgId, - env, - }); - } + 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, + }); } return true; diff --git a/server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts b/server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts deleted file mode 100644 index d4b6e4e11..000000000 --- a/server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts +++ /dev/null @@ -1,117 +0,0 @@ -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; - fullCus.customer_products = fullCus.customer_products.filter( - (cp) => cp.processor?.type === ProcessorType.RevenueCat, - ); - - // 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 deleted file mode 100644 index cf1b674f5..000000000 --- a/server/src/internal/migrations/migrationSteps/migrateStripeCustomer.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index c11a02bb1..000000000 --- a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { - AppEnv, - InternalError, - type Organization, - type RevenueCatProcessorConfig, - UpsertRevenueCatProcessorConfigSchema, -} from "@autumn/shared"; -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"; - -// Generate a random 64-character alphanumeric string -const generateWebhookSecret = (): string => { - const chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; - const randomBytes = crypto.getRandomValues(new Uint8Array(64)); - for (let i = 0; i < 64; i++) { - result += chars[randomBytes[i] % chars.length]; - } - return result; -}; - -export const getRevenueCatConfigDisplay = ({ - org, - env, -}: { - org: Organization; - env: AppEnv; -}) => { - const revenueCatConfig = org.processor_configs?.revenuecat; - if (!revenueCatConfig) { - return { - connected: false, - api_key: undefined, - sandbox_api_key: undefined, - project_id: undefined, - sandbox_project_id: undefined, - webhook_secret: undefined, - sandbox_webhook_secret: undefined, - }; - } - - const liveApiKeyDecrypted = revenueCatConfig.api_key - ? decryptData(revenueCatConfig.api_key) - : undefined; - const sandboxApiKeyDecrypted = revenueCatConfig.sandbox_api_key - ? decryptData(revenueCatConfig.sandbox_api_key) - : undefined; - - const webhookSecret = - env === AppEnv.Live - ? revenueCatConfig.webhook_secret - : revenueCatConfig.sandbox_webhook_secret; - - const apiKeyForEnv = - env === AppEnv.Live ? liveApiKeyDecrypted : sandboxApiKeyDecrypted; - - return { - connected: !!apiKeyForEnv && !!webhookSecret, - api_key: mask(liveApiKeyDecrypted, 3, 2), - sandbox_api_key: mask(sandboxApiKeyDecrypted, 5, 5), - project_id: revenueCatConfig.project_id, - sandbox_project_id: revenueCatConfig.sandbox_project_id, - webhook_secret: revenueCatConfig.webhook_secret, - sandbox_webhook_secret: revenueCatConfig.sandbox_webhook_secret, - }; -}; - -export const handleGetRevenueCatConfig = createRoute({ - handler: async (c) => { - const { db, org, env } = c.get("ctx"); - - const revenueCatConfig = org.processor_configs?.revenuecat; - - // Generate webhook secrets if they don't exist - const needsWebhookSecrets = - !revenueCatConfig?.webhook_secret || - !revenueCatConfig?.sandbox_webhook_secret; - - if (!revenueCatConfig || needsWebhookSecrets) { - const webhookSecret = - revenueCatConfig?.webhook_secret || generateWebhookSecret(); - const sandboxWebhookSecret = - revenueCatConfig?.sandbox_webhook_secret || generateWebhookSecret(); - - await OrgService.update({ - db, - orgId: org.id, - updates: { - processor_configs: { - ...org.processor_configs, - revenuecat: { - ...(revenueCatConfig || ({} as RevenueCatProcessorConfig)), - webhook_secret: webhookSecret, - sandbox_webhook_secret: sandboxWebhookSecret, - }, - }, - }, - }); - - // Return fresh config after update - return c.json({ - connected: false, - api_key: undefined, - sandbox_api_key: undefined, - project_id: undefined, - sandbox_project_id: undefined, - webhook_secret: webhookSecret, - sandbox_webhook_secret: sandboxWebhookSecret, - }); - } - - const config = getRevenueCatConfigDisplay({ org, env }); - - return c.json(config); - }, -}); - -export const handleUpsertRevenueCatConfig = createRoute({ - body: UpsertRevenueCatProcessorConfigSchema, - handler: async (c) => { - const { db, org } = c.get("ctx"); - - const body = c.req.valid("json"); - - // Merge with existing processor_configs to avoid unsetting fields - const existingRevenueCatConfig = - org.processor_configs?.revenuecat || ({} as RevenueCatProcessorConfig); - - await OrgService.update({ - db, - orgId: org.id, - updates: { - processor_configs: { - ...org.processor_configs, - revenuecat: { - ...existingRevenueCatConfig, - // Live fields - ...(body.api_key ? { api_key: encryptData(body.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 - ? { sandbox_project_id: body.sandbox_project_id } - : {}), - }, - }, - }, - }); - - return c.json({ - success: true, - }); - }, -}); - -export const handleGetVercelSink = createRoute({ - handler: async (c) => { - const { db, org, env } = c.get("ctx"); - const vercelConfig = org.processor_configs?.vercel; - const svixCli = createSvixCli(); - let liveApp: ApplicationOut | undefined; - let sandboxApp: ApplicationOut | undefined; - - if (!vercelConfig) { - throw new InternalError({ - message: `Vercel config not found for org ${org.id}`, - }); - } - - if (!vercelConfig?.svix?.live_id || !vercelConfig?.svix?.sandbox_id) { - liveApp = await createSvixApp({ - name: `${org.slug}_live_vercel_sink`, - orgId: org.id, - env: AppEnv.Live, - }); - } - - if (!vercelConfig?.svix?.sandbox_id) { - sandboxApp = await createSvixApp({ - name: `${org.slug}_sandbox_vercel_sink`, - orgId: org.id, - env: AppEnv.Sandbox, - }); - } - - const updates = { - ...(liveApp - ? { svix: { ...(vercelConfig?.svix || {}), live_id: liveApp.id } } - : {}), - ...(sandboxApp - ? { svix: { ...(vercelConfig?.svix || {}), sandbox_id: sandboxApp.id } } - : {}), - }; - - await OrgService.update({ - db, - orgId: org.id, - updates: { - processor_configs: { - ...org.processor_configs, - vercel: { ...(vercelConfig || {}), ...updates }, - }, - }, - }); - - let url: string | undefined; - - if (env === AppEnv.Live) { - url = ( - await svixCli.authentication.appPortalAccess( - liveApp?.id || vercelConfig?.svix?.live_id || "", - { - featureFlags: ["vercel"], - }, - ) - ).url; - } else { - url = ( - await svixCli.authentication.appPortalAccess( - sandboxApp?.id || vercelConfig?.svix?.sandbox_id || "", - { - featureFlags: ["vercel"], - }, - ) - ).url; - } - - return c.json({ url }); - }, -}); diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts index 6561fc4b8..f180b9a3e 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -1,16 +1,9 @@ import { Hono } from "hono"; -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"; import { handleGetUploadUrl } from "./handlers/handleGetUploadUrl.js"; import { handleResetDefaultAccount } from "./handlers/handleResetDefaultAccount.js"; -import { - handleGetRevenueCatConfig, - handleUpsertRevenueCatConfig, -} from "./handlers/handleRevenueCatConfig.js"; import { handleUpdateOrg } from "./handlers/handleUpdateOrg.js"; import { handleGetVercelSink, @@ -41,12 +34,5 @@ honoOrgRouter.delete("/stripe", ...handleDeleteStripe); honoOrgRouter.post("/stripe", ...handleConnectStripe); honoOrgRouter.get("/stripe/oauth_url", ...handleGetOAuthUrl); honoOrgRouter.post("/reset_default_account", ...handleResetDefaultAccount); - honoOrgRouter.patch("/vercel", ...handleUpsertVercelConfig); honoOrgRouter.get("/vercel_sink", ...handleGetVercelSink); - -honoOrgRouter.get("/revenuecat", ...handleGetRevenueCatConfig); -honoOrgRouter.patch("/revenuecat", ...handleUpsertRevenueCatConfig); -honoOrgRouter.post("/revenuecat/products", ...handleGetRevenueCatProducts); -honoOrgRouter.get("/revenuecat/mappings", ...handleGetRCMappings); -honoOrgRouter.post("/revenuecat/mappings", ...handleSaveRCMappings); diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index 49b51dc13..558be7482 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -22,7 +22,6 @@ import { notNullish } from "@server/utils/genUtils.js"; import { eq } from "drizzle-orm"; import Stripe from "stripe"; import { FeatureService } from "../features/FeatureService.js"; -import { getRevenueCatConfigDisplay } from "./handlers/handleRevenueCatConfig.js"; import { getVercelConfigDisplay } from "./handlers/handleVercelConfig.js"; import { OrgService } from "./OrgService.js"; import { clearOrgCache } from "./orgUtils/clearOrgCache.js"; @@ -201,7 +200,6 @@ export const createOrgResponse = ({ }); const vercelConnection = getVercelConfigDisplay({ org, env }); - const revenueCatConnection = getRevenueCatConfigDisplay({ org, env }); const stripeConnection = secretKeyConnected ? "secret_key" @@ -246,15 +244,6 @@ export const createOrgResponse = ({ custom_payment_method: vercelConnection.custom_payment_method, marketplace_mode: vercelConnection.marketplace_mode, }, - revenuecat: { - connected: revenueCatConnection.connected, - api_key: revenueCatConnection.api_key, - sandbox_api_key: revenueCatConnection.sandbox_api_key, - project_id: revenueCatConnection.project_id, - sandbox_project_id: revenueCatConnection.sandbox_project_id, - webhook_secret: revenueCatConnection.webhook_secret, - sandbox_webhook_secret: revenueCatConnection.sandbox_webhook_secret, - }, }, created_at: new Date(org.createdAt).getTime(), diff --git a/server/src/utils/genUtils.ts b/server/src/utils/genUtils.ts index a38f1ece4..25a7ce77d 100644 --- a/server/src/utils/genUtils.ts +++ b/server/src/utils/genUtils.ts @@ -54,14 +54,6 @@ export const nullish = ( return value === null || value === undefined; }; -export const mask = (v: string | undefined, p: number, s: number) => { - if (!v) return undefined; - const len = v.length; - if (len <= p + s) return v; - const maskLen = len - p - s; - return v.slice(0, p) + "*".repeat(maskLen) + v.slice(-s); -}; - export const notNullish = (value: T | null | undefined): value is T => { return !nullish(value); }; diff --git a/server/tests/external-psps/revenuecat/revenuecat-migration.test.ts b/server/tests/external-psps/revenuecat/revenuecat-migration.test.ts deleted file mode 100644 index cc321a365..000000000 --- a/server/tests/external-psps/revenuecat/revenuecat-migration.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - AppEnv, - CusProductStatus, - customers, - ProcessorType, -} from "@autumn/shared"; -import { replaceItems } from "@tests/attach/utils.js"; -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/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"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { expectFeaturesCorrect } from "../../utils/expectUtils/expectFeaturesCorrect.js"; -import { timeout } from "../../utils/genUtils.js"; -import { - expectWebhookSuccess, - RevenueCatWebhookClient, -} from "./utils/revenue-cat-webhook-client.js"; - -const testCase = "rcMigration1"; -const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_migration"; - -// RevenueCat product ID -const RC_PRO_MONTHLY_ID = "com.app.migration_pro_monthly"; - -// Autumn product definitions -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 1000, -}); - -const proMonthlyV1 = constructProduct({ - id: `${testCase}-pro-monthly`, - type: "pro", - items: [messagesFeature], - isDefault: false, -}); - -const proMonthlyV2 = { - ...proMonthlyV1, - version: 2, - items: replaceItems({ - items: proMonthlyV1.items, - featureId: TestFeature.Messages, - newItem: constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 2000, - }), - }), -}; - -describe( - chalk.yellowBright("rcMigration1: RevenueCat customer migration"), - () => { - const customerId = `${testCase}-customer`; - const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); - let internalCustomerId: string | null = null; - let rcClient: RevenueCatWebhookClient; - - beforeAll(async () => { - // 1. Configure org with RevenueCat processor config - if ( - ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== - RC_WEBHOOK_SECRET - ) { - await OrgService.update({ - db: ctx.db, - orgId: ctx.org.id, - updates: { - processor_configs: { - ...ctx.org.processor_configs, - revenuecat: { - 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, - sandbox_webhook_secret: RC_WEBHOOK_SECRET, - }, - }, - }, - }); - } - - // 2. Create product and mappings - await initProductsV0({ - ctx, - products: [proMonthlyV1], - prefix: testCase, - customerId, - }); - - await RCMappingService.upsert({ - db: ctx.db, - data: { - org_id: ctx.org.id, - env: AppEnv.Sandbox, - autumn_product_id: proMonthlyV1.id, - revenuecat_product_ids: [RC_PRO_MONTHLY_ID], - }, - }); - - // 3. Create 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; - - // Initialize RevenueCat webhook client - rcClient = new RevenueCatWebhookClient({ - orgId: ctx.org.id, - env: ctx.env, - webhookSecret: RC_WEBHOOK_SECRET, - }); - }); - - test("should create customer with pro monthly v1 product via initial purchase", async () => { - const { response, data } = await rcClient.initialPurchase({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "migration_tx_12345", - }); - - expectWebhookSuccess({ response, data }); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proMonthlyV1.id); - - // Verify cus_product has RevenueCat processor - const cusProducts = await CusProductService.list({ - db: ctx.db, - internalCustomerId: internalCustomerId!, - inStatuses: [CusProductStatus.Active], - }); - expect(cusProducts).toHaveLength(1); - expect(cusProducts[0].processor?.type).toBe(ProcessorType.RevenueCat); - }); - - test("should create v2 of the product with updated features", async () => { - // Create v2 with increased usage - const newItems = replaceItems({ - items: proMonthlyV1.items, - featureId: TestFeature.Messages, - newItem: constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 2000, // Increased from 1000 - }), - }); - - await autumnV1.products.update(proMonthlyV1.id, { - items: newItems, - }); - }); - - test("should migrate customer from v1 to v2", async () => { - await autumnV1.track({ - customer_id: customerId, - value: 500, - feature_id: TestFeature.Messages, - }); - - await timeout(2000); - - // Run migration via API - await autumnV1.migrate({ - from_product_id: proMonthlyV1.id, - to_product_id: proMonthlyV1.id, - from_version: 1, - to_version: 2, - }); - - await timeout(5000); - - const customer = await autumnV1.customers.get(customerId); - expect(customer).toBeDefined(); - expect(customer.products).toHaveLength(1); - expect(customer.products[0].id).toBe(proMonthlyV1.id); - expect(customer.products[0].version).toBe(2); - - expectFeaturesCorrect({ - customer, - product: proMonthlyV2, - usage: [ - { - featureId: TestFeature.Messages, - value: 500, - }, - ], - }); - }); - }, -); diff --git a/server/tests/external-psps/revenuecat/revenuecat-webhooks.test.ts b/server/tests/external-psps/revenuecat/revenuecat-webhooks.test.ts deleted file mode 100644 index 0f036f7eb..000000000 --- a/server/tests/external-psps/revenuecat/revenuecat-webhooks.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -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/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"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { - expectWebhookSuccess, - RevenueCatWebhookClient, -} from "./utils/revenue-cat-webhook-client.js"; - -const testCase = "rc1"; -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({ - featureId: TestFeature.Messages, - includedUsage: 1000, -}); - -const proMonthly = constructProduct({ - id: `${testCase}-pro-monthly`, - type: "pro", - items: [messagesFeature], - isDefault: false, -}); - -const proYearly = constructProduct({ - id: `${testCase}-pro-yearly`, - type: "pro", - isAnnual: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 1000, - }), - ], - isDefault: false, -}); - -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; - let rcClient: RevenueCatWebhookClient; - - 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 || - cp.status === CusProductStatus.PastDue, - ) - .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 - if ( - ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== - RC_WEBHOOK_SECRET - ) { - await OrgService.update({ - db: ctx.db, - orgId: ctx.org.id, - updates: { - processor_configs: { - ...ctx.org.processor_configs, - revenuecat: { - 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, - sandbox_webhook_secret: RC_WEBHOOK_SECRET, - }, - }, - }, - }); - } - - // Initialize RevenueCat webhook client - rcClient = new RevenueCatWebhookClient({ - orgId: ctx.org.id, - env: ctx.env, - webhookSecret: RC_WEBHOOK_SECRET, - }); - - // 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, - }), - ]); - - 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 () => { - const result = await rcClient.initialPurchase({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - 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); - - proMonthlyCusProductId = await fetchLatestActiveCusProductId(); - }); - - test("should upgrade customer to pro yearly product upon renewal", async () => { - const result = await rcClient.renewal({ - productId: RC_PRO_YEARLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - await fetchLatestActiveCusProductId(); - 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(proYearly.id); - }); - - test("should downgrade customer to pro monthly product upon initial purchase", async () => { - const result = await rcClient.initialPurchase({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - 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); - - const currentCusProductId = await fetchLatestActiveCusProductId(); - console.log("currentCusProductId", currentCusProductId); - expect(currentCusProductId).not.toBe(getBaselineCusProductId()); - updateBaselineCusProductId(currentCusProductId); - }); - - test("should go to cancelling state upon cancellation", async () => { - const result = await rcClient.cancellation({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - expirationAtMs: Date.now() + 1000 * 60 * 60 * 24 * 30, - }); - expectWebhookSuccess(result); - - 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); - 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 result = await rcClient.uncancellation({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - }); - expectWebhookSuccess(result); - - 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 mark product as past due upon billing issue", async () => { - const result = await rcClient.billingIssue({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - 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(String(customer.products[0].status)).toBe("past_due"); - - const currentCusProductId = await fetchLatestActiveCusProductId(); - expect(currentCusProductId).toBe(getBaselineCusProductId()); - }); - - test("should go to expired state upon expiration", async () => { - const result = await rcClient.expiration({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - const customer = await autumnV1.customers.get(customerId); - 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 result = await rcClient.nonRenewingPurchase({ - productId: RC_ADD_ON_ID, - appUserId: customerId, - originalTransactionId: "add_on_tx_12345", - }); - expectWebhookSuccess(result); - - 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/server/tests/external-psps/revenuecat/revenuecatWebhooks.test.ts b/server/tests/external-psps/revenuecat/revenuecatWebhooks.test.ts deleted file mode 100644 index 51fda2298..000000000 --- a/server/tests/external-psps/revenuecat/revenuecatWebhooks.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -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/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"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { - expectWebhookSuccess, - RevenueCatWebhookClient, -} from "./utils/revenue-cat-webhook-client.js"; - -const testCase = "rc1"; -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({ - featureId: TestFeature.Messages, - includedUsage: 1000, -}); - -const proMonthly = constructProduct({ - id: `${testCase}-pro-monthly`, - type: "pro", - items: [messagesFeature], - isDefault: false, -}); - -const proYearly = constructProduct({ - id: `${testCase}-pro-yearly`, - type: "pro", - isAnnual: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 1000, - }), - ], - isDefault: false, -}); - -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; - let rcClient: RevenueCatWebhookClient; - - 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 || - cp.status === CusProductStatus.PastDue, - ) - .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 - if ( - ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== - RC_WEBHOOK_SECRET - ) { - await OrgService.update({ - db: ctx.db, - orgId: ctx.org.id, - updates: { - processor_configs: { - ...ctx.org.processor_configs, - revenuecat: { - 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, - sandbox_webhook_secret: RC_WEBHOOK_SECRET, - }, - }, - }, - }); - } - - // Initialize RevenueCat webhook client - rcClient = new RevenueCatWebhookClient({ - orgId: ctx.org.id, - env: ctx.env, - webhookSecret: RC_WEBHOOK_SECRET, - }); - - // 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, - }), - ]); - - 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 () => { - const result = await rcClient.initialPurchase({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - 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); - - proMonthlyCusProductId = await fetchLatestActiveCusProductId(); - }); - - test("should upgrade customer to pro yearly product upon renewal", async () => { - const result = await rcClient.renewal({ - productId: RC_PRO_YEARLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - await fetchLatestActiveCusProductId(); - 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(proYearly.id); - }); - - test("should downgrade customer to pro monthly product upon initial purchase", async () => { - const result = await rcClient.initialPurchase({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - 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); - - const currentCusProductId = await fetchLatestActiveCusProductId(); - console.log("currentCusProductId", currentCusProductId); - expect(currentCusProductId).not.toBe(getBaselineCusProductId()); - updateBaselineCusProductId(currentCusProductId); - }); - - test("should go to cancelling state upon cancellation", async () => { - const result = await rcClient.cancellation({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - expirationAtMs: Date.now() + 1000 * 60 * 60 * 24 * 30, - }); - expectWebhookSuccess(result); - - 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); - 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 result = await rcClient.uncancellation({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - }); - expectWebhookSuccess(result); - - 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 mark product as past due upon billing issue", async () => { - const result = await rcClient.billingIssue({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - 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(String(customer.products[0].status)).toBe("past_due"); - - const currentCusProductId = await fetchLatestActiveCusProductId(); - expect(currentCusProductId).toBe(getBaselineCusProductId()); - }); - - test("should go to expired state upon expiration", async () => { - const result = await rcClient.expiration({ - productId: RC_PRO_MONTHLY_ID, - appUserId: customerId, - originalTransactionId: "1234567890", - }); - expectWebhookSuccess(result); - - const customer = await autumnV1.customers.get(customerId); - 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 result = await rcClient.nonRenewingPurchase({ - productId: RC_ADD_ON_ID, - appUserId: customerId, - originalTransactionId: "add_on_tx_12345", - }); - expectWebhookSuccess(result); - - 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/server/tests/external-psps/revenuecat/utils/revenue-cat-webhook-client.ts b/server/tests/external-psps/revenuecat/utils/revenue-cat-webhook-client.ts deleted file mode 100644 index 4155fa441..000000000 --- a/server/tests/external-psps/revenuecat/utils/revenue-cat-webhook-client.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type { AppEnv } from "@autumn/shared"; - -type RevenueCatEventType = - | "INITIAL_PURCHASE" - | "RENEWAL" - | "CANCELLATION" - | "UNCANCELLATION" - | "EXPIRATION" - | "NON_RENEWING_PURCHASE" - | "BILLING_ISSUE" - | "PRODUCT_CHANGE"; - -interface BaseWebhookEvent { - product_id: string; - app_user_id: string; - original_app_user_id?: string; - original_transaction_id?: string; -} - -interface CancellationEvent extends BaseWebhookEvent { - expiration_at_ms: number; -} - -interface RenewalEvent extends BaseWebhookEvent {} - -interface ExpirationEvent extends BaseWebhookEvent { - expiration_at_ms?: number; -} - -interface RevenueCatWebhookClientConfig { - orgId: string; - env: AppEnv; - webhookSecret: string; - baseUrl?: string; -} - -/** - * Mock client for sending RevenueCat webhook events in tests - */ -export class RevenueCatWebhookClient { - private orgId: string; - private env: AppEnv; - private webhookSecret: string; - private baseUrl: string; - - constructor({ - orgId, - env, - webhookSecret, - baseUrl = "http://localhost:8080", - }: RevenueCatWebhookClientConfig) { - this.orgId = orgId; - this.env = env; - this.webhookSecret = webhookSecret; - this.baseUrl = baseUrl; - } - - private get webhookUrl(): string { - return `${this.baseUrl}/webhooks/revenuecat/${this.orgId}/${this.env}`; - } - - private async sendEvent({ - type, - event, - }: { - type: RevenueCatEventType; - event: Record; - }): Promise<{ response: Response; data: unknown }> { - const response = await fetch(this.webhookUrl, { - method: "POST", - body: JSON.stringify({ - event: { - type, - ...event, - }, - }), - headers: { - "Content-Type": "application/json", - Authorization: this.webhookSecret, - }, - }); - - const data = await response.json(); - return { response, data }; - } - - /** - * Send INITIAL_PURCHASE event - when a user first subscribes - */ - async initialPurchase({ - productId, - appUserId, - originalAppUserId, - originalTransactionId, - }: { - productId: string; - appUserId: string; - originalAppUserId?: string; - originalTransactionId?: string; - }) { - return this.sendEvent({ - type: "INITIAL_PURCHASE", - event: { - product_id: productId, - app_user_id: appUserId, - original_app_user_id: originalAppUserId, - original_transaction_id: - originalTransactionId ?? - `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`, - }, - }); - } - - /** - * Send RENEWAL event - when a subscription renews - */ - async renewal({ - productId, - appUserId, - originalAppUserId, - originalTransactionId, - }: { - productId: string; - appUserId: string; - originalAppUserId?: string; - originalTransactionId?: string; - }) { - return this.sendEvent({ - type: "RENEWAL", - event: { - product_id: productId, - app_user_id: appUserId, - original_app_user_id: originalAppUserId, - original_transaction_id: - originalTransactionId ?? - `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`, - }, - }); - } - - /** - * Send CANCELLATION event - when a user cancels their subscription - */ - async cancellation({ - productId, - appUserId, - originalAppUserId, - expirationAtMs, - originalTransactionId, - }: { - productId: string; - appUserId: string; - originalAppUserId?: string; - expirationAtMs?: number; - originalTransactionId?: string; - }) { - return this.sendEvent({ - type: "CANCELLATION", - event: { - product_id: productId, - app_user_id: appUserId, - original_app_user_id: originalAppUserId, - expiration_at_ms: - expirationAtMs ?? Date.now() + 1000 * 60 * 60 * 24 * 30, // Default: 30 days - original_transaction_id: - originalTransactionId ?? - `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`, - }, - }); - } - - /** - * Send UNCANCELLATION event - when a user resubscribes after cancelling - */ - async uncancellation({ - productId, - appUserId, - originalAppUserId, - }: { - productId: string; - appUserId: string; - originalAppUserId?: string; - }) { - return this.sendEvent({ - type: "UNCANCELLATION", - event: { - product_id: productId, - app_user_id: appUserId, - original_app_user_id: originalAppUserId, - }, - }); - } - - /** - * Send EXPIRATION event - when a subscription expires - */ - async expiration({ - productId, - appUserId, - originalAppUserId, - expirationAtMs, - originalTransactionId, - }: { - productId: string; - appUserId: string; - originalAppUserId?: string; - expirationAtMs?: number; - originalTransactionId?: string; - }) { - return this.sendEvent({ - type: "EXPIRATION", - event: { - product_id: productId, - app_user_id: appUserId, - original_app_user_id: originalAppUserId, - expiration_at_ms: expirationAtMs, - original_transaction_id: - originalTransactionId ?? - `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`, - }, - }); - } - - /** - * Send NON_RENEWING_PURCHASE event - for one-time purchases (consumables, non-consumables) - */ - async nonRenewingPurchase({ - productId, - appUserId, - originalAppUserId, - originalTransactionId, - }: { - productId: string; - appUserId: string; - originalAppUserId?: string; - originalTransactionId?: string; - }) { - return this.sendEvent({ - type: "NON_RENEWING_PURCHASE", - event: { - product_id: productId, - app_user_id: appUserId, - original_app_user_id: originalAppUserId, - original_transaction_id: - originalTransactionId ?? - `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`, - }, - }); - } - - /** - * Send BILLING_ISSUE event - when there's a billing problem - */ - async billingIssue({ - productId, - appUserId, - originalAppUserId, - originalTransactionId, - }: { - productId: string; - appUserId: string; - originalAppUserId?: string; - originalTransactionId?: string; - }) { - return this.sendEvent({ - type: "BILLING_ISSUE", - event: { - product_id: productId, - app_user_id: appUserId, - original_app_user_id: originalAppUserId, - original_transaction_id: - originalTransactionId ?? - `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`, - }, - }); - } - - /** - * Send PRODUCT_CHANGE event - when a user changes their subscription - */ - async productChange({ - productId, - newProductId, - appUserId, - originalAppUserId, - originalTransactionId, - }: { - productId: string; - newProductId: string; - appUserId: string; - originalAppUserId?: string; - originalTransactionId?: string; - }) { - return this.sendEvent({ - type: "PRODUCT_CHANGE", - event: { - product_id: productId, - new_product_id: newProductId, - app_user_id: appUserId, - original_app_user_id: originalAppUserId, - original_transaction_id: - originalTransactionId ?? - `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`, - }, - }); - } -} - -/** - * Helper to assert webhook response is successful - */ -export const expectWebhookSuccess = ({ - response, - data, -}: { - response: Response; - data: unknown; -}) => { - if (response.status !== 200) { - throw new Error( - `Expected webhook response status 200, got ${response.status}. Data: ${JSON.stringify(data)}`, - ); - } - if ((data as { success?: boolean })?.success !== true) { - throw new Error( - `Expected webhook response { success: true }, got ${JSON.stringify(data)}`, - ); - } -}; diff --git a/shared/db/schema.ts b/shared/db/schema.ts index 54c2a5353..d4bba4448 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -39,7 +39,6 @@ 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 { 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 +103,6 @@ export { replaceables, rollovers, vercelResources, - revenuecatMappings as revcatMappings, // Auth user, session, diff --git a/shared/enums/ErrCode.ts b/shared/enums/ErrCode.ts index 34878ab26..9d692aaa8 100644 --- a/shared/enums/ErrCode.ts +++ b/shared/enums/ErrCode.ts @@ -167,7 +167,4 @@ export const ErrCode = { VercelSubscriptionAlreadyExists: "vercel_subscription_already_exists", VercelSubscriptionNotFound: "vercel_subscription_not_found", VercelResourceNotFound: "vercel_resource_not_found", - - // Products - ProductNotFound: "product_not_found", }; diff --git a/shared/models/cusProductModels/cusProductTable.ts b/shared/models/cusProductModels/cusProductTable.ts index 98a9694cd..19edc355b 100644 --- a/shared/models/cusProductModels/cusProductTable.ts +++ b/shared/models/cusProductModels/cusProductTable.ts @@ -14,7 +14,7 @@ import { freeTrials } from "../productModels/freeTrialModels/freeTrialTable.js"; import { products } from "../productModels/productTable.js"; export type CustomerProductProcessor = { - type: "stripe" | "revenuecat"; + type: "stripe"; id: string; }; diff --git a/shared/models/genModels/genEnums.ts b/shared/models/genModels/genEnums.ts index d9088d016..e8138d205 100644 --- a/shared/models/genModels/genEnums.ts +++ b/shared/models/genModels/genEnums.ts @@ -15,5 +15,4 @@ export enum Duration { export enum ProcessorType { Stripe = "stripe", - RevenueCat = "revenuecat", } diff --git a/shared/models/genModels/processorSchemas.ts b/shared/models/genModels/processorSchemas.ts index 4af3a6cd6..c461c8fe1 100644 --- a/shared/models/genModels/processorSchemas.ts +++ b/shared/models/genModels/processorSchemas.ts @@ -1,5 +1,4 @@ import { z } from "zod/v4"; -import { ProcessorType } from "./genEnums"; /** * Customer-level Vercel processor @@ -88,40 +87,13 @@ export const UpsertVercelProcessorConfigSchema = z.object({ marketplace_mode: z.enum(VercelMarketplaceMode).optional(), }); -/** - * Organization-level RevenueCat processor configuration - * Stores API key, project ID, and webhook secret - */ -export const RevenueCatProcessorConfigSchema = z.object({ - api_key: z.string(), - sandbox_api_key: z.string().optional(), - project_id: z.string().optional(), - sandbox_project_id: z.string().optional(), - webhook_secret: z.string(), - sandbox_webhook_secret: z.string().optional(), -}); - -export const UpsertRevenueCatProcessorConfigSchema = z.object({ - api_key: z.string().min(8).optional(), - sandbox_api_key: z.string().min(8).optional(), - project_id: z.string().min(1).optional(), - sandbox_project_id: z.string().min(1).optional(), -}); - /** * Container for all processor configurations at organization level */ export const ProcessorConfigsSchema = z.object({ vercel: VercelProcessorConfigSchema.optional(), - revenuecat: RevenueCatProcessorConfigSchema.optional(), }); -export const ExternalSubIDSchema = z.object({ - type: z.enum(ProcessorType), - id: z.string(), -}); - -export type ExternalSubID = z.infer; // Export inferred types for backward compatibility export type VercelProcessor = z.infer; export type ExternalProcessors = z.infer; @@ -129,10 +101,4 @@ export type VercelProcessorConfig = z.infer; export type UpsertVercelProcessorConfig = z.infer< typeof UpsertVercelProcessorConfigSchema >; -export type RevenueCatProcessorConfig = z.infer< - typeof RevenueCatProcessorConfigSchema ->; -export type UpsertRevenueCatProcessorConfig = z.infer< - typeof UpsertRevenueCatProcessorConfigSchema ->; export type ProcessorConfigs = z.infer; diff --git a/shared/models/orgModels/frontendOrg.ts b/shared/models/orgModels/frontendOrg.ts index 7bbec071a..8b579ab57 100644 --- a/shared/models/orgModels/frontendOrg.ts +++ b/shared/models/orgModels/frontendOrg.ts @@ -36,18 +36,6 @@ export const FrontendOrgSchema = z.object({ custom_payment_method: z.string().optional(), marketplace_mode: z.enum(VercelMarketplaceMode).optional(), }), - revenuecat: z.object({ - connected: z.boolean(), - /** These API Keys are also masked in the frontend - * - e.g test_******3a - */ - api_key: z.string().optional(), - sandbox_api_key: z.string().optional(), - project_id: z.string().optional(), - sandbox_project_id: z.string().optional(), - webhook_secret: z.string().optional(), - sandbox_webhook_secret: z.string().optional(), - }), }), }); diff --git a/shared/models/processorModels/processorModels.ts b/shared/models/processorModels/processorModels.ts index b7761620b..57756bbb6 100644 --- a/shared/models/processorModels/processorModels.ts +++ b/shared/models/processorModels/processorModels.ts @@ -1,3 +1,2 @@ -// Vercel + RevenueCat -export * from "./revenuecatModels/revenuecatMappingsTable.js"; +// Vercel export * from "./vercelModels/vercelResourcesTable.js"; diff --git a/shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts b/shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts deleted file mode 100644 index 4e0da9b4a..000000000 --- a/shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { AppEnv } from "@models/genModels/genEnums"; -import { organizations } from "@models/orgModels/orgTable.js"; -import { foreignKey, pgTable, primaryKey, text } from "drizzle-orm/pg-core"; - -// 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(), - autumn_product_id: text("autumn_product_id").notNull(), - revenuecat_product_ids: text("revenuecat_product_ids") - .array() - .notNull() - .default([]), - }, - (table) => [ - primaryKey({ - columns: [table.org_id, table.env, table.autumn_product_id], - name: "revenuecat_mappings_pkey", - }), - foreignKey({ - columns: [table.org_id], - foreignColumns: [organizations.id], - name: "revenuecat_mappings_org_id_fkey", - }).onDelete("cascade"), - ], -); - -export type RevenuecatMapping = typeof revenuecatMappings.$inferSelect; diff --git a/shared/models/productV2Models/productV2Models.ts b/shared/models/productV2Models/productV2Models.ts index 3837096d1..391402930 100644 --- a/shared/models/productV2Models/productV2Models.ts +++ b/shared/models/productV2Models/productV2Models.ts @@ -29,15 +29,6 @@ export const FrontendProductSchema = ProductV2Schema.extend({ .enum(["recurring", "one-off", "usage"]) .default("recurring") .nullable(), - external_processors: z - .object({ - revenuecat: z - .object({ - linked_product_id: z.string().nullish(), - }) - .nullish(), - }) - .nullish(), }); export type ProductV2 = z.infer; diff --git a/shared/utils/cusProductUtils/classifyCusProduct.ts b/shared/utils/cusProductUtils/classifyCusProduct.ts index e95907d69..77101dc4d 100644 --- a/shared/utils/cusProductUtils/classifyCusProduct.ts +++ b/shared/utils/cusProductUtils/classifyCusProduct.ts @@ -1,4 +1,4 @@ -import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; export const isCanceled = ({ cusProduct }: { cusProduct: FullCusProduct }) => { return cusProduct.canceled; diff --git a/shared/utils/cusProductUtils/convertCusProduct.ts b/shared/utils/cusProductUtils/convertCusProduct.ts index cf1bc251c..a3899a5e8 100644 --- a/shared/utils/cusProductUtils/convertCusProduct.ts +++ b/shared/utils/cusProductUtils/convertCusProduct.ts @@ -3,11 +3,7 @@ import type { SortCusEntParams } from "../../models/cusProductModels/cusEntModel import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import type { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels.js"; import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; -import type { - CusProduct, - FullCusProduct, -} from "../../models/cusProductModels/cusProductModels.js"; -import { ProcessorType } from "../../models/genModels/genEnums.js"; +import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import type { BillingType } from "../../models/productModels/priceModels/priceEnums.js"; import type { FullProduct } from "../../models/productModels/productModels.js"; import { cusEntMatchesEntity } from "../cusEntUtils/filterCusEntUtils.js"; @@ -159,7 +155,3 @@ export const cusProductToProduct = ({ free_trial: cusProduct.free_trial, } as FullProduct; }; - -export const cusProductToProcessorType = (cusProduct: CusProduct) => { - return cusProduct.processor?.type ?? ProcessorType.Stripe; -}; diff --git a/vite/src/components/general/PageSectionHeader.tsx b/vite/src/components/general/PageSectionHeader.tsx index e56b8d0bf..847e93750 100644 --- a/vite/src/components/general/PageSectionHeader.tsx +++ b/vite/src/components/general/PageSectionHeader.tsx @@ -26,7 +26,7 @@ export const PageSectionHeader = ({ return (
); }; - -export const RevenueCatIcon = ({ - size = 32, - color = "currentColor", -}: { - size?: number; - color?: string; -}) => { - return ( - - - - - ); -}; diff --git a/vite/src/hooks/common/useAutumnFlags.tsx b/vite/src/hooks/common/useAutumnFlags.tsx index e62f21b04..795cca274 100644 --- a/vite/src/hooks/common/useAutumnFlags.tsx +++ b/vite/src/hooks/common/useAutumnFlags.tsx @@ -12,7 +12,6 @@ export const useAutumnFlags = () => { stripe_key: false, platform: false, vercel: false, - revenuecat: false, }); useEffect(() => { @@ -24,7 +23,6 @@ export const useAutumnFlags = () => { stripe_key: notNullish(customer.features.stripe_key), platform: notNullish(customer.features.platform), vercel: notNullish(customer.features.vercel), - revenuecat: notNullish(customer.features.revenuecat), }; // Only update storage/state when values actually change @@ -33,8 +31,7 @@ export const useAutumnFlags = () => { flags.webhooks !== nextFlags.webhooks || flags.stripe_key !== nextFlags.stripe_key || flags.platform !== nextFlags.platform || - flags.vercel !== nextFlags.vercel || - flags.revenuecat !== nextFlags.revenuecat + flags.vercel !== nextFlags.vercel ) { setFlags(nextFlags); } diff --git a/vite/src/hooks/queries/revcat/useRCMappings.tsx b/vite/src/hooks/queries/revcat/useRCMappings.tsx deleted file mode 100644 index 96143b37c..000000000 --- a/vite/src/hooks/queries/revcat/useRCMappings.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; - -interface RCMapping { - org_id: string; - env: string; - autumn_product_id: string; - revenuecat_product_ids: string[]; -} - -interface SaveMappingInput { - autumn_product_id: string; - revenuecat_product_ids: string[]; -} - -export const useRCMappings = () => { - const axiosInstance = useAxiosInstance(); - const queryClient = useQueryClient(); - - const { data: mappings = [], isLoading } = useQuery({ - queryKey: ["revenuecat-mappings"], - queryFn: async () => { - const { data } = await axiosInstance.get<{ mappings: RCMapping[] }>( - "/v1/organization/revenuecat/mappings", - ); - return data.mappings; - }, - }); - - const saveMutation = useMutation({ - mutationFn: async (mappingsToSave: SaveMappingInput[]) => { - const { data } = await axiosInstance.post<{ mappings: RCMapping[] }>( - "/v1/organization/revenuecat/mappings", - { mappings: mappingsToSave }, - ); - return data.mappings; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["revenuecat-mappings"] }); - toast.success("Mappings saved successfully"); - }, - onError: () => { - toast.error("Failed to save mappings"); - }, - }); - - return { - mappings, - isLoading, - saveMappings: saveMutation.mutateAsync, - isSaving: saveMutation.isPending, - }; -}; diff --git a/vite/src/hooks/queries/revcat/useRCProducts.tsx b/vite/src/hooks/queries/revcat/useRCProducts.tsx deleted file mode 100644 index 43420a93b..000000000 --- a/vite/src/hooks/queries/revcat/useRCProducts.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; - -interface RevenueCatProduct { - id: string; - name: string; -} - -interface RevenueCatProductsResponse { - products: RevenueCatProduct[]; -} - -export const useRCProducts = () => { - const axiosInstance = useAxiosInstance(); - - const fetcher = async () => { - try { - const { data }: { data: RevenueCatProductsResponse } = - await axiosInstance.post("/v1/organization/revenuecat/products"); - - return data.products || []; - } catch (_error) { - return []; - } - }; - - const { data: products = [], isLoading, error, refetch } = useQuery({ - queryKey: ["revenuecat-products"], - queryFn: fetcher, - }); - - return { - products, - isLoading, - error, - refetch, - }; -}; diff --git a/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx b/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx deleted file mode 100644 index 94e5bf258..000000000 --- a/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; - -interface RevenueCatConfig { - connected: boolean; - api_key?: string; - sandbox_api_key?: string; - project_id?: string; - sandbox_project_id?: string; - webhook_secret?: string; - sandbox_webhook_secret?: string; -} - -export const useRevenueCatQuery = () => { - const axiosInstance = useAxiosInstance(); - const fetcher = async () => { - try { - const { data }: { data: RevenueCatConfig } = await axiosInstance.get( - "/v1/organization/revenuecat", - ); - return data; - } catch (_error) { - return null; - } - }; - const { data, isLoading, error, refetch } = useQuery({ - queryKey: ["revenuecat"], - queryFn: fetcher, - }); - - return { - revenueCatConfig: data, - isLoading, - error, - refetch, - }; -}; diff --git a/vite/src/hooks/queries/useProductsQuery.tsx b/vite/src/hooks/queries/useProductsQuery.tsx index c3dc627e5..2d2542184 100644 --- a/vite/src/hooks/queries/useProductsQuery.tsx +++ b/vite/src/hooks/queries/useProductsQuery.tsx @@ -2,9 +2,6 @@ import type { FullProduct, ProductCounts, ProductV2 } from "@autumn/shared"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAxiosInstance } from "@/services/useAxiosInstance"; -/** - * Fetch all products for the current org. - */ export const useProductsQuery = () => { const axiosInstance = useAxiosInstance(); const queryClient = useQueryClient(); @@ -45,7 +42,7 @@ export const useProductsQuery = () => { }; return { - products: data?.products || [], + products: (data?.products || []) as ProductV2[], counts: countsData || {}, groupToDefaults: data?.groupToDefaults || {}, isLoading, diff --git a/vite/src/utils/linkUtils.ts b/vite/src/utils/linkUtils.ts index 671869372..d53996431 100644 --- a/vite/src/utils/linkUtils.ts +++ b/vite/src/utils/linkUtils.ts @@ -17,16 +17,6 @@ export const getStripeCusLink = ({ return `${baseUrl}${accountPath}${withTest}/customers/${customerId}`; }; -export const getRevenueCatCusLink = ({ - customerId, - projectId, -}: { - customerId: string; - projectId: string; -}) => { - return `https://app.revenuecat.com/projects/${projectId}/customers/${customerId}`; -}; - export const getStripeSubLink = ({ subscriptionId, env, diff --git a/vite/src/views/customers2/customer/CustomerActions.tsx b/vite/src/views/customers2/customer/CustomerActions.tsx index 6b2e7e4d0..dfdf4a947 100644 --- a/vite/src/views/customers2/customer/CustomerActions.tsx +++ b/vite/src/views/customers2/customer/CustomerActions.tsx @@ -1,5 +1,5 @@ -import type { Feature, FullCusProduct } from "@autumn/shared"; -import { AppEnv, FeatureUsageType, ProcessorType } from "@autumn/shared"; +import type { Feature } from "@autumn/shared"; +import { FeatureUsageType } from "@autumn/shared"; import { ArrowSquareOutIcon, CaretDownIcon, @@ -20,7 +20,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; -import { useOrg } from "@/hooks/common/useOrg"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; import { useDropdownShortcut } from "@/hooks/useDropdownShortcut"; @@ -29,7 +28,7 @@ import { CusService } from "@/services/customers/CusService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useEnv } from "@/utils/envUtils"; import { getBackendErr } from "@/utils/genUtils"; -import { getRevenueCatCusLink, getStripeCusLink } from "@/utils/linkUtils"; +import { getStripeCusLink } from "@/utils/linkUtils"; import { DeleteCustomerDialog } from "@/views/customers/customer/components/DeleteCustomerDialog"; import UpdateCustomerDialog from "@/views/customers/customer/components/UpdateCustomerDialog"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; @@ -45,7 +44,6 @@ export function CustomerActions() { const [portalLoading, setPortalLoading] = useState(false); const { customer } = useCusQuery(); const { features } = useFeaturesQuery(); - const { org } = useOrg(); const { stripeAccount } = useOrgStripeQuery(); const env = useEnv(); const axiosInstance = useAxiosInstance(); @@ -143,55 +141,23 @@ export function CustomerActions() { {portalLoading ? "Opening..." : "Open customer portal"} - {stripeCustomerId && - customer?.processor?.type === ProcessorType.Stripe && ( - { - window.open( - getStripeCusLink({ - customerId: stripeCustomerId, - env, - accountId: stripeAccount?.id, - }), - "_blank", - ); - }} - className="flex gap-2" - shortcut="s" - > - - Open in Stripe - - )} - {((customer?.processor?.id && - customer.processor.type === ProcessorType.RevenueCat) || - customer?.customer_products?.some( - (cp: FullCusProduct) => - cp.processor?.type === ProcessorType.RevenueCat, - )) && ( + {stripeCustomerId && ( { window.open( - getRevenueCatCusLink({ - customerId: customer.id, - projectId: - env === AppEnv.Live - ? (org?.processor_configs?.revenuecat?.project_id?.replace( - "proj", - "", - ) ?? "") - : (org?.processor_configs?.revenuecat?.sandbox_project_id?.replace( - "proj", - "", - ) ?? ""), + getStripeCusLink({ + customerId: stripeCustomerId, + env, + accountId: stripeAccount?.id, }), "_blank", ); }} className="flex gap-2" + shortcut="s" > - Open in RevenueCat + Open in Stripe )} diff --git a/vite/src/views/developer/DevView.tsx b/vite/src/views/developer/DevView.tsx index 409733417..ee045c472 100644 --- a/vite/src/views/developer/DevView.tsx +++ b/vite/src/views/developer/DevView.tsx @@ -8,7 +8,6 @@ import { useAutumnFlags } from "@/hooks/common/useAutumnFlags"; import { useDevQuery } from "@/hooks/queries/useDevQuery"; import LoadingScreen from "../general/LoadingScreen"; import { ApiKeysPage } from "./api-keys/ApiKeysPage"; -import { ConfigureRevenueCat } from "./configure-revenuecat/ConfigureRevenueCat"; import { ConfigureStripe } from "./configure-stripe/ConfigureStripe"; import { ConfigureVercel } from "./configure-vercel/ConfigureVercel"; import { PublishableKeySection } from "./publishable-key"; @@ -18,7 +17,7 @@ export default function DevScreen() { const { queryStates } = useAppQueryStates({ defaultTab: "api_keys" }); const tab = queryStates.tab; - const { pkey, webhooks, vercel, revenuecat } = useAutumnFlags(); + const { pkey, webhooks, vercel } = useAutumnFlags(); if (isLoading) return ; @@ -39,8 +38,6 @@ export default function DevScreen() { )} {tab === "vercel" && vercel && } - - {tab === "revenuecat" && revenuecat && }
); } diff --git a/vite/src/views/developer/configure-revenuecat/ConfigureRevenueCat.tsx b/vite/src/views/developer/configure-revenuecat/ConfigureRevenueCat.tsx deleted file mode 100644 index c217d2301..000000000 --- a/vite/src/views/developer/configure-revenuecat/ConfigureRevenueCat.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useCallback, useState } from "react"; -import { toast } from "sonner"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useRevenueCatQuery } from "@/hooks/queries/revcat/useRevenueCatQuery"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useEnv } from "@/utils/envUtils"; -import LoadingScreen from "@/views/general/LoadingScreen"; -import { ApiKeyDialog } from "./components/ApiKeyDialog"; -import { ProjectIdDialog } from "./components/ProjectIdDialog"; -import { RevenueCatConnectionCard } from "./components/RevenueCatConnectionCard"; -import { RevenueCatMappingSheet } from "./components/RevenueCatMappingSheet"; -import { RevenueCatWebhookSecret } from "./components/RevenueCatWebhookSecret"; -import { RevenueCatWebhookUrl } from "./components/RevenueCatWebhookUrl"; - -export const ConfigureRevenueCat = () => { - const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); - const [showProjectIdDialog, setShowProjectIdDialog] = useState(false); - const [showMappingSheet, setShowMappingSheet] = useState(false); - const [connecting, setConnecting] = useState(false); - const [apiKeyInput, setApiKeyInput] = useState(""); - const [projectIdInput, setProjectIdInput] = useState(""); - - const { org } = useOrg(); - const { - revenueCatConfig, - isLoading: isLoadingRevenueCatAccount, - refetch, - } = useRevenueCatQuery(); - const axiosInstance = useAxiosInstance(); - const env = useEnv(); - - const dashboardUrl = "https://app.revenuecat.com/"; - - const handleUpdateApiKey = async () => { - if (!apiKeyInput.trim()) return; - - setConnecting(true); - try { - const payload = - env === "live" - ? { api_key: apiKeyInput } - : { sandbox_api_key: apiKeyInput }; - - await axiosInstance.patch("/v1/organization/revenuecat", payload); - - // Refetch config - await refetch(); - - setShowApiKeyDialog(false); - setApiKeyInput(""); - } catch (error) { - console.error("Failed to update API key:", error); - } finally { - setConnecting(false); - } - }; - - const handleUpdateProjectId = async () => { - if (!projectIdInput.trim()) return; - - setConnecting(true); - try { - const payload = - env === "live" - ? { project_id: projectIdInput } - : { sandbox_project_id: projectIdInput }; - - await axiosInstance.patch("/v1/organization/revenuecat", payload); - - // Refetch config - await refetch(); - - setShowProjectIdDialog(false); - setProjectIdInput(""); - } catch (error) { - console.error("Failed to update project ID:", error); - } finally { - setConnecting(false); - } - }; - - const currentWebhookSecret = - env === "live" - ? revenueCatConfig?.webhook_secret - : revenueCatConfig?.sandbox_webhook_secret; - - const currentApiKey = - env === "live" - ? revenueCatConfig?.api_key - : revenueCatConfig?.sandbox_api_key; - - const currentProjectId = - env === "live" - ? revenueCatConfig?.project_id - : revenueCatConfig?.sandbox_project_id; - - const statusDescription = revenueCatConfig?.connected - ? "Your RevenueCat account is connected." - : "Connect your RevenueCat account to start tracking subscriptions."; - - const handleApiKeyClick = useCallback(() => setShowApiKeyDialog(true), []); - const handleProjectIdClick = useCallback( - () => setShowProjectIdDialog(true), - [], - ); - const handleMapProductsClick = useCallback(() => { - if (!currentApiKey) { - toast.error("You need to link your RevenueCat API Key first"); - return; - } - setShowMappingSheet(true); - }, [currentApiKey]); - - if (isLoadingRevenueCatAccount) { - return ; - } - - return ( -
-
- - - - - -
- - - - - - -
- ); -}; diff --git a/vite/src/views/developer/configure-revenuecat/components/ApiKeyDialog.tsx b/vite/src/views/developer/configure-revenuecat/components/ApiKeyDialog.tsx deleted file mode 100644 index 348270ea4..000000000 --- a/vite/src/views/developer/configure-revenuecat/components/ApiKeyDialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Button } from "@/components/v2/buttons/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/v2/dialogs/Dialog"; -import { FormLabel } from "@/components/v2/form/FormLabel"; -import { Input } from "@/components/v2/inputs/Input"; - -interface ApiKeyDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - env: string; - currentApiKey?: string; - apiKeyInput: string; - onApiKeyInputChange: (value: string) => void; - onSave: () => void; - isLoading: boolean; -} - -export const ApiKeyDialog = ({ - open, - onOpenChange, - env, - currentApiKey, - apiKeyInput, - onApiKeyInputChange, - onSave, - isLoading, -}: ApiKeyDialogProps) => { - return ( - - - - - {currentApiKey ? "Update" : "Add"}{" "} - {env === "live" ? "API Key" : "Sandbox API Key"} - - - Enter your RevenueCat {env === "live" ? "" : "sandbox "}API key. You - can find this in your RevenueCat dashboard. - - -
-
- - - {env === "live" ? "API Key" : "Sandbox API Key"} - - - onApiKeyInputChange(e.target.value)} - placeholder="sk_..." - /> -
-
- - -
-
-
-
- ); -}; diff --git a/vite/src/views/developer/configure-revenuecat/components/ProjectIdDialog.tsx b/vite/src/views/developer/configure-revenuecat/components/ProjectIdDialog.tsx deleted file mode 100644 index 197bf78a9..000000000 --- a/vite/src/views/developer/configure-revenuecat/components/ProjectIdDialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Button } from "@/components/v2/buttons/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/v2/dialogs/Dialog"; -import { FormLabel } from "@/components/v2/form/FormLabel"; -import { Input } from "@/components/v2/inputs/Input"; - -interface ProjectIdDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - env: string; - currentProjectId?: string; - projectIdInput: string; - onProjectIdInputChange: (value: string) => void; - onSave: () => void; - isLoading: boolean; -} - -export const ProjectIdDialog = ({ - open, - onOpenChange, - env, - currentProjectId, - projectIdInput, - onProjectIdInputChange, - onSave, - isLoading, -}: ProjectIdDialogProps) => { - return ( - - - - - {currentProjectId ? "Update" : "Add"}{" "} - {env === "live" ? "Project ID" : "Sandbox Project ID"} - - - Enter your RevenueCat {env === "live" ? "" : "sandbox "}project ID. - You can find this in your RevenueCat dashboard. - - -
-
- - - {env === "live" ? "Project ID" : "Sandbox Project ID"} - - - onProjectIdInputChange(e.target.value)} - placeholder="Enter project ID..." - /> -
-
- - -
-
-
-
- ); -}; diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatConnectionCard.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatConnectionCard.tsx deleted file mode 100644 index 100b1f245..000000000 --- a/vite/src/views/developer/configure-revenuecat/components/RevenueCatConnectionCard.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { Button } from "@/components/v2/buttons/Button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/v2/cards/Card"; -import { FormLabel } from "@/components/v2/form/FormLabel"; - -interface RevenueCatConnectionCardProps { - isLoading: boolean; - statusDescription: string; - dashboardUrl: string; - currentApiKey?: string; - env: string; - onApiKeyClick: () => void; - onProjectIdClick: () => void; - onMapProductsClick: () => void; - currentProjectId?: string; -} - -export const RevenueCatConnectionCard = ({ - isLoading, - statusDescription, - dashboardUrl, - currentApiKey, - env, - onApiKeyClick, - onProjectIdClick, - onMapProductsClick, - currentProjectId, -}: RevenueCatConnectionCardProps) => { - return ( - - - Connect your RevenueCat account - {isLoading ? ( -
- - -
- ) : ( - statusDescription && ( - - {statusDescription} - {dashboardUrl && ( - - {" "} - Visit the RevenueCat dashboard{" "} - - here - - - )} - - ) - )} -
- - {currentApiKey && ( -
- - Current API key:{" "} - - {currentApiKey} - - -
- )} - {currentProjectId && ( -
- - Current project ID:{" "} - - {currentProjectId} - - -
- )} -
- - - -
-
-
- ); -}; diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatMappingSheet.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatMappingSheet.tsx deleted file mode 100644 index 19adea4df..000000000 --- a/vite/src/views/developer/configure-revenuecat/components/RevenueCatMappingSheet.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import { isFeaturePriceItem } from "@autumn/shared"; -import { X } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/v2/selects/Select"; -import { - SheetFooter, - SheetHeader, -} from "@/components/v2/sheets/SharedSheetComponents"; -import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet"; -import { useRCMappings } from "@/hooks/queries/revcat/useRCMappings"; -import { useRCProducts } from "@/hooks/queries/revcat/useRCProducts"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; - -interface ProductMapping { - autumnProductId: string; - autumnProductName: string; - revenueCatProductIds: string[]; -} - -interface RevenueCatMappingSheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -interface RCProduct { - id: string; - name: string; -} - -const MappingRow = memo(function MappingRow({ - mapping, - rcProducts, - mappedRevenueCatProductIds, - onAddProduct, - onRemoveProduct, -}: { - mapping: ProductMapping; - rcProducts: RCProduct[]; - mappedRevenueCatProductIds: string[]; - onAddProduct: (autumnProductId: string, revenueCatProductId: string) => void; - onRemoveProduct: ( - autumnProductId: string, - revenueCatProductId: string, - ) => void; -}) { - const selectedProducts = mapping.revenueCatProductIds; - const availableProducts = rcProducts.filter( - (p) => - !mappedRevenueCatProductIds.includes(p.id) && - !selectedProducts.includes(p.id), - ); - const hasNoRcProducts = rcProducts.length === 0; - - return ( -
-
{mapping.autumnProductName}
- - {/* Display selected products as tags */} - {selectedProducts.length > 0 && ( -
- {selectedProducts.map((productId) => { - const product = rcProducts.find((p) => p.id === productId); - return ( -
- {product?.name || productId} - -
- ); - })} -
- )} - - {/* Select to add more products */} - {hasNoRcProducts ? ( -
- No RevenueCat products found. Create products in RevenueCat before - mapping. -
- ) : availableProducts.length === 0 ? ( -
- All RevenueCat products are already mapped to Autumn products. Remove - an existing mapping to change assignments. -
- ) : ( - - )} -
- ); -}); - -export function RevenueCatMappingSheet({ - open, - onOpenChange, -}: RevenueCatMappingSheetProps) { - const { products: allProducts } = useProductsQuery(); - const { products: rcProducts } = useRCProducts(); - - // Filter out prepaid products locally with useMemo to avoid infinite loops - const products = useMemo( - () => allProducts.filter((p) => !p.items.some(isFeaturePriceItem)), - [allProducts], - ); - const { - mappings: existingMappings, - saveMappings, - isSaving, - } = useRCMappings(); - const [mappings, setMappings] = useState([]); - const initializedRef = useRef(false); - - // Initialize mappings only once when sheet opens - useEffect(() => { - if (!open) { - initializedRef.current = false; - return; - } - - if (initializedRef.current || !products || products.length === 0) { - return; - } - - // Group products by ID and get the latest version of each - const productMap = new Map(); - for (const product of products) { - const existing = productMap.get(product.id); - if (!existing || product.version > existing.version) { - productMap.set(product.id, product); - } - } - - const latestProducts = Array.from(productMap.values()); - const initialMappings = latestProducts.map((product) => { - const existingMapping = existingMappings.find( - (m) => m.autumn_product_id === product.id, - ); - return { - autumnProductId: product.id, - autumnProductName: product.name, - revenueCatProductIds: existingMapping?.revenuecat_product_ids || [], - }; - }); - - setMappings(initialMappings); - initializedRef.current = true; - }, [open, products, existingMappings]); - - const handleAddProduct = useCallback( - (autumnProductId: string, revenueCatProductId: string) => { - setMappings((prev) => - prev.map((mapping) => - mapping.autumnProductId === autumnProductId - ? { - ...mapping, - revenueCatProductIds: [ - ...mapping.revenueCatProductIds, - revenueCatProductId, - ], - } - : mapping, - ), - ); - }, - [], - ); - - const handleRemoveProduct = useCallback( - (autumnProductId: string, revenueCatProductId: string) => { - setMappings((prev) => - prev.map((mapping) => - mapping.autumnProductId === autumnProductId - ? { - ...mapping, - revenueCatProductIds: mapping.revenueCatProductIds.filter( - (id) => id !== revenueCatProductId, - ), - } - : mapping, - ), - ); - }, - [], - ); - - const handleSave = useCallback(async () => { - const allRcProductIds = mappings.flatMap((m) => m.revenueCatProductIds); - const duplicateRc = allRcProductIds.filter( - (id, idx) => allRcProductIds.indexOf(id) !== idx, - ); - if (duplicateRc.length > 0) { - toast.error( - "Each RevenueCat product can only be mapped to one Autumn product", - ); - return; - } - - try { - await saveMappings( - mappings.map((m) => ({ - autumn_product_id: m.autumnProductId, - revenuecat_product_ids: m.revenueCatProductIds, - })), - ); - onOpenChange(false); - } catch (_error) { - // Error handled by hook - } - }, [mappings, saveMappings, onOpenChange]); - - const handleCancel = useCallback(() => { - onOpenChange(false); - }, [onOpenChange]); - - // Compute mapped product IDs for each row - memoized per mapping - const getMappedIdsForProduct = useCallback( - (excludeAutumnProductId: string) => { - return mappings - .filter((m) => m.autumnProductId !== excludeAutumnProductId) - .flatMap((m) => m.revenueCatProductIds); - }, - [mappings], - ); - - return ( - - - - -
- {mappings.length === 0 ? ( -
- No products found. Create products to map them to RevenueCat. -
- ) : ( -
- {mappings.map((mapping) => ( - - ))} -
- )} -
- - - - Cancel - - - Save mappings - - -
-
- ); -} diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookSecret.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookSecret.tsx deleted file mode 100644 index c405faa32..000000000 --- a/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookSecret.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { CopyableSpan } from "@/components/general/CopyablePre"; -import { Skeleton } from "@/components/ui/skeleton"; -import { FormLabel } from "@/components/v2/form/FormLabel"; - -interface RevenueCatWebhookSecretProps { - env: string; - webhookSecret?: string; -} - -export const RevenueCatWebhookSecret = ({ - env, - webhookSecret, -}: RevenueCatWebhookSecretProps) => { - return ( -
- - - {env === "live" ? "Webhook Secret" : "Sandbox Webhook Secret"} - - -

- This is the webhook secret for RevenueCat events. You must set this - value in the RevenueCat console. -

- {webhookSecret ? ( - - ) : ( - - )} -
- ); -}; diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookUrl.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookUrl.tsx deleted file mode 100644 index 8897ba1f9..000000000 --- a/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookUrl.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - CodeGroup, - CodeGroupCodeSolidColour, - CodeGroupContent, - CodeGroupCopyButton, - CodeGroupList, - CodeGroupTab, -} from "@/components/v2/CodeGroup"; -import { FormLabel } from "@/components/v2/form/FormLabel"; - -interface RevenueCatWebhookUrlProps { - env: string; - orgId?: string; -} - -export const RevenueCatWebhookUrl = ({ - env, - orgId, -}: RevenueCatWebhookUrlProps) => { - const webhookUrl = `https://api.useautumn.com/webhooks/revenuecat/${orgId}/${env}`; - - return ( -
- - Webhook URL - -

- This is the webhook URL for your RevenueCat integration. You should - provide this to RevenueCat as the webhook URL in your project settings. -

- - - - {env === "live" ? "Live" : "Sandbox"} - - navigator.clipboard.writeText(webhookUrl)} - /> - - - - {webhookUrl} - - - -
- ); -}; diff --git a/vite/src/views/main-sidebar/MainSidebar.tsx b/vite/src/views/main-sidebar/MainSidebar.tsx index 5a80c39ee..503a7bd32 100644 --- a/vite/src/views/main-sidebar/MainSidebar.tsx +++ b/vite/src/views/main-sidebar/MainSidebar.tsx @@ -14,7 +14,6 @@ import { import { PanelLeft } from "lucide-react"; import { useHotkeys } from "react-hotkeys-hook"; import { Button } from "@/components/ui/button"; -import { RevenueCatIcon } from "@/components/v2/icons/AutumnIcons"; import { useAutumnFlags } from "@/hooks/common/useAutumnFlags"; import { useLocalStorage } from "@/hooks/common/useLocalStorage"; import { useOrg } from "@/hooks/common/useOrg"; @@ -35,7 +34,6 @@ export const buildDevSubTabs = ({ flags: { webhooks: boolean; vercel: boolean; - revenuecat: boolean; }; }) => { return [ @@ -58,16 +56,6 @@ export const buildDevSubTabs = ({ }, ] : []), - ...(flags.revenuecat - ? [ - { - title: "RevenueCat", - value: "revenuecat", - // icon: , - icon: , - }, - ] - : []), ...(flags.webhooks ? [ {