From 2cbaa8a3400767dd64f4c2223287800853795d3d Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 22 Dec 2025 21:19:13 +0000 Subject: [PATCH] =?UTF-8?q?Revert=20"Revert=20"feat:=20=F0=9F=8E=B8=20reve?= =?UTF-8?q?nue=20cat""?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 029c50dcf04c7d0b9fbf7ecbcf19d64ae5b8efd8. --- bun.lock | 24 ++ package.json | 2 +- scripts/testGroups/g2.sh | 7 +- server/experiments/revenuecat.ts | 0 server/package.json | 4 +- .../handlers/handleGetRevenuecatMappings.ts | 16 + .../handlers/handleGetRevenuecatProducts.ts | 32 ++ .../handlers/handleSaveRevenuecatMappings.ts | 51 +++ .../revenueCat/misc/RCMappingService.ts | 154 +++++++ .../misc/getRevenuecatWebhookSecret.ts | 13 + .../revenueCat/misc/initRevenuecatCli.ts | 54 +++ .../misc/resolveRevenuecatResources.ts | 88 ++++ .../revenueCat/misc/revenueCatMiddleware.ts | 65 +++ .../external/revenueCat/revenuecatTypes.ts | 156 +++++++ .../revenueCat/revenuecatWebhookRouter.ts | 100 +++++ .../webhookHandlers/handleRevenucatRenewal.ts | 162 +++++++ .../handleRevenuecatBillingIssue.ts | 73 ++++ .../handleRevenuecatCancellation.ts | 58 +++ .../handleRevenuecatExpiration.ts | 71 ++++ .../handleRevenuecatInitialPurchase.ts | 124 ++++++ .../handleRevenuecatNonRenewingPurchase.ts | 78 ++++ .../handleRevenuecatUncancellation.ts | 59 +++ server/src/initHono.ts | 2 + .../add-product/createFullCusProduct.ts | 13 +- .../attach/attachUtils/handleAttachErrors.ts | 23 + .../handleCheckoutErrors.ts | 9 +- .../internal/customers/cancel/cancelRouter.ts | 11 +- .../cusProducts/CusProductService.ts | 2 +- .../cusProductUtils/getExistingCusProducts.ts | 7 +- .../migrationSteps/migrateCustomer.ts | 64 +-- .../migrateRevenuecatCustomer.ts | 117 ++++++ .../migrationSteps/migrateStripeCustomer.ts | 54 +++ .../orgs/handlers/handleRevenueCatConfig.ts | 237 +++++++++++ server/src/internal/orgs/orgRouter.ts | 14 + server/src/internal/orgs/orgUtils.ts | 11 + server/src/utils/genUtils.ts | 8 + .../revenuecat/revenuecat-migration.test.ts | 211 ++++++++++ .../revenuecat/revenuecat-webhooks.test.ts | 395 ++++++++++++++++++ .../revenuecat/revenuecatWebhooks.test.ts | 395 ++++++++++++++++++ .../utils/revenue-cat-webhook-client.ts | 329 +++++++++++++++ shared/db/schema.ts | 2 + shared/enums/ErrCode.ts | 3 + .../cusProductModels/cusProductTable.ts | 2 +- shared/models/genModels/genEnums.ts | 1 + shared/models/genModels/processorSchemas.ts | 34 ++ shared/models/orgModels/frontendOrg.ts | 12 + .../models/processorModels/processorModels.ts | 3 +- .../revenuecatMappingsTable.ts | 30 ++ .../models/productV2Models/productV2Models.ts | 9 + .../cusProductUtils/classifyCusProduct.ts | 2 +- .../cusProductUtils/convertCusProduct.ts | 10 +- .../components/general/PageSectionHeader.tsx | 4 +- vite/src/components/v2/icons/AutumnIcons.tsx | 28 ++ vite/src/hooks/common/useAutumnFlags.tsx | 5 +- .../hooks/queries/revcat/useRCMappings.tsx | 54 +++ .../hooks/queries/revcat/useRCProducts.tsx | 38 ++ .../queries/revcat/useRevenueCatQuery.tsx | 37 ++ vite/src/hooks/queries/useProductsQuery.tsx | 5 +- vite/src/utils/linkUtils.ts | 10 + .../customers2/customer/CustomerActions.tsx | 54 ++- vite/src/views/developer/DevView.tsx | 5 +- .../ConfigureRevenueCat.tsx | 169 ++++++++ .../components/ApiKeyDialog.tsx | 81 ++++ .../components/ProjectIdDialog.tsx | 81 ++++ .../components/RevenueCatConnectionCard.tsx | 101 +++++ .../components/RevenueCatMappingSheet.tsx | 313 ++++++++++++++ .../components/RevenueCatWebhookSecret.tsx | 36 ++ .../components/RevenueCatWebhookUrl.tsx | 52 +++ vite/src/views/main-sidebar/MainSidebar.tsx | 12 + 69 files changed, 4425 insertions(+), 61 deletions(-) create mode 100644 server/experiments/revenuecat.ts create mode 100644 server/src/external/revenueCat/handlers/handleGetRevenuecatMappings.ts create mode 100644 server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts create mode 100644 server/src/external/revenueCat/handlers/handleSaveRevenuecatMappings.ts create mode 100644 server/src/external/revenueCat/misc/RCMappingService.ts create mode 100644 server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts create mode 100644 server/src/external/revenueCat/misc/initRevenuecatCli.ts create mode 100644 server/src/external/revenueCat/misc/resolveRevenuecatResources.ts create mode 100644 server/src/external/revenueCat/misc/revenueCatMiddleware.ts create mode 100644 server/src/external/revenueCat/revenuecatTypes.ts create mode 100644 server/src/external/revenueCat/revenuecatWebhookRouter.ts create mode 100644 server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts create mode 100644 server/src/external/revenueCat/webhookHandlers/handleRevenuecatBillingIssue.ts create mode 100644 server/src/external/revenueCat/webhookHandlers/handleRevenuecatCancellation.ts create mode 100644 server/src/external/revenueCat/webhookHandlers/handleRevenuecatExpiration.ts create mode 100644 server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts create mode 100644 server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts create mode 100644 server/src/external/revenueCat/webhookHandlers/handleRevenuecatUncancellation.ts create mode 100644 server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts create mode 100644 server/src/internal/migrations/migrationSteps/migrateStripeCustomer.ts create mode 100644 server/src/internal/orgs/handlers/handleRevenueCatConfig.ts create mode 100644 server/tests/external-psps/revenuecat/revenuecat-migration.test.ts create mode 100644 server/tests/external-psps/revenuecat/revenuecat-webhooks.test.ts create mode 100644 server/tests/external-psps/revenuecat/revenuecatWebhooks.test.ts create mode 100644 server/tests/external-psps/revenuecat/utils/revenue-cat-webhook-client.ts create mode 100644 shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts create mode 100644 vite/src/hooks/queries/revcat/useRCMappings.tsx create mode 100644 vite/src/hooks/queries/revcat/useRCProducts.tsx create mode 100644 vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx create mode 100644 vite/src/views/developer/configure-revenuecat/ConfigureRevenueCat.tsx create mode 100644 vite/src/views/developer/configure-revenuecat/components/ApiKeyDialog.tsx create mode 100644 vite/src/views/developer/configure-revenuecat/components/ProjectIdDialog.tsx create mode 100644 vite/src/views/developer/configure-revenuecat/components/RevenueCatConnectionCard.tsx create mode 100644 vite/src/views/developer/configure-revenuecat/components/RevenueCatMappingSheet.tsx create mode 100644 vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookSecret.tsx create mode 100644 vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookUrl.tsx diff --git a/bun.lock b/bun.lock index da9accfa3..98fee6c9e 100644 --- a/bun.lock +++ b/bun.lock @@ -65,6 +65,7 @@ "@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", @@ -75,6 +76,7 @@ "@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", @@ -965,6 +967,16 @@ "@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=="], @@ -999,6 +1011,8 @@ "@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=="], @@ -1653,6 +1667,8 @@ "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=="], @@ -3189,8 +3205,11 @@ "@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=="], @@ -3673,6 +3692,8 @@ "@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=="], @@ -3963,8 +3984,11 @@ "@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 64a5ee006..ba8f31aab 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 --env=dev -- bun scripts/dev.ts", + "d": "ENV_FILE=.env infisical run --watch --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 68024a63d..f16849227 100755 --- a/scripts/testGroups/g2.sh +++ b/scripts/testGroups/g2.sh @@ -3,7 +3,6 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_COMPACT \ - 'server/tests/attach/entities' \ 'server/tests/attach/basic' \ 'server/tests/attach/upgrade' \ 'server/tests/attach/downgrade' \ @@ -15,5 +14,9 @@ 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 new file mode 100644 index 000000000..e69de29bb diff --git a/server/package.json b/server/package.json index 550c317eb..12ebf1c79 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 --env=dev -- bun dev", + "d": "ENV_FILE=.env infisical run --watch --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,6 +55,7 @@ "@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", @@ -65,6 +66,7 @@ "@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 new file mode 100644 index 000000000..f95a0b82e --- /dev/null +++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatMappings.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 000000000..f97ce011e --- /dev/null +++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts @@ -0,0 +1,32 @@ +import { AppEnv } from "@shared/index"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { initRevenuecatCli } from "../misc/initRevenuecatCli"; + +export const handleGetRevenueCatProducts = createRoute({ + handler: async (c) => { + const { org, env } = c.get("ctx"); + const revenueCatConfig = org.processor_configs?.revenuecat; + + if (!revenueCatConfig) { + return c.json({ products: [] }, 404); + } + + const projectId = + env === AppEnv.Live + ? revenueCatConfig.project_id + : revenueCatConfig.sandbox_project_id; + const apiKey = + env === AppEnv.Live + ? revenueCatConfig.api_key + : revenueCatConfig.sandbox_api_key; + + if (!projectId || !apiKey) { + return c.json({ products: [] }, 404); + } + + const rcCli = initRevenuecatCli({ projectId, apiKey }); + const products = await rcCli.listProducts(); + + return c.json(products); + }, +}); diff --git a/server/src/external/revenueCat/handlers/handleSaveRevenuecatMappings.ts b/server/src/external/revenueCat/handlers/handleSaveRevenuecatMappings.ts new file mode 100644 index 000000000..67609fa70 --- /dev/null +++ b/server/src/external/revenueCat/handlers/handleSaveRevenuecatMappings.ts @@ -0,0 +1,51 @@ +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 new file mode 100644 index 000000000..ee643acfb --- /dev/null +++ b/server/src/external/revenueCat/misc/RCMappingService.ts @@ -0,0 +1,154 @@ +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 new file mode 100644 index 000000000..45e504c66 --- /dev/null +++ b/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts @@ -0,0 +1,13 @@ +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 new file mode 100644 index 000000000..0e1d3cc89 --- /dev/null +++ b/server/src/external/revenueCat/misc/initRevenuecatCli.ts @@ -0,0 +1,54 @@ +import { decryptData } from "@server/utils/encryptUtils.js"; +import type { RevenueCatProductsResponse } from "../revenuecatTypes"; + +export type ListRevenuecatProductsResponse = { + products: { id: string; name: string }[]; +}; + +export const initRevenuecatCli = ({ + projectId, + apiKey, +}: { + projectId: string; + apiKey: string; +}) => { + let resolvedApiKey = apiKey; + + resolvedApiKey = decryptData(apiKey); + + return { + listProducts: async () => { + const url = new URL( + `https://api.revenuecat.com/v2/projects/${projectId}/products`, + ); + url.searchParams.set("limit", "20"); + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${resolvedApiKey}`, + "Content-Type": "application/json", + }, + }); + + const data = (await response.json()) as RevenueCatProductsResponse; + + // Group products by store_identifier and combine names + const productMap = new Map(); + for (const product of data.items) { + const existing = productMap.get(product.store_identifier); + if (existing) { + existing.push(product.display_name); + } else { + productMap.set(product.store_identifier, [product.display_name]); + } + } + + return { + products: Array.from(productMap.entries()).map(([id, names]) => ({ + id, + name: names.join(", "), + })), + } satisfies ListRevenuecatProductsResponse; + }, + }; +}; diff --git a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts new file mode 100644 index 000000000..37c103717 --- /dev/null +++ b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts @@ -0,0 +1,88 @@ +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 new file mode 100644 index 000000000..6f7192870 --- /dev/null +++ b/server/src/external/revenueCat/misc/revenueCatMiddleware.ts @@ -0,0 +1,65 @@ +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 new file mode 100644 index 000000000..49dc0095d --- /dev/null +++ b/server/src/external/revenueCat/revenuecatTypes.ts @@ -0,0 +1,156 @@ +// 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 new file mode 100644 index 000000000..03430ed78 --- /dev/null +++ b/server/src/external/revenueCat/revenuecatWebhookRouter.ts @@ -0,0 +1,100 @@ +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 new file mode 100644 index 000000000..2ebc713f2 --- /dev/null +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts @@ -0,0 +1,162 @@ +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 new file mode 100644 index 000000000..a97bc90e3 --- /dev/null +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatBillingIssue.ts @@ -0,0 +1,73 @@ +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 new file mode 100644 index 000000000..5479614a5 --- /dev/null +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatCancellation.ts @@ -0,0 +1,58 @@ +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 new file mode 100644 index 000000000..a8a37dd4e --- /dev/null +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatExpiration.ts @@ -0,0 +1,71 @@ +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 new file mode 100644 index 000000000..7f92ffc93 --- /dev/null +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts @@ -0,0 +1,124 @@ +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 new file mode 100644 index 000000000..8b58e899e --- /dev/null +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts @@ -0,0 +1,78 @@ +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 new file mode 100644 index 000000000..e60fd0842 --- /dev/null +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatUncancellation.ts @@ -0,0 +1,59 @@ +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 1a5005e0c..c17440dd2 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -1,6 +1,7 @@ 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"; @@ -93,6 +94,7 @@ 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 25391c474..4633ee6f7 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -1,4 +1,5 @@ import { + ACTIVE_STATUSES, type ApiVersion, CollectionMethod, type CusProduct, @@ -78,6 +79,7 @@ export const initCusProduct = ({ internalEntityId, apiVersion, quantity, + processor = ProcessorType.Stripe, }: { customer: Customer; product: FullProduct; @@ -100,6 +102,7 @@ export const initCusProduct = ({ internalEntityId?: string; apiVersion?: ApiVersion; quantity?: number; + processor?: ProcessorType; }) => { const isFuture = startsAt && startsAt > Date.now(); @@ -124,7 +127,7 @@ export const initCusProduct = ({ : CusProductStatus.Active, processor: { - type: ProcessorType.Stripe, + type: processor ?? ProcessorType.Stripe, // subscription_id: subscriptionId, // subscription_schedule_id: subscriptionScheduleId, // last_invoice_id: lastInvoiceId, @@ -237,7 +240,6 @@ export const getExistingCusProduct = async ({ internalEntityId, }: { db: DrizzleCli; - cusProducts?: FullCusProduct[]; product: FullProduct; internalCustomerId: string; @@ -284,6 +286,7 @@ export const createFullCusProduct = async ({ scenario = "default", sendWebhook = true, logger, + processorType = ProcessorType.Stripe, }: { db: DrizzleCli; attachParams: InsertCusProductParams; @@ -306,6 +309,7 @@ export const createFullCusProduct = async ({ scenario?: string; sendWebhook?: boolean; logger: any; + processorType?: ProcessorType; }) => { disableFreeTrial = attachParams.disableFreeTrial || disableFreeTrial; @@ -319,6 +323,7 @@ export const createFullCusProduct = async ({ product, internalCustomerId: customer.internal_id, internalEntityId: attachParams.internalEntityId, + // processorType, }); freeTrial = disableFreeTrial ? null : freeTrial; @@ -342,7 +347,8 @@ export const createFullCusProduct = async ({ notNullish(existingCusProduct) && !attachParams.isCustom && !existingCusProduct.is_custom && - product.version === existingCusProduct.product.version + product.version === existingCusProduct.product.version && + ACTIVE_STATUSES.includes(existingCusProduct.status) ) { await updateOneTimeCusProduct({ db, @@ -435,6 +441,7 @@ 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 bd35d6565..cb29aa61a 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -3,7 +3,9 @@ import { AttachBranch, type AttachConfig, BillingType, + cusProductToProcessorType, ErrCode, + ProcessorType, RecaseError, type UsagePriceConfig, } from "@autumn/shared"; @@ -158,6 +160,23 @@ 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, @@ -177,6 +196,10 @@ 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 03ad189f1..ae9b8bed2 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.ts @@ -1,6 +1,9 @@ import type { AttachBranch } from "@autumn/shared"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { handleCustomPaymentMethodErrors } from "../handleAttachErrors.js"; +import { + handleCustomPaymentMethodErrors, + handleExternalPSPErrors, +} from "../handleAttachErrors.js"; export const handleCheckoutErrors = ({ attachParams, @@ -13,6 +16,10 @@ 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 1ab2bf49f..e951995cc 100644 --- a/server/src/internal/customers/cancel/cancelRouter.ts +++ b/server/src/internal/customers/cancel/cancelRouter.ts @@ -1,11 +1,13 @@ 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"; @@ -63,6 +65,7 @@ cancelRouter.post("", async (req, res) => const cusProductIdMatch = customer_product_id ? cusProduct.id === customer_product_id : true; + return productIdMatch && entityMatch && cusProductIdMatch; }); @@ -74,6 +77,12 @@ 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 72048e1bc..1387f4cb6 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 d6308b1b4..e962cb7a5 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.ts @@ -11,10 +11,12 @@ 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 { @@ -24,7 +26,10 @@ export const getExistingCusProducts = ({ }; } - const curMainProduct = cusProducts.find((cp: any) => { + const curMainProduct = cusProducts.find((cp: FullCusProduct) => { + // const sameProcessor = cp.processor?.type + // ? cp.processor.type === processorType + // : true; 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 0994193dd..7a8a77c3c 100644 --- a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts +++ b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts @@ -1,21 +1,19 @@ -import type { - AppEnv, - Feature, - FullCusProduct, - FullProduct, - MigrationJob, - Organization, +import { + type AppEnv, + type Feature, + type FullCusProduct, + type FullProduct, + type MigrationJob, + type Organization, + ProcessorType, } 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 type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; -import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; -import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js"; -import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js"; +import { migrateRevenueCatCustomer } from "./migrateRevenuecatCustomer.js"; +import { migrateStripeCustomer } from "./migrateStripeCustomer.js"; export const migrateCustomer = async ({ db, @@ -68,25 +66,29 @@ export const migrateCustomer = async ({ ); for (const cusProduct of filteredCusProducts) { - 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, - }); + 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, + }); + } } return true; diff --git a/server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts b/server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts new file mode 100644 index 000000000..d4b6e4e11 --- /dev/null +++ b/server/src/internal/migrations/migrationSteps/migrateRevenuecatCustomer.ts @@ -0,0 +1,117 @@ +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 new file mode 100644 index 000000000..cf1b674f5 --- /dev/null +++ b/server/src/internal/migrations/migrationSteps/migrateStripeCustomer.ts @@ -0,0 +1,54 @@ +import { + type AppEnv, + type FullCusProduct, + type FullCustomer, + type FullProduct, +} from "@autumn/shared"; +import type { Stripe } from "stripe"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; +import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; +import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js"; +import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js"; + +export const migrateStripeCustomer = async ({ + req, + stripeCli, + fullCus, + cusProduct, + toProduct, + fromProduct, + customerId, + orgId, + env, +}: { + req: ExtendedRequest; + stripeCli: Stripe; + fullCus: FullCustomer; + cusProduct: FullCusProduct; + toProduct: FullProduct; + fromProduct: FullProduct; + customerId: string; + orgId: string; + env: AppEnv; +}) => { + const attachParams = await migrationToAttachParams({ + req, + stripeCli, + customer: fullCus, + cusProduct, + newProduct: toProduct, + }); + + await runMigrationAttach({ + ctx: req as unknown as AutumnContext, + attachParams, + fromProduct, + }); + + await deleteCachedApiCustomer({ + customerId, + orgId, + env, + }); +}; diff --git a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts new file mode 100644 index 000000000..c11a02bb1 --- /dev/null +++ b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts @@ -0,0 +1,237 @@ +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 f180b9a3e..6561fc4b8 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -1,9 +1,16 @@ 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, @@ -34,5 +41,12 @@ 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 558be7482..49b51dc13 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -22,6 +22,7 @@ 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"; @@ -200,6 +201,7 @@ export const createOrgResponse = ({ }); const vercelConnection = getVercelConfigDisplay({ org, env }); + const revenueCatConnection = getRevenueCatConfigDisplay({ org, env }); const stripeConnection = secretKeyConnected ? "secret_key" @@ -244,6 +246,15 @@ 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 25a7ce77d..a38f1ece4 100644 --- a/server/src/utils/genUtils.ts +++ b/server/src/utils/genUtils.ts @@ -54,6 +54,14 @@ 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 new file mode 100644 index 000000000..cc321a365 --- /dev/null +++ b/server/tests/external-psps/revenuecat/revenuecat-migration.test.ts @@ -0,0 +1,211 @@ +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 new file mode 100644 index 000000000..0f036f7eb --- /dev/null +++ b/server/tests/external-psps/revenuecat/revenuecat-webhooks.test.ts @@ -0,0 +1,395 @@ +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 new file mode 100644 index 000000000..51fda2298 --- /dev/null +++ b/server/tests/external-psps/revenuecat/revenuecatWebhooks.test.ts @@ -0,0 +1,395 @@ +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 new file mode 100644 index 000000000..4155fa441 --- /dev/null +++ b/server/tests/external-psps/revenuecat/utils/revenue-cat-webhook-client.ts @@ -0,0 +1,329 @@ +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 d4bba4448..54c2a5353 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -39,6 +39,7 @@ import { migrationJobs } from "../models/migrationModels/migrationJobTable.js"; import { organizationsRelations } from "../models/orgModels/orgRelations.js"; import { organizations } from "../models/orgModels/orgTable.js"; import { metadata } from "../models/otherModels/metadataTable.js"; +import { 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"; @@ -103,6 +104,7 @@ export { replaceables, rollovers, vercelResources, + revenuecatMappings as revcatMappings, // Auth user, session, diff --git a/shared/enums/ErrCode.ts b/shared/enums/ErrCode.ts index 9d692aaa8..34878ab26 100644 --- a/shared/enums/ErrCode.ts +++ b/shared/enums/ErrCode.ts @@ -167,4 +167,7 @@ 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 19edc355b..98a9694cd 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"; + type: "stripe" | "revenuecat"; id: string; }; diff --git a/shared/models/genModels/genEnums.ts b/shared/models/genModels/genEnums.ts index e8138d205..d9088d016 100644 --- a/shared/models/genModels/genEnums.ts +++ b/shared/models/genModels/genEnums.ts @@ -15,4 +15,5 @@ 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 c461c8fe1..4af3a6cd6 100644 --- a/shared/models/genModels/processorSchemas.ts +++ b/shared/models/genModels/processorSchemas.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { ProcessorType } from "./genEnums"; /** * Customer-level Vercel processor @@ -87,13 +88,40 @@ 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; @@ -101,4 +129,10 @@ 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 8b579ab57..7bbec071a 100644 --- a/shared/models/orgModels/frontendOrg.ts +++ b/shared/models/orgModels/frontendOrg.ts @@ -36,6 +36,18 @@ 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 57756bbb6..b7761620b 100644 --- a/shared/models/processorModels/processorModels.ts +++ b/shared/models/processorModels/processorModels.ts @@ -1,2 +1,3 @@ -// Vercel +// Vercel + RevenueCat +export * from "./revenuecatModels/revenuecatMappingsTable.js"; export * from "./vercelModels/vercelResourcesTable.js"; diff --git a/shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts b/shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts new file mode 100644 index 000000000..4e0da9b4a --- /dev/null +++ b/shared/models/processorModels/revenuecatModels/revenuecatMappingsTable.ts @@ -0,0 +1,30 @@ +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 391402930..3837096d1 100644 --- a/shared/models/productV2Models/productV2Models.ts +++ b/shared/models/productV2Models/productV2Models.ts @@ -29,6 +29,15 @@ 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 77101dc4d..e95907d69 100644 --- a/shared/utils/cusProductUtils/classifyCusProduct.ts +++ b/shared/utils/cusProductUtils/classifyCusProduct.ts @@ -1,4 +1,4 @@ -import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { 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 a3899a5e8..cf1bc251c 100644 --- a/shared/utils/cusProductUtils/convertCusProduct.ts +++ b/shared/utils/cusProductUtils/convertCusProduct.ts @@ -3,7 +3,11 @@ 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 { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { + CusProduct, + FullCusProduct, +} from "../../models/cusProductModels/cusProductModels.js"; +import { ProcessorType } from "../../models/genModels/genEnums.js"; import type { BillingType } from "../../models/productModels/priceModels/priceEnums.js"; import type { FullProduct } from "../../models/productModels/productModels.js"; import { cusEntMatchesEntity } from "../cusEntUtils/filterCusEntUtils.js"; @@ -155,3 +159,7 @@ 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 847e93750..e56b8d0bf 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 795cca274..e62f21b04 100644 --- a/vite/src/hooks/common/useAutumnFlags.tsx +++ b/vite/src/hooks/common/useAutumnFlags.tsx @@ -12,6 +12,7 @@ export const useAutumnFlags = () => { stripe_key: false, platform: false, vercel: false, + revenuecat: false, }); useEffect(() => { @@ -23,6 +24,7 @@ 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 @@ -31,7 +33,8 @@ export const useAutumnFlags = () => { flags.webhooks !== nextFlags.webhooks || flags.stripe_key !== nextFlags.stripe_key || flags.platform !== nextFlags.platform || - flags.vercel !== nextFlags.vercel + flags.vercel !== nextFlags.vercel || + flags.revenuecat !== nextFlags.revenuecat ) { setFlags(nextFlags); } diff --git a/vite/src/hooks/queries/revcat/useRCMappings.tsx b/vite/src/hooks/queries/revcat/useRCMappings.tsx new file mode 100644 index 000000000..96143b37c --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCMappings.tsx @@ -0,0 +1,54 @@ +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 new file mode 100644 index 000000000..43420a93b --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCProducts.tsx @@ -0,0 +1,38 @@ +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 new file mode 100644 index 000000000..94e5bf258 --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx @@ -0,0 +1,37 @@ +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 2d2542184..c3dc627e5 100644 --- a/vite/src/hooks/queries/useProductsQuery.tsx +++ b/vite/src/hooks/queries/useProductsQuery.tsx @@ -2,6 +2,9 @@ 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(); @@ -42,7 +45,7 @@ export const useProductsQuery = () => { }; return { - products: (data?.products || []) as ProductV2[], + products: data?.products || [], counts: countsData || {}, groupToDefaults: data?.groupToDefaults || {}, isLoading, diff --git a/vite/src/utils/linkUtils.ts b/vite/src/utils/linkUtils.ts index d53996431..671869372 100644 --- a/vite/src/utils/linkUtils.ts +++ b/vite/src/utils/linkUtils.ts @@ -17,6 +17,16 @@ 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 dfdf4a947..6b2e7e4d0 100644 --- a/vite/src/views/customers2/customer/CustomerActions.tsx +++ b/vite/src/views/customers2/customer/CustomerActions.tsx @@ -1,5 +1,5 @@ -import type { Feature } from "@autumn/shared"; -import { FeatureUsageType } from "@autumn/shared"; +import type { Feature, FullCusProduct } from "@autumn/shared"; +import { AppEnv, FeatureUsageType, ProcessorType } from "@autumn/shared"; import { ArrowSquareOutIcon, CaretDownIcon, @@ -20,6 +20,7 @@ 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"; @@ -28,7 +29,7 @@ import { CusService } from "@/services/customers/CusService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useEnv } from "@/utils/envUtils"; import { getBackendErr } from "@/utils/genUtils"; -import { getStripeCusLink } from "@/utils/linkUtils"; +import { getRevenueCatCusLink, 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"; @@ -44,6 +45,7 @@ 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(); @@ -141,23 +143,55 @@ export function CustomerActions() { {portalLoading ? "Opening..." : "Open customer portal"} - {stripeCustomerId && ( + {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, + )) && ( { window.open( - getStripeCusLink({ - customerId: stripeCustomerId, - env, - accountId: stripeAccount?.id, + 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", + "", + ) ?? ""), }), "_blank", ); }} className="flex gap-2" - shortcut="s" > - Open in Stripe + Open in RevenueCat )} diff --git a/vite/src/views/developer/DevView.tsx b/vite/src/views/developer/DevView.tsx index ee045c472..409733417 100644 --- a/vite/src/views/developer/DevView.tsx +++ b/vite/src/views/developer/DevView.tsx @@ -8,6 +8,7 @@ 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"; @@ -17,7 +18,7 @@ export default function DevScreen() { const { queryStates } = useAppQueryStates({ defaultTab: "api_keys" }); const tab = queryStates.tab; - const { pkey, webhooks, vercel } = useAutumnFlags(); + const { pkey, webhooks, vercel, revenuecat } = useAutumnFlags(); if (isLoading) return ; @@ -38,6 +39,8 @@ 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 new file mode 100644 index 000000000..c217d2301 --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/ConfigureRevenueCat.tsx @@ -0,0 +1,169 @@ +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 new file mode 100644 index 000000000..348270ea4 --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/ApiKeyDialog.tsx @@ -0,0 +1,81 @@ +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 new file mode 100644 index 000000000..197bf78a9 --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/ProjectIdDialog.tsx @@ -0,0 +1,81 @@ +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 new file mode 100644 index 000000000..100b1f245 --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatConnectionCard.tsx @@ -0,0 +1,101 @@ +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 new file mode 100644 index 000000000..19adea4df --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatMappingSheet.tsx @@ -0,0 +1,313 @@ +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 new file mode 100644 index 000000000..c405faa32 --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookSecret.tsx @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..8897ba1f9 --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookUrl.tsx @@ -0,0 +1,52 @@ +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 503a7bd32..5a80c39ee 100644 --- a/vite/src/views/main-sidebar/MainSidebar.tsx +++ b/vite/src/views/main-sidebar/MainSidebar.tsx @@ -14,6 +14,7 @@ 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"; @@ -34,6 +35,7 @@ export const buildDevSubTabs = ({ flags: { webhooks: boolean; vercel: boolean; + revenuecat: boolean; }; }) => { return [ @@ -56,6 +58,16 @@ export const buildDevSubTabs = ({ }, ] : []), + ...(flags.revenuecat + ? [ + { + title: "RevenueCat", + value: "revenuecat", + // icon: , + icon: , + }, + ] + : []), ...(flags.webhooks ? [ {