Revert "feat: 🎸 revenue cat"
This commit is contained in:
24
bun.lock
24
bun.lock
@@ -65,7 +65,6 @@
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
||||
"@opentelemetry/sdk-trace-node": "^2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@puzzmo/revenue-cat-webhook-types": "^1.1.0",
|
||||
"@react-email/components": "^0.0.42",
|
||||
"@sentry/bun": "catalog:",
|
||||
"@supabase/supabase-js": "^2.46.2",
|
||||
@@ -76,7 +75,6 @@
|
||||
"@upstash/redis": "^1.35.6",
|
||||
"@vercel/sdk": "^1.17.0",
|
||||
"ai": "^4.3.10",
|
||||
"arctic": "^3.7.0",
|
||||
"autumn-js": "^0.1.8",
|
||||
"axios": "^1.8.3",
|
||||
"better-auth": "^1.2.9",
|
||||
@@ -967,16 +965,6 @@
|
||||
|
||||
"@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
|
||||
|
||||
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
|
||||
|
||||
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
||||
|
||||
"@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
|
||||
@@ -1011,8 +999,6 @@
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.11.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ=="],
|
||||
|
||||
"@puzzmo/revenue-cat-webhook-types": ["@puzzmo/revenue-cat-webhook-types@1.1.0", "", {}, "sha512-ChEa8v4dHUlZyQ7mt5i8x2lBcZ7STRSe3D+YO+DhXicJI0Thjcy23Ehbn9l071bxgmbeH7OErOphJrCLpq/SEg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
@@ -1667,8 +1653,6 @@
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"arctic": ["arctic@3.7.0", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="],
|
||||
|
||||
"arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
@@ -3205,11 +3189,8 @@
|
||||
|
||||
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"@autumn/server/@clickhouse/client": ["@clickhouse/client@1.14.0", "", { "dependencies": { "@clickhouse/client-common": "1.14.0" } }, "sha512-co2spjR7wZoZ3Ck0H/jv76bpiuO3oJHtOmq9/gxFiod2DcT9NFg01u/hXcG8MJFnEJuMB6e3vGqS6IOnLwHqRw=="],
|
||||
|
||||
>>>>>>> main
|
||||
"@autumn/vite/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="],
|
||||
|
||||
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||
@@ -3692,8 +3673,6 @@
|
||||
|
||||
"@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.1", "@opentelemetry/core": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-UhdbPF19pMpBtCWYP5lHbTogLWx9N0EBxtdagvkn5YtsAnCBZzL7SjktG+ZmupRgifsHMjwUaCCaVmqGfSADmA=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
|
||||
"@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg=="],
|
||||
@@ -3984,11 +3963,8 @@
|
||||
|
||||
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"@autumn/server/@clickhouse/client/@clickhouse/client-common": ["@clickhouse/client-common@1.14.0", "", {}, "sha512-CyUcv2iCkZ1A++vmOSufYRpHR3aAWVfbrWed7ATzf0yyx/BW/2SEqlL07vBpSRa3BIkQe/DSOHVv8JkWZpUOwQ=="],
|
||||
|
||||
>>>>>>> main
|
||||
"@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"scripts": {
|
||||
"dev": "bun scripts/dev.ts",
|
||||
"vite:build": "bun -F @autumn/vite build:bun",
|
||||
"d": "ENV_FILE=.env infisical run --watch --env=dev -- bun scripts/dev.ts",
|
||||
"d": "ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts",
|
||||
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts",
|
||||
"setup": "node scripts/setup/setup.js",
|
||||
"setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
'server/tests/attach/entities' \
|
||||
'server/tests/attach/basic' \
|
||||
'server/tests/attach/upgrade' \
|
||||
'server/tests/attach/downgrade' \
|
||||
@@ -14,9 +15,5 @@ BUN_PARALLEL_COMPACT \
|
||||
'server/tests/billing/cancel' \
|
||||
'server/tests/billing/cancel/add-ons' \
|
||||
'server/tests/renew' \
|
||||
--max=6
|
||||
--max=6 \
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
'server/tests/attach/entities' \
|
||||
--max=6
|
||||
# 'server/tests/external-psps/revenuecat' \
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"email": "email dev -p 3001",
|
||||
"d": "ENV_FILE=.env infisical run --watch --env=dev -- bun dev",
|
||||
"d": "ENV_FILE=.env infisical run --env=dev -- bun dev",
|
||||
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun dev",
|
||||
"w": "ENV_FILE=.env infisical run --env=dev -- bun workers:dev",
|
||||
"c": "ENV_FILE=.env infisical run --env=dev -- bun cron",
|
||||
@@ -55,7 +55,6 @@
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
||||
"@opentelemetry/sdk-trace-node": "^2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@puzzmo/revenue-cat-webhook-types": "^1.1.0",
|
||||
"@react-email/components": "^0.0.42",
|
||||
"@sentry/bun": "catalog:",
|
||||
"@supabase/supabase-js": "^2.46.2",
|
||||
@@ -66,7 +65,6 @@
|
||||
"@upstash/redis": "^1.35.6",
|
||||
"@vercel/sdk": "^1.17.0",
|
||||
"ai": "^4.3.10",
|
||||
"arctic": "^3.7.0",
|
||||
"autumn-js": "^0.1.8",
|
||||
"axios": "^1.8.3",
|
||||
"better-auth": "^1.2.9",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { RCMappingService } from "../misc/RCMappingService";
|
||||
|
||||
export const handleGetRCMappings = createRoute({
|
||||
handler: async (c) => {
|
||||
const { db, org, env } = c.get("ctx");
|
||||
|
||||
const mappings = await RCMappingService.getAll({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
return c.json({ mappings });
|
||||
},
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { AppEnv } from "@shared/index";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { initRevenuecatCli } from "../misc/initRevenuecatCli";
|
||||
|
||||
export const handleGetRevenueCatProducts = createRoute({
|
||||
handler: async (c) => {
|
||||
const { org, env } = c.get("ctx");
|
||||
const revenueCatConfig = org.processor_configs?.revenuecat;
|
||||
|
||||
if (!revenueCatConfig) {
|
||||
return c.json({ products: [] }, 404);
|
||||
}
|
||||
|
||||
const projectId =
|
||||
env === AppEnv.Live
|
||||
? revenueCatConfig.project_id
|
||||
: revenueCatConfig.sandbox_project_id;
|
||||
const apiKey =
|
||||
env === AppEnv.Live
|
||||
? revenueCatConfig.api_key
|
||||
: revenueCatConfig.sandbox_api_key;
|
||||
|
||||
if (!projectId || !apiKey) {
|
||||
return c.json({ products: [] }, 404);
|
||||
}
|
||||
|
||||
const rcCli = initRevenuecatCli({ projectId, apiKey });
|
||||
const products = await rcCli.listProducts();
|
||||
|
||||
return c.json(products);
|
||||
},
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { RCMappingService } from "../misc/RCMappingService";
|
||||
|
||||
const MappingSchema = z.object({
|
||||
autumn_product_id: z.string(),
|
||||
revenuecat_product_ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const handleSaveRCMappings = createRoute({
|
||||
body: z.object({
|
||||
mappings: z.array(MappingSchema),
|
||||
}),
|
||||
handler: async (c) => {
|
||||
const { db, org, env } = c.get("ctx");
|
||||
const { mappings } = c.req.valid("json");
|
||||
|
||||
// Process each mapping
|
||||
for (const mapping of mappings) {
|
||||
if (mapping.revenuecat_product_ids.length > 0) {
|
||||
// Upsert mapping if it has products
|
||||
await RCMappingService.upsert({
|
||||
db,
|
||||
data: {
|
||||
org_id: org.id,
|
||||
env,
|
||||
autumn_product_id: mapping.autumn_product_id,
|
||||
revenuecat_product_ids: mapping.revenuecat_product_ids,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Delete mapping if no products assigned
|
||||
await RCMappingService.delete({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
autumnProductId: mapping.autumn_product_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated mappings
|
||||
const updatedMappings = await RCMappingService.getAll({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
return c.json({ mappings: updatedMappings });
|
||||
},
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
type RevenuecatMapping,
|
||||
revenuecatMappings,
|
||||
} from "@shared/index";
|
||||
import { and, arrayContains, eq } from "drizzle-orm";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle";
|
||||
|
||||
export class RCMappingService {
|
||||
/**
|
||||
* Find the Autumn product ID that maps to a given RevenueCat product ID
|
||||
*/
|
||||
static async getAutumnProductId({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
revenuecatProductId,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
revenuecatProductId: string;
|
||||
}): Promise<string | null> {
|
||||
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<RevenuecatMapping>;
|
||||
}) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { AppEnv, type Organization } from "@autumn/shared";
|
||||
|
||||
export const getRevenuecatWebhookSecret = ({
|
||||
org,
|
||||
env,
|
||||
}: {
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
return env === AppEnv.Sandbox
|
||||
? org.processor_configs?.revenuecat?.sandbox_webhook_secret
|
||||
: org.processor_configs?.revenuecat?.webhook_secret;
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { decryptData } from "@server/utils/encryptUtils.js";
|
||||
import type { RevenueCatProductsResponse } from "../revenuecatTypes";
|
||||
|
||||
export type ListRevenuecatProductsResponse = {
|
||||
products: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
export const initRevenuecatCli = ({
|
||||
projectId,
|
||||
apiKey,
|
||||
}: {
|
||||
projectId: string;
|
||||
apiKey: string;
|
||||
}) => {
|
||||
let resolvedApiKey = apiKey;
|
||||
|
||||
resolvedApiKey = decryptData(apiKey);
|
||||
|
||||
return {
|
||||
listProducts: async () => {
|
||||
const url = new URL(
|
||||
`https://api.revenuecat.com/v2/projects/${projectId}/products`,
|
||||
);
|
||||
url.searchParams.set("limit", "20");
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${resolvedApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = (await response.json()) as RevenueCatProductsResponse;
|
||||
|
||||
// Group products by store_identifier and combine names
|
||||
const productMap = new Map<string, string[]>();
|
||||
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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import {
|
||||
ErrCode,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type FullProduct,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
} from "@shared/index";
|
||||
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
import { getOrCreateCustomer } from "../../../internal/customers/cusUtils/getOrCreateCustomer";
|
||||
|
||||
/**
|
||||
* Resolves a RevenueCat product ID to an Autumn product and fetches the customer.
|
||||
* Throws if product mapping, product, customer is not found, or customer has non-RevenueCat products.
|
||||
*/
|
||||
export const resolveRevenuecatResources = async ({
|
||||
ctx,
|
||||
revenuecatProductId,
|
||||
customerId,
|
||||
autoCreateCustomer = false,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
revenuecatProductId: string;
|
||||
customerId: string;
|
||||
autoCreateCustomer?: boolean;
|
||||
}): Promise<{
|
||||
product: FullProduct;
|
||||
customer: FullCustomer;
|
||||
cusProducts: FullCusProduct[];
|
||||
}> => {
|
||||
const { db, org, env } = ctx;
|
||||
|
||||
// Look up Autumn product ID from RevenueCat mapping
|
||||
const autumnProductId = await RCMappingService.getAutumnProductId({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
revenuecatProductId,
|
||||
});
|
||||
|
||||
if (!autumnProductId) {
|
||||
throw new RecaseError({
|
||||
message: `No Autumn product mapped to RevenueCat product: ${revenuecatProductId}`,
|
||||
code: ErrCode.ProductNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const [product, customer] = await Promise.all([
|
||||
ProductService.getFull({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
idOrInternalId: autumnProductId,
|
||||
}),
|
||||
autoCreateCustomer
|
||||
? getOrCreateCustomer({
|
||||
ctx,
|
||||
customerId,
|
||||
})
|
||||
: CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (
|
||||
customer.customer_products.some(
|
||||
(cp) => cp.processor?.type !== ProcessorType.RevenueCat,
|
||||
)
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"Customer already has a product from a different processor than RevenueCat.",
|
||||
});
|
||||
}
|
||||
|
||||
const cusProducts = customer.customer_products.filter(
|
||||
(cp) => cp.processor?.type === ProcessorType.RevenueCat,
|
||||
);
|
||||
|
||||
return { product, customer, cusProducts };
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { AppEnv } from "@shared/models/genModels/genEnums";
|
||||
import type { Organization } from "@shared/models/orgModels/orgTable";
|
||||
import chalk from "chalk";
|
||||
import type { Context, Next } from "hono";
|
||||
import type { Logger } from "@/external/logtail/logtailUtils";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv";
|
||||
import { OrgService } from "@/internal/orgs/OrgService";
|
||||
|
||||
export const revenuecatSeederMiddleware = async (
|
||||
c: Context<HonoEnv>,
|
||||
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<HonoEnv>,
|
||||
next: Next,
|
||||
) => {
|
||||
const { logger, org } = c.get("ctx");
|
||||
const body = await c.req.json();
|
||||
|
||||
logRevCatWebhook({ logger, org, event: body.event });
|
||||
|
||||
await next();
|
||||
};
|
||||
156
server/src/external/revenueCat/revenuecatTypes.ts
vendored
156
server/src/external/revenueCat/revenuecatTypes.ts
vendored
@@ -1,156 +0,0 @@
|
||||
// RevenueCat Webhook Event Types
|
||||
|
||||
export type RevCatExperiment = {
|
||||
experiment_id: string;
|
||||
experiment_variant: string;
|
||||
enrolled_at_ms: number;
|
||||
};
|
||||
|
||||
export type RevCatSubscriberAttribute = {
|
||||
updated_at_ms: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type RevCatEvent = {
|
||||
event_timestamp_ms: number;
|
||||
product_id: string;
|
||||
period_type: "NORMAL" | "INTRO" | "TRIAL";
|
||||
purchased_at_ms: number;
|
||||
expiration_at_ms: number;
|
||||
environment: "PRODUCTION" | "SANDBOX";
|
||||
entitlement_id: string | null;
|
||||
entitlement_ids: string[];
|
||||
presented_offering_id: string | null;
|
||||
transaction_id: string;
|
||||
original_transaction_id: string;
|
||||
is_family_share: boolean;
|
||||
country_code: string;
|
||||
app_user_id: string;
|
||||
aliases: string[];
|
||||
original_app_user_id: string;
|
||||
currency: string;
|
||||
price: number;
|
||||
price_in_purchased_currency: number;
|
||||
subscriber_attributes: {
|
||||
[key: string]: RevCatSubscriberAttribute;
|
||||
};
|
||||
store:
|
||||
| "APP_STORE"
|
||||
| "PLAY_STORE"
|
||||
| "STRIPE"
|
||||
| "MAC_APP_STORE"
|
||||
| "AMAZON"
|
||||
| "PROMOTIONAL"
|
||||
| "UNKNOWN_STORE";
|
||||
takehome_percentage: number;
|
||||
tax_percentage: number;
|
||||
commission_percentage: number;
|
||||
offer_code: string | null;
|
||||
type:
|
||||
| "INITIAL_PURCHASE"
|
||||
| "RENEWAL"
|
||||
| "NON_RENEWING_PURCHASE"
|
||||
| "PRODUCT_CHANGE"
|
||||
| "CANCELLATION"
|
||||
| "UNCANCELLATION"
|
||||
| "BILLING_ISSUE"
|
||||
| "SUBSCRIPTION_PAUSED"
|
||||
| "SUBSCRIPTION_PAUSED_DENIED"
|
||||
| "SUBSCRIPTION_REACTIVATED"
|
||||
| "REFUND"
|
||||
| "RENEWAL_EXTENDED"
|
||||
| "EXPIRATION"
|
||||
| "RENEWAL_OVERRIDE"
|
||||
| "REVENUE_RECOGNITION"
|
||||
| "TRANSFER"
|
||||
| "UNKNOWN";
|
||||
id: string;
|
||||
app_id: string;
|
||||
experiments: RevCatExperiment[];
|
||||
};
|
||||
|
||||
export type RevCatWebhookPayload = {
|
||||
event: RevCatEvent;
|
||||
api_version: string;
|
||||
};
|
||||
|
||||
export type RevenueCatOfferings = {
|
||||
object: "list";
|
||||
items: RevenueCatOffering[];
|
||||
next_page: string | null;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type RevenueCatOffering = {
|
||||
object: "offering";
|
||||
id: string;
|
||||
lookup_key: string | null;
|
||||
display_name: string;
|
||||
is_current: boolean;
|
||||
created_at: number;
|
||||
project_id: string;
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
packages: RevenueCatOfferingPackageList;
|
||||
};
|
||||
|
||||
export type RevenueCatOfferingPackageList = {
|
||||
object: "list";
|
||||
items: RevenueCatOfferingPackage[];
|
||||
next_page: string | null;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type RevenueCatOfferingPackage = {
|
||||
object: "package";
|
||||
id: string;
|
||||
lookup_key: string | null;
|
||||
display_name: string;
|
||||
position: number;
|
||||
created_at: number;
|
||||
products: RevenueCatOfferingProductList;
|
||||
};
|
||||
|
||||
export type RevenueCatOfferingProductList = {
|
||||
object: "list";
|
||||
items: RevenueCatOfferingProductItem[];
|
||||
next_page: string | null;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type RevenueCatOfferingProductItem = {
|
||||
product: Record<string, unknown>;
|
||||
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;
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import type {
|
||||
Webhook,
|
||||
WebhookBillingIssue,
|
||||
WebhookCancellation,
|
||||
WebhookExpiration,
|
||||
WebhookInitialPurchase,
|
||||
WebhookNonRenewingPurchase,
|
||||
WebhookRenewal,
|
||||
WebhookUnCancellation,
|
||||
} from "@puzzmo/revenue-cat-webhook-types";
|
||||
import { type Context, Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv";
|
||||
import { getRevenuecatWebhookSecret } from "./misc/getRevenuecatWebhookSecret";
|
||||
import {
|
||||
revenuecatLogMiddleware,
|
||||
revenuecatSeederMiddleware,
|
||||
} from "./misc/revenueCatMiddleware";
|
||||
import { handleRenewal } from "./webhookHandlers/handleRevenucatRenewal";
|
||||
import { handleBillingIssue } from "./webhookHandlers/handleRevenuecatBillingIssue";
|
||||
import { handleCancellation } from "./webhookHandlers/handleRevenuecatCancellation";
|
||||
import { handleExpiration } from "./webhookHandlers/handleRevenuecatExpiration";
|
||||
import { handleInitialPurchase } from "./webhookHandlers/handleRevenuecatInitialPurchase";
|
||||
import { handleNonRenewingPurchase } from "./webhookHandlers/handleRevenuecatNonRenewingPurchase";
|
||||
import { handleUncancellation } from "./webhookHandlers/handleRevenuecatUncancellation";
|
||||
|
||||
export const revenuecatWebhookRouter = new Hono<HonoEnv>();
|
||||
|
||||
revenuecatWebhookRouter.post(
|
||||
"/:orgId/:env",
|
||||
revenuecatSeederMiddleware,
|
||||
revenuecatLogMiddleware,
|
||||
async (c: Context<HonoEnv>) => {
|
||||
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.
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { WebhookRenewal } from "@puzzmo/revenue-cat-webhook-types";
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
AttachScenario,
|
||||
CusProductStatus,
|
||||
cusProductToPrices,
|
||||
ProcessorType,
|
||||
} from "@shared/index";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
|
||||
import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
|
||||
import {
|
||||
attachToInsertParams,
|
||||
isProductUpgrade,
|
||||
} from "@/internal/products/productUtils";
|
||||
import { isMainProduct } from "@/internal/products/productUtils/classifyProduct";
|
||||
|
||||
export const handleRenewal = async ({
|
||||
event,
|
||||
ctx,
|
||||
}: {
|
||||
event: WebhookRenewal;
|
||||
ctx: AutumnContext;
|
||||
}) => {
|
||||
const { db, org, env, logger, features } = ctx;
|
||||
const { product_id, app_user_id } = event;
|
||||
|
||||
const { product, customer, cusProducts } = await resolveRevenuecatResources({
|
||||
ctx,
|
||||
revenuecatProductId: product_id,
|
||||
customerId: app_user_id,
|
||||
});
|
||||
|
||||
const { curSameProduct, curMainProduct } = getExistingCusProducts({
|
||||
product,
|
||||
cusProducts,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// If same product exists and is active, this is just a renewal - nothing to do
|
||||
if (curSameProduct && ACTIVE_STATUSES.includes(curSameProduct.status)) {
|
||||
logger.info(
|
||||
`Renewal for existing active product ${product.id}, no action needed`,
|
||||
);
|
||||
return { success: true };
|
||||
} else if (curSameProduct && curSameProduct.status === CusProductStatus.PastDue) {
|
||||
logger.info(
|
||||
`Renewal for existing past due product ${product.id}, marking as active`,
|
||||
);
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: curSameProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Active,
|
||||
},
|
||||
});
|
||||
logger.info(`Marked past due product as active: ${curSameProduct.id}`);
|
||||
await deleteCachedApiCustomer({
|
||||
customerId: customer.id ?? "",
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Check if this is an upgrade (renewing to a different/better product)
|
||||
const isNewProductMain = isMainProduct({ product, prices: product.prices });
|
||||
let scenario = AttachScenario.New;
|
||||
|
||||
if (curMainProduct && isNewProductMain) {
|
||||
const curPrices = cusProductToPrices({ cusProduct: curMainProduct });
|
||||
const newPrices = product.prices;
|
||||
|
||||
const isUpgrade = isProductUpgrade({
|
||||
prices1: curPrices,
|
||||
prices2: newPrices,
|
||||
});
|
||||
|
||||
scenario = isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade;
|
||||
|
||||
logger.info(
|
||||
`Renewal with ${isUpgrade ? "upgrade" : "downgrade"}: ${curMainProduct.product.id} -> ${product.id}`,
|
||||
);
|
||||
|
||||
// Expire old cus_product
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: curMainProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
ended_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Expired old cus_product: ${curMainProduct.id}`);
|
||||
} else if (curSameProduct) {
|
||||
// Reactivate the same product if it was expired/cancelled
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: curSameProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Active,
|
||||
canceled_at: null,
|
||||
ended_at: null,
|
||||
canceled: false,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Reactivated cus_product: ${curSameProduct.id}`);
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId: customer.id ?? "",
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Create new cus_product for upgrade or new product
|
||||
await createFullCusProduct({
|
||||
db,
|
||||
logger,
|
||||
scenario,
|
||||
processorType: ProcessorType.RevenueCat,
|
||||
attachParams: attachToInsertParams(
|
||||
{
|
||||
customer,
|
||||
products: [product],
|
||||
prices: product.prices,
|
||||
entitlements: product.entitlements,
|
||||
entities: customer.entities || [],
|
||||
org,
|
||||
stripeCli: createStripeCli({ org, env }),
|
||||
now,
|
||||
paymentMethod: null,
|
||||
freeTrial: null,
|
||||
optionsList: [],
|
||||
cusProducts,
|
||||
replaceables: [],
|
||||
features,
|
||||
},
|
||||
product,
|
||||
),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Created cus_product for ${product.id} with scenario: ${scenario} (renewal)`,
|
||||
);
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId: customer.id ?? "",
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { WebhookBillingIssue } from "@puzzmo/revenue-cat-webhook-types";
|
||||
import { RecaseError } from "@shared/api/errors/base/RecaseError";
|
||||
import { ErrCode } from "@shared/enums/ErrCode";
|
||||
import { CusProductStatus } from "@shared/models/cusProductModels/cusProductEnums";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
CusProductService,
|
||||
} from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
|
||||
import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
|
||||
import { resolveRevenuecatResources } from "../misc/resolveRevenuecatResources";
|
||||
|
||||
export const handleBillingIssue = async ({
|
||||
event,
|
||||
ctx,
|
||||
}: {
|
||||
event: WebhookBillingIssue;
|
||||
ctx: AutumnContext;
|
||||
}) => {
|
||||
const { db, logger, org, env } = ctx;
|
||||
const { product_id, app_user_id } = event;
|
||||
|
||||
const { product, customer, cusProducts } = await resolveRevenuecatResources({
|
||||
ctx,
|
||||
revenuecatProductId: product_id,
|
||||
customerId: app_user_id,
|
||||
});
|
||||
|
||||
const { curSameProduct } = getExistingCusProducts({
|
||||
product,
|
||||
cusProducts,
|
||||
});
|
||||
|
||||
if (!curSameProduct) {
|
||||
throw new RecaseError({
|
||||
message: "Cus product not found",
|
||||
code: ErrCode.CusProductNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (curSameProduct.status === CusProductStatus.PastDue) {
|
||||
logger.info(
|
||||
`Billing issue for existing past due product ${product.id}, no action needed`,
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (ACTIVE_STATUSES.includes(curSameProduct.status)) {
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: curSameProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.PastDue,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId: customer.id ?? "",
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
throw new RecaseError({
|
||||
message: "Cus product is not in a valid status to be billed",
|
||||
code: ErrCode.NoActiveCusProducts,
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { WebhookCancellation } from "@puzzmo/revenue-cat-webhook-types";
|
||||
import { ErrCode, RecaseError } from "@shared/index";
|
||||
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
|
||||
import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
|
||||
|
||||
export const handleCancellation = async ({
|
||||
event,
|
||||
ctx,
|
||||
}: {
|
||||
event: WebhookCancellation;
|
||||
ctx: AutumnContext;
|
||||
}) => {
|
||||
const { db, org, env, logger } = ctx;
|
||||
const { product_id, original_app_user_id, app_user_id, expiration_at_ms } =
|
||||
event;
|
||||
|
||||
const { product, cusProducts } = await resolveRevenuecatResources({
|
||||
ctx,
|
||||
revenuecatProductId: product_id,
|
||||
customerId: original_app_user_id ?? app_user_id,
|
||||
});
|
||||
|
||||
const { curSameProduct } = getExistingCusProducts({
|
||||
product,
|
||||
cusProducts,
|
||||
});
|
||||
|
||||
if (!curSameProduct) {
|
||||
throw new RecaseError({
|
||||
message: "Cus product not found",
|
||||
code: ErrCode.CusProductNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: curSameProduct.id,
|
||||
updates: {
|
||||
canceled_at: Date.now(),
|
||||
canceled: true,
|
||||
ended_at: expiration_at_ms,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Marked cus_product ${curSameProduct.id} as cancelled, will expire at ${expiration_at_ms}`,
|
||||
);
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId: original_app_user_id ?? app_user_id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { WebhookExpiration } from "@puzzmo/revenue-cat-webhook-types";
|
||||
import { CusProductStatus, ErrCode, RecaseError } from "@shared/index";
|
||||
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { activateDefaultProduct } from "@/internal/customers/cusProducts/cusProductUtils";
|
||||
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
|
||||
import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
|
||||
import { isOneOff } from "@/internal/products/productUtils";
|
||||
|
||||
export const handleExpiration = async ({
|
||||
event,
|
||||
ctx,
|
||||
}: {
|
||||
event: WebhookExpiration;
|
||||
ctx: AutumnContext;
|
||||
}) => {
|
||||
const { db, org, env, logger } = ctx;
|
||||
const { product_id, original_app_user_id, app_user_id } = event;
|
||||
|
||||
const { product, customer, cusProducts } = await resolveRevenuecatResources({
|
||||
ctx,
|
||||
revenuecatProductId: product_id,
|
||||
customerId: original_app_user_id ?? app_user_id,
|
||||
});
|
||||
|
||||
const { curSameProduct } = getExistingCusProducts({
|
||||
product,
|
||||
cusProducts,
|
||||
});
|
||||
|
||||
if (!curSameProduct) {
|
||||
throw new RecaseError({
|
||||
message: "Cus product not found",
|
||||
code: ErrCode.CusProductNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Expire the cus_product
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: curSameProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
ended_at: event.expiration_at_ms,
|
||||
canceled: !!curSameProduct.canceled_at,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Expired cus_product: ${curSameProduct.id}`);
|
||||
|
||||
// Activate default product if this was a main product
|
||||
const isMain = !product.is_add_on;
|
||||
const isOneOffProduct = isOneOff(product.prices);
|
||||
|
||||
if (isMain && !isOneOffProduct) {
|
||||
await activateDefaultProduct({
|
||||
ctx,
|
||||
productGroup: product.group,
|
||||
fullCus: customer,
|
||||
curCusProduct: curSameProduct,
|
||||
});
|
||||
}
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId: event.original_app_user_id ?? event.app_user_id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { WebhookInitialPurchase } from "@puzzmo/revenue-cat-webhook-types";
|
||||
import {
|
||||
AttachScenario,
|
||||
CusProductStatus,
|
||||
cusProductToPrices,
|
||||
ErrCode,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
} from "@shared/index";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
|
||||
import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
|
||||
import {
|
||||
attachToInsertParams,
|
||||
isProductUpgrade,
|
||||
} from "@/internal/products/productUtils";
|
||||
import { isMainProduct } from "@/internal/products/productUtils/classifyProduct";
|
||||
|
||||
export const handleInitialPurchase = async ({
|
||||
event,
|
||||
ctx,
|
||||
}: {
|
||||
event: WebhookInitialPurchase;
|
||||
ctx: AutumnContext;
|
||||
}) => {
|
||||
const { db, org, env, logger, features } = ctx;
|
||||
const { product_id, app_user_id } = event;
|
||||
|
||||
const { product, customer, cusProducts } = await resolveRevenuecatResources({
|
||||
ctx,
|
||||
revenuecatProductId: product_id,
|
||||
customerId: app_user_id,
|
||||
autoCreateCustomer: true,
|
||||
});
|
||||
|
||||
const { curSameProduct, curMainProduct } = getExistingCusProducts({
|
||||
product,
|
||||
cusProducts,
|
||||
});
|
||||
|
||||
// If same product already exists, skip
|
||||
if (curSameProduct) {
|
||||
throw new RecaseError({
|
||||
message: `[handleInitialPurchase] Customer ${customer.id} already has product ${product.id}`,
|
||||
code: ErrCode.CustomerAlreadyHasProduct,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let scenario = AttachScenario.New;
|
||||
|
||||
// Handle upgrade/downgrade (only when both are main products)
|
||||
const isNewProductMain = isMainProduct({ product, prices: product.prices });
|
||||
|
||||
if (curMainProduct && isNewProductMain) {
|
||||
const curPrices = cusProductToPrices({ cusProduct: curMainProduct });
|
||||
const newPrices = product.prices;
|
||||
|
||||
const isUpgrade = isProductUpgrade({
|
||||
prices1: curPrices,
|
||||
prices2: newPrices,
|
||||
});
|
||||
|
||||
scenario = isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade;
|
||||
|
||||
logger.info(
|
||||
`${isUpgrade ? "Upgrade" : "Downgrade"} detected: ${curMainProduct.product.id} -> ${product.id}`,
|
||||
);
|
||||
|
||||
// Expire old cus_product
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: curMainProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
ended_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Expired old cus_product: ${curMainProduct.id}`);
|
||||
}
|
||||
|
||||
// Create new cus_product
|
||||
await createFullCusProduct({
|
||||
db,
|
||||
logger,
|
||||
scenario,
|
||||
processorType: ProcessorType.RevenueCat,
|
||||
attachParams: attachToInsertParams(
|
||||
{
|
||||
customer,
|
||||
products: [product],
|
||||
prices: product.prices,
|
||||
entitlements: product.entitlements,
|
||||
entities: customer.entities || [],
|
||||
org,
|
||||
stripeCli: createStripeCli({ org, env }),
|
||||
now,
|
||||
paymentMethod: null,
|
||||
freeTrial: null,
|
||||
optionsList: [],
|
||||
cusProducts,
|
||||
replaceables: [],
|
||||
features,
|
||||
},
|
||||
product,
|
||||
),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Created cus_product for ${product.id} with scenario: ${scenario}`,
|
||||
);
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId: customer.id ?? "",
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { WebhookNonRenewingPurchase } from "@puzzmo/revenue-cat-webhook-types";
|
||||
import {
|
||||
AttachScenario,
|
||||
ErrCode,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
} from "@shared/index";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct";
|
||||
import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
|
||||
import { attachToInsertParams } from "@/internal/products/productUtils";
|
||||
import { oneOffOrAddOn } from "@/internal/products/productUtils/classifyProduct";
|
||||
|
||||
export const handleNonRenewingPurchase = async ({
|
||||
event,
|
||||
ctx,
|
||||
}: {
|
||||
event: WebhookNonRenewingPurchase;
|
||||
ctx: AutumnContext;
|
||||
}) => {
|
||||
const { db, org, env, logger, features } = ctx;
|
||||
|
||||
const { product, customer, cusProducts } = await resolveRevenuecatResources({
|
||||
ctx,
|
||||
revenuecatProductId: event.product_id,
|
||||
customerId: event.app_user_id,
|
||||
});
|
||||
|
||||
if (!oneOffOrAddOn({ product, prices: product.prices })) {
|
||||
throw new RecaseError({
|
||||
message: "Non-renewing purchase is not a one-off or add-on",
|
||||
code: ErrCode.InvalidProductItem,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const scenario = AttachScenario.New;
|
||||
|
||||
// Create new cus_product
|
||||
await createFullCusProduct({
|
||||
db,
|
||||
logger,
|
||||
scenario,
|
||||
processorType: ProcessorType.RevenueCat,
|
||||
attachParams: attachToInsertParams(
|
||||
{
|
||||
customer,
|
||||
products: [product],
|
||||
prices: product.prices,
|
||||
entitlements: product.entitlements,
|
||||
entities: customer.entities || [],
|
||||
org,
|
||||
stripeCli: createStripeCli({ org, env }),
|
||||
now,
|
||||
paymentMethod: null,
|
||||
freeTrial: null,
|
||||
optionsList: [],
|
||||
cusProducts,
|
||||
replaceables: [],
|
||||
features,
|
||||
},
|
||||
product,
|
||||
),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Created cus_product for ${product.id} with scenario: ${scenario}`,
|
||||
);
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId: customer.id ?? "",
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { WebhookUnCancellation } from "@puzzmo/revenue-cat-webhook-types";
|
||||
import {
|
||||
CusProductStatus,
|
||||
ErrCode,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
} from "@shared/index";
|
||||
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
|
||||
|
||||
export const handleUncancellation = async ({
|
||||
event,
|
||||
ctx,
|
||||
}: {
|
||||
event: WebhookUnCancellation;
|
||||
ctx: AutumnContext;
|
||||
}) => {
|
||||
const { db, org, env } = ctx;
|
||||
const { product_id, original_app_user_id, app_user_id } = event;
|
||||
|
||||
const { product, cusProducts } = await resolveRevenuecatResources({
|
||||
ctx,
|
||||
revenuecatProductId: product_id,
|
||||
customerId: original_app_user_id ?? app_user_id,
|
||||
});
|
||||
|
||||
const cusProduct = cusProducts.find(
|
||||
(cp) =>
|
||||
cp.internal_product_id === product.internal_id &&
|
||||
cp.processor?.type === ProcessorType.RevenueCat,
|
||||
);
|
||||
|
||||
if (cusProduct) {
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: cusProduct.id,
|
||||
updates: {
|
||||
canceled_at: null,
|
||||
canceled: false,
|
||||
ended_at: null,
|
||||
status: CusProductStatus.Active,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId: original_app_user_id ?? app_user_id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
throw new RecaseError({
|
||||
message: "Cus product not found",
|
||||
code: ErrCode.CusProductNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getRequestListener } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { revenuecatWebhookRouter } from "./external/revenueCat/revenuecatWebhookRouter.js";
|
||||
import { vercelWebhookRouter } from "./external/vercel/vercelWebhookRouter.js";
|
||||
import { handleConnectWebhook } from "./external/webhooks/connectWebhookRouter.js";
|
||||
import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js";
|
||||
@@ -94,7 +93,6 @@ export const createHonoApp = () => {
|
||||
// Webhook routes
|
||||
app.post("/webhooks/connect/:env", handleConnectWebhook);
|
||||
app.route("/webhooks/vercel", vercelWebhookRouter);
|
||||
app.route("/webhooks/revenuecat", revenuecatWebhookRouter);
|
||||
|
||||
// API Middleware
|
||||
app.route("/v1", apiRouter);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
type ApiVersion,
|
||||
CollectionMethod,
|
||||
type CusProduct,
|
||||
@@ -79,7 +78,6 @@ export const initCusProduct = ({
|
||||
internalEntityId,
|
||||
apiVersion,
|
||||
quantity,
|
||||
processor = ProcessorType.Stripe,
|
||||
}: {
|
||||
customer: Customer;
|
||||
product: FullProduct;
|
||||
@@ -102,7 +100,6 @@ export const initCusProduct = ({
|
||||
internalEntityId?: string;
|
||||
apiVersion?: ApiVersion;
|
||||
quantity?: number;
|
||||
processor?: ProcessorType;
|
||||
}) => {
|
||||
const isFuture = startsAt && startsAt > Date.now();
|
||||
|
||||
@@ -127,7 +124,7 @@ export const initCusProduct = ({
|
||||
: CusProductStatus.Active,
|
||||
|
||||
processor: {
|
||||
type: processor ?? ProcessorType.Stripe,
|
||||
type: ProcessorType.Stripe,
|
||||
// subscription_id: subscriptionId,
|
||||
// subscription_schedule_id: subscriptionScheduleId,
|
||||
// last_invoice_id: lastInvoiceId,
|
||||
@@ -240,6 +237,7 @@ export const getExistingCusProduct = async ({
|
||||
internalEntityId,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
|
||||
cusProducts?: FullCusProduct[];
|
||||
product: FullProduct;
|
||||
internalCustomerId: string;
|
||||
@@ -286,7 +284,6 @@ export const createFullCusProduct = async ({
|
||||
scenario = "default",
|
||||
sendWebhook = true,
|
||||
logger,
|
||||
processorType = ProcessorType.Stripe,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
attachParams: InsertCusProductParams;
|
||||
@@ -309,7 +306,6 @@ export const createFullCusProduct = async ({
|
||||
scenario?: string;
|
||||
sendWebhook?: boolean;
|
||||
logger: any;
|
||||
processorType?: ProcessorType;
|
||||
}) => {
|
||||
disableFreeTrial = attachParams.disableFreeTrial || disableFreeTrial;
|
||||
|
||||
@@ -323,7 +319,6 @@ export const createFullCusProduct = async ({
|
||||
product,
|
||||
internalCustomerId: customer.internal_id,
|
||||
internalEntityId: attachParams.internalEntityId,
|
||||
// processorType,
|
||||
});
|
||||
|
||||
freeTrial = disableFreeTrial ? null : freeTrial;
|
||||
@@ -347,8 +342,7 @@ export const createFullCusProduct = async ({
|
||||
notNullish(existingCusProduct) &&
|
||||
!attachParams.isCustom &&
|
||||
!existingCusProduct.is_custom &&
|
||||
product.version === existingCusProduct.product.version &&
|
||||
ACTIVE_STATUSES.includes(existingCusProduct.status)
|
||||
product.version === existingCusProduct.product.version
|
||||
) {
|
||||
await updateOneTimeCusProduct({
|
||||
db,
|
||||
@@ -441,7 +435,6 @@ export const createFullCusProduct = async ({
|
||||
const cusProd = initCusProduct({
|
||||
cusProdId,
|
||||
customer,
|
||||
processor: processorType ?? ProcessorType.Stripe,
|
||||
product,
|
||||
startsAt,
|
||||
optionsList,
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
AttachBranch,
|
||||
type AttachConfig,
|
||||
BillingType,
|
||||
cusProductToProcessorType,
|
||||
ErrCode,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
@@ -160,23 +158,6 @@ export const handleCustomPaymentMethodErrors = ({
|
||||
}
|
||||
};
|
||||
|
||||
export const handleExternalPSPErrors = ({
|
||||
attachParams,
|
||||
}: {
|
||||
attachParams: AttachParams;
|
||||
}) => {
|
||||
if (
|
||||
attachParams.customer.customer_products.some(
|
||||
(cp) => cusProductToProcessorType(cp) !== ProcessorType.Stripe,
|
||||
)
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAttachErrors = async ({
|
||||
attachParams,
|
||||
attachBody,
|
||||
@@ -196,10 +177,6 @@ export const handleAttachErrors = async ({
|
||||
attachParams,
|
||||
});
|
||||
|
||||
handleExternalPSPErrors({
|
||||
attachParams,
|
||||
});
|
||||
|
||||
if (branch === AttachBranch.MultiAttach) {
|
||||
await handleMultiAttachErrors({
|
||||
attachParams,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { AttachBranch } from "@autumn/shared";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import {
|
||||
handleCustomPaymentMethodErrors,
|
||||
handleExternalPSPErrors,
|
||||
} from "../handleAttachErrors.js";
|
||||
import { handleCustomPaymentMethodErrors } from "../handleAttachErrors.js";
|
||||
|
||||
export const handleCheckoutErrors = ({
|
||||
attachParams,
|
||||
@@ -16,10 +13,6 @@ export const handleCheckoutErrors = ({
|
||||
attachParams,
|
||||
});
|
||||
|
||||
handleExternalPSPErrors({
|
||||
attachParams,
|
||||
});
|
||||
|
||||
// if (attachParams.setupPayment) {
|
||||
// // Make sure only usage prices are added?
|
||||
// }
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import {
|
||||
CusProductNotFoundError,
|
||||
cusProductToProcessorType,
|
||||
ErrCode,
|
||||
type FullCusProduct,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import { Router } from "express";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { notNullish, nullish } from "@/utils/genUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
|
||||
@@ -65,7 +63,6 @@ cancelRouter.post("", async (req, res) =>
|
||||
const cusProductIdMatch = customer_product_id
|
||||
? cusProduct.id === customer_product_id
|
||||
: true;
|
||||
|
||||
return productIdMatch && entityMatch && cusProductIdMatch;
|
||||
});
|
||||
|
||||
@@ -77,12 +74,6 @@ cancelRouter.post("", async (req, res) =>
|
||||
});
|
||||
}
|
||||
|
||||
if (cusProductToProcessorType(cusProduct) === ProcessorType.RevenueCat) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot cancel '${cusProduct.product.name}' because it is managed by RevenueCat.`,
|
||||
});
|
||||
}
|
||||
|
||||
await handleCancelProduct({
|
||||
ctx: req as unknown as AutumnContext,
|
||||
cusProduct,
|
||||
|
||||
@@ -184,7 +184,7 @@ export class CusProductService {
|
||||
internalCustomerId: string;
|
||||
withCustomer?: boolean;
|
||||
inStatuses?: string[];
|
||||
}): Promise<FullCusProduct[]> {
|
||||
}) {
|
||||
const cusProducts = await db.query.customerProducts.findMany({
|
||||
where: and(
|
||||
eq(customerProducts.internal_customer_id, internalCustomerId),
|
||||
|
||||
@@ -11,12 +11,10 @@ export const getExistingCusProducts = ({
|
||||
product,
|
||||
cusProducts,
|
||||
internalEntityId,
|
||||
// processorType = ProcessorType.Stripe,
|
||||
}: {
|
||||
product: Product;
|
||||
cusProducts: FullCusProduct[];
|
||||
internalEntityId?: string | null;
|
||||
// processorType?: ProcessorType;
|
||||
}) => {
|
||||
if (!cusProducts || cusProducts.length === 0 || !product) {
|
||||
return {
|
||||
@@ -26,10 +24,7 @@ export const getExistingCusProducts = ({
|
||||
};
|
||||
}
|
||||
|
||||
const curMainProduct = cusProducts.find((cp: FullCusProduct) => {
|
||||
// const sameProcessor = cp.processor?.type
|
||||
// ? cp.processor.type === processorType
|
||||
// : true;
|
||||
const curMainProduct = cusProducts.find((cp: any) => {
|
||||
const sameGroup = cp.product.group === product.group;
|
||||
const isMain = !cp.product.is_add_on;
|
||||
const isActive =
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
type Feature,
|
||||
type FullCusProduct,
|
||||
type FullProduct,
|
||||
type MigrationJob,
|
||||
type Organization,
|
||||
ProcessorType,
|
||||
import type {
|
||||
AppEnv,
|
||||
Feature,
|
||||
FullCusProduct,
|
||||
FullProduct,
|
||||
MigrationJob,
|
||||
Organization,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import type { Logger } from "../../../external/logtail/logtailUtils.js";
|
||||
import { migrateRevenueCatCustomer } from "./migrateRevenuecatCustomer.js";
|
||||
import { migrateStripeCustomer } from "./migrateStripeCustomer.js";
|
||||
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
|
||||
import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
|
||||
import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js";
|
||||
import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js";
|
||||
|
||||
export const migrateCustomer = async ({
|
||||
db,
|
||||
@@ -66,30 +68,26 @@ export const migrateCustomer = async ({
|
||||
);
|
||||
|
||||
for (const cusProduct of filteredCusProducts) {
|
||||
if (cusProduct.processor?.type === ProcessorType.RevenueCat) {
|
||||
await migrateRevenueCatCustomer({
|
||||
req,
|
||||
fullCus,
|
||||
cusProduct,
|
||||
toProduct,
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await migrateStripeCustomer({
|
||||
const attachParams = await migrationToAttachParams({
|
||||
req,
|
||||
stripeCli,
|
||||
fullCus,
|
||||
customer: fullCus,
|
||||
cusProduct,
|
||||
toProduct,
|
||||
newProduct: toProduct,
|
||||
});
|
||||
|
||||
await runMigrationAttach({
|
||||
ctx: req as unknown as AutumnContext,
|
||||
attachParams,
|
||||
fromProduct,
|
||||
});
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
AttachScenario,
|
||||
CusProductStatus,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type FullProduct,
|
||||
ProcessorType,
|
||||
} from "@autumn/shared";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { attachToInsertParams } from "@/internal/products/productUtils.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
|
||||
|
||||
export const migrateRevenueCatCustomer = async ({
|
||||
req,
|
||||
fullCus,
|
||||
cusProduct,
|
||||
toProduct,
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
fullCus: FullCustomer;
|
||||
cusProduct: FullCusProduct;
|
||||
toProduct: FullProduct;
|
||||
customerId: string;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
const { logger } = req;
|
||||
fullCus.customer_products = fullCus.customer_products.filter(
|
||||
(cp) => cp.processor?.type === ProcessorType.RevenueCat,
|
||||
);
|
||||
|
||||
// Debug: Log the old cusProduct dates
|
||||
logger.info(`[RC Migration] Old cusProduct dates:`, {
|
||||
cusProductId: cusProduct.id,
|
||||
created_at: cusProduct.created_at,
|
||||
created_at_date: cusProduct.created_at
|
||||
? new Date(cusProduct.created_at).toISOString()
|
||||
: null,
|
||||
starts_at: cusProduct.starts_at,
|
||||
starts_at_date: cusProduct.starts_at
|
||||
? new Date(cusProduct.starts_at).toISOString()
|
||||
: null,
|
||||
});
|
||||
|
||||
await CusProductService.update({
|
||||
db: req.db,
|
||||
cusProductId: cusProduct.id,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
ended_at: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const createdAtToPass = cusProduct.created_at;
|
||||
const startsAtToPass = cusProduct.starts_at;
|
||||
const anchorToPass = cusProduct.created_at;
|
||||
|
||||
// // Debug: Log what we're passing to createFullCusProduct
|
||||
// logger.info(`[RC Migration] Passing to createFullCusProduct:`, {
|
||||
// createdAt: createdAtToPass,
|
||||
// createdAt_date: createdAtToPass
|
||||
// ? new Date(createdAtToPass).toISOString()
|
||||
// : null,
|
||||
// startsAt: startsAtToPass,
|
||||
// startsAt_date: startsAtToPass
|
||||
// ? new Date(startsAtToPass).toISOString()
|
||||
// : null,
|
||||
// anchorToUnix: anchorToPass,
|
||||
// anchorToUnix_date: anchorToPass
|
||||
// ? new Date(anchorToPass).toISOString()
|
||||
// : null,
|
||||
// createdAt_type: typeof createdAtToPass,
|
||||
// });
|
||||
|
||||
await createFullCusProduct({
|
||||
db: req.db,
|
||||
logger: req.logger,
|
||||
scenario: AttachScenario.New,
|
||||
processorType: ProcessorType.RevenueCat,
|
||||
// Preserve the original created_at, starts_at, and billing cycle anchor
|
||||
createdAt: createdAtToPass,
|
||||
anchorToUnix: anchorToPass,
|
||||
carryExistingUsages: true,
|
||||
attachParams: attachToInsertParams(
|
||||
{
|
||||
customer: fullCus,
|
||||
products: [toProduct],
|
||||
prices: toProduct.prices,
|
||||
entitlements: toProduct.entitlements,
|
||||
entities: fullCus.entities || [],
|
||||
org: req.org,
|
||||
stripeCli: createStripeCli({ org: req.org, env: req.env }),
|
||||
paymentMethod: null,
|
||||
freeTrial: null,
|
||||
optionsList: cusProduct.options || [],
|
||||
cusProducts: fullCus.customer_products,
|
||||
replaceables: [],
|
||||
features: req.features,
|
||||
fromMigration: true,
|
||||
},
|
||||
toProduct,
|
||||
),
|
||||
});
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type FullProduct,
|
||||
} from "@autumn/shared";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
|
||||
import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
|
||||
import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js";
|
||||
import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js";
|
||||
|
||||
export const migrateStripeCustomer = async ({
|
||||
req,
|
||||
stripeCli,
|
||||
fullCus,
|
||||
cusProduct,
|
||||
toProduct,
|
||||
fromProduct,
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
stripeCli: Stripe;
|
||||
fullCus: FullCustomer;
|
||||
cusProduct: FullCusProduct;
|
||||
toProduct: FullProduct;
|
||||
fromProduct: FullProduct;
|
||||
customerId: string;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
const attachParams = await migrationToAttachParams({
|
||||
req,
|
||||
stripeCli,
|
||||
customer: fullCus,
|
||||
cusProduct,
|
||||
newProduct: toProduct,
|
||||
});
|
||||
|
||||
await runMigrationAttach({
|
||||
ctx: req as unknown as AutumnContext,
|
||||
attachParams,
|
||||
fromProduct,
|
||||
});
|
||||
|
||||
await deleteCachedApiCustomer({
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
};
|
||||
@@ -1,237 +0,0 @@
|
||||
import {
|
||||
AppEnv,
|
||||
InternalError,
|
||||
type Organization,
|
||||
type RevenueCatProcessorConfig,
|
||||
UpsertRevenueCatProcessorConfigSchema,
|
||||
} from "@autumn/shared";
|
||||
import { createSvixApp } from "@server/external/svix/svixHelpers.js";
|
||||
import { createSvixCli } from "@server/external/svix/svixUtils.js";
|
||||
import { createRoute } from "@server/honoMiddlewares/routeHandler.js";
|
||||
import { decryptData, encryptData } from "@server/utils/encryptUtils.js";
|
||||
import { mask } from "@server/utils/genUtils.js";
|
||||
import type { ApplicationOut } from "svix";
|
||||
import { OrgService } from "../OrgService.js";
|
||||
|
||||
// Generate a random 64-character alphanumeric string
|
||||
const generateWebhookSecret = (): string => {
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(64));
|
||||
for (let i = 0; i < 64; i++) {
|
||||
result += chars[randomBytes[i] % chars.length];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getRevenueCatConfigDisplay = ({
|
||||
org,
|
||||
env,
|
||||
}: {
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
const revenueCatConfig = org.processor_configs?.revenuecat;
|
||||
if (!revenueCatConfig) {
|
||||
return {
|
||||
connected: false,
|
||||
api_key: undefined,
|
||||
sandbox_api_key: undefined,
|
||||
project_id: undefined,
|
||||
sandbox_project_id: undefined,
|
||||
webhook_secret: undefined,
|
||||
sandbox_webhook_secret: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const liveApiKeyDecrypted = revenueCatConfig.api_key
|
||||
? decryptData(revenueCatConfig.api_key)
|
||||
: undefined;
|
||||
const sandboxApiKeyDecrypted = revenueCatConfig.sandbox_api_key
|
||||
? decryptData(revenueCatConfig.sandbox_api_key)
|
||||
: undefined;
|
||||
|
||||
const webhookSecret =
|
||||
env === AppEnv.Live
|
||||
? revenueCatConfig.webhook_secret
|
||||
: revenueCatConfig.sandbox_webhook_secret;
|
||||
|
||||
const apiKeyForEnv =
|
||||
env === AppEnv.Live ? liveApiKeyDecrypted : sandboxApiKeyDecrypted;
|
||||
|
||||
return {
|
||||
connected: !!apiKeyForEnv && !!webhookSecret,
|
||||
api_key: mask(liveApiKeyDecrypted, 3, 2),
|
||||
sandbox_api_key: mask(sandboxApiKeyDecrypted, 5, 5),
|
||||
project_id: revenueCatConfig.project_id,
|
||||
sandbox_project_id: revenueCatConfig.sandbox_project_id,
|
||||
webhook_secret: revenueCatConfig.webhook_secret,
|
||||
sandbox_webhook_secret: revenueCatConfig.sandbox_webhook_secret,
|
||||
};
|
||||
};
|
||||
|
||||
export const handleGetRevenueCatConfig = createRoute({
|
||||
handler: async (c) => {
|
||||
const { db, org, env } = c.get("ctx");
|
||||
|
||||
const revenueCatConfig = org.processor_configs?.revenuecat;
|
||||
|
||||
// Generate webhook secrets if they don't exist
|
||||
const needsWebhookSecrets =
|
||||
!revenueCatConfig?.webhook_secret ||
|
||||
!revenueCatConfig?.sandbox_webhook_secret;
|
||||
|
||||
if (!revenueCatConfig || needsWebhookSecrets) {
|
||||
const webhookSecret =
|
||||
revenueCatConfig?.webhook_secret || generateWebhookSecret();
|
||||
const sandboxWebhookSecret =
|
||||
revenueCatConfig?.sandbox_webhook_secret || generateWebhookSecret();
|
||||
|
||||
await OrgService.update({
|
||||
db,
|
||||
orgId: org.id,
|
||||
updates: {
|
||||
processor_configs: {
|
||||
...org.processor_configs,
|
||||
revenuecat: {
|
||||
...(revenueCatConfig || ({} as RevenueCatProcessorConfig)),
|
||||
webhook_secret: webhookSecret,
|
||||
sandbox_webhook_secret: sandboxWebhookSecret,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Return fresh config after update
|
||||
return c.json({
|
||||
connected: false,
|
||||
api_key: undefined,
|
||||
sandbox_api_key: undefined,
|
||||
project_id: undefined,
|
||||
sandbox_project_id: undefined,
|
||||
webhook_secret: webhookSecret,
|
||||
sandbox_webhook_secret: sandboxWebhookSecret,
|
||||
});
|
||||
}
|
||||
|
||||
const config = getRevenueCatConfigDisplay({ org, env });
|
||||
|
||||
return c.json(config);
|
||||
},
|
||||
});
|
||||
|
||||
export const handleUpsertRevenueCatConfig = createRoute({
|
||||
body: UpsertRevenueCatProcessorConfigSchema,
|
||||
handler: async (c) => {
|
||||
const { db, org } = c.get("ctx");
|
||||
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Merge with existing processor_configs to avoid unsetting fields
|
||||
const existingRevenueCatConfig =
|
||||
org.processor_configs?.revenuecat || ({} as RevenueCatProcessorConfig);
|
||||
|
||||
await OrgService.update({
|
||||
db,
|
||||
orgId: org.id,
|
||||
updates: {
|
||||
processor_configs: {
|
||||
...org.processor_configs,
|
||||
revenuecat: {
|
||||
...existingRevenueCatConfig,
|
||||
// Live fields
|
||||
...(body.api_key ? { api_key: encryptData(body.api_key) } : {}),
|
||||
...(body.sandbox_api_key
|
||||
? { sandbox_api_key: encryptData(body.sandbox_api_key) }
|
||||
: {}),
|
||||
...(body.project_id ? { project_id: body.project_id } : {}),
|
||||
...(body.sandbox_project_id
|
||||
? { sandbox_project_id: body.sandbox_project_id }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const handleGetVercelSink = createRoute({
|
||||
handler: async (c) => {
|
||||
const { db, org, env } = c.get("ctx");
|
||||
const vercelConfig = org.processor_configs?.vercel;
|
||||
const svixCli = createSvixCli();
|
||||
let liveApp: ApplicationOut | undefined;
|
||||
let sandboxApp: ApplicationOut | undefined;
|
||||
|
||||
if (!vercelConfig) {
|
||||
throw new InternalError({
|
||||
message: `Vercel config not found for org ${org.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!vercelConfig?.svix?.live_id || !vercelConfig?.svix?.sandbox_id) {
|
||||
liveApp = await createSvixApp({
|
||||
name: `${org.slug}_live_vercel_sink`,
|
||||
orgId: org.id,
|
||||
env: AppEnv.Live,
|
||||
});
|
||||
}
|
||||
|
||||
if (!vercelConfig?.svix?.sandbox_id) {
|
||||
sandboxApp = await createSvixApp({
|
||||
name: `${org.slug}_sandbox_vercel_sink`,
|
||||
orgId: org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
});
|
||||
}
|
||||
|
||||
const updates = {
|
||||
...(liveApp
|
||||
? { svix: { ...(vercelConfig?.svix || {}), live_id: liveApp.id } }
|
||||
: {}),
|
||||
...(sandboxApp
|
||||
? { svix: { ...(vercelConfig?.svix || {}), sandbox_id: sandboxApp.id } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await OrgService.update({
|
||||
db,
|
||||
orgId: org.id,
|
||||
updates: {
|
||||
processor_configs: {
|
||||
...org.processor_configs,
|
||||
vercel: { ...(vercelConfig || {}), ...updates },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let url: string | undefined;
|
||||
|
||||
if (env === AppEnv.Live) {
|
||||
url = (
|
||||
await svixCli.authentication.appPortalAccess(
|
||||
liveApp?.id || vercelConfig?.svix?.live_id || "",
|
||||
{
|
||||
featureFlags: ["vercel"],
|
||||
},
|
||||
)
|
||||
).url;
|
||||
} else {
|
||||
url = (
|
||||
await svixCli.authentication.appPortalAccess(
|
||||
sandboxApp?.id || vercelConfig?.svix?.sandbox_id || "",
|
||||
{
|
||||
featureFlags: ["vercel"],
|
||||
},
|
||||
)
|
||||
).url;
|
||||
}
|
||||
|
||||
return c.json({ url });
|
||||
},
|
||||
});
|
||||
@@ -1,16 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { handleGetRCMappings } from "@/external/revenueCat/handlers/handleGetRevenuecatMappings.js";
|
||||
import { handleGetRevenueCatProducts } from "@/external/revenueCat/handlers/handleGetRevenuecatProducts.js";
|
||||
import { handleSaveRCMappings } from "@/external/revenueCat/handlers/handleSaveRevenuecatMappings.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { handleDeleteOrg } from "./handlers/crudHandlers/handleDeleteOrg.js";
|
||||
import { handleGetOrg } from "./handlers/crudHandlers/handleGetOrg.js";
|
||||
import { handleGetUploadUrl } from "./handlers/handleGetUploadUrl.js";
|
||||
import { handleResetDefaultAccount } from "./handlers/handleResetDefaultAccount.js";
|
||||
import {
|
||||
handleGetRevenueCatConfig,
|
||||
handleUpsertRevenueCatConfig,
|
||||
} from "./handlers/handleRevenueCatConfig.js";
|
||||
import { handleUpdateOrg } from "./handlers/handleUpdateOrg.js";
|
||||
import {
|
||||
handleGetVercelSink,
|
||||
@@ -41,12 +34,5 @@ honoOrgRouter.delete("/stripe", ...handleDeleteStripe);
|
||||
honoOrgRouter.post("/stripe", ...handleConnectStripe);
|
||||
honoOrgRouter.get("/stripe/oauth_url", ...handleGetOAuthUrl);
|
||||
honoOrgRouter.post("/reset_default_account", ...handleResetDefaultAccount);
|
||||
|
||||
honoOrgRouter.patch("/vercel", ...handleUpsertVercelConfig);
|
||||
honoOrgRouter.get("/vercel_sink", ...handleGetVercelSink);
|
||||
|
||||
honoOrgRouter.get("/revenuecat", ...handleGetRevenueCatConfig);
|
||||
honoOrgRouter.patch("/revenuecat", ...handleUpsertRevenueCatConfig);
|
||||
honoOrgRouter.post("/revenuecat/products", ...handleGetRevenueCatProducts);
|
||||
honoOrgRouter.get("/revenuecat/mappings", ...handleGetRCMappings);
|
||||
honoOrgRouter.post("/revenuecat/mappings", ...handleSaveRCMappings);
|
||||
|
||||
@@ -22,7 +22,6 @@ import { notNullish } from "@server/utils/genUtils.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import Stripe from "stripe";
|
||||
import { FeatureService } from "../features/FeatureService.js";
|
||||
import { getRevenueCatConfigDisplay } from "./handlers/handleRevenueCatConfig.js";
|
||||
import { getVercelConfigDisplay } from "./handlers/handleVercelConfig.js";
|
||||
import { OrgService } from "./OrgService.js";
|
||||
import { clearOrgCache } from "./orgUtils/clearOrgCache.js";
|
||||
@@ -201,7 +200,6 @@ export const createOrgResponse = ({
|
||||
});
|
||||
|
||||
const vercelConnection = getVercelConfigDisplay({ org, env });
|
||||
const revenueCatConnection = getRevenueCatConfigDisplay({ org, env });
|
||||
|
||||
const stripeConnection = secretKeyConnected
|
||||
? "secret_key"
|
||||
@@ -246,15 +244,6 @@ export const createOrgResponse = ({
|
||||
custom_payment_method: vercelConnection.custom_payment_method,
|
||||
marketplace_mode: vercelConnection.marketplace_mode,
|
||||
},
|
||||
revenuecat: {
|
||||
connected: revenueCatConnection.connected,
|
||||
api_key: revenueCatConnection.api_key,
|
||||
sandbox_api_key: revenueCatConnection.sandbox_api_key,
|
||||
project_id: revenueCatConnection.project_id,
|
||||
sandbox_project_id: revenueCatConnection.sandbox_project_id,
|
||||
webhook_secret: revenueCatConnection.webhook_secret,
|
||||
sandbox_webhook_secret: revenueCatConnection.sandbox_webhook_secret,
|
||||
},
|
||||
},
|
||||
|
||||
created_at: new Date(org.createdAt).getTime(),
|
||||
|
||||
@@ -54,14 +54,6 @@ export const nullish = <T>(
|
||||
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 = <T>(value: T | null | undefined): value is T => {
|
||||
return !nullish(value);
|
||||
};
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
ApiVersion,
|
||||
AppEnv,
|
||||
CusProductStatus,
|
||||
customers,
|
||||
ProcessorType,
|
||||
} from "@autumn/shared";
|
||||
import { replaceItems } from "@tests/attach/utils.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { encryptData } from "@/utils/encryptUtils.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { expectFeaturesCorrect } from "../../utils/expectUtils/expectFeaturesCorrect.js";
|
||||
import { timeout } from "../../utils/genUtils.js";
|
||||
import {
|
||||
expectWebhookSuccess,
|
||||
RevenueCatWebhookClient,
|
||||
} from "./utils/revenue-cat-webhook-client.js";
|
||||
|
||||
const testCase = "rcMigration1";
|
||||
const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_migration";
|
||||
|
||||
// RevenueCat product ID
|
||||
const RC_PRO_MONTHLY_ID = "com.app.migration_pro_monthly";
|
||||
|
||||
// Autumn product definitions
|
||||
const messagesFeature = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
});
|
||||
|
||||
const proMonthlyV1 = constructProduct({
|
||||
id: `${testCase}-pro-monthly`,
|
||||
type: "pro",
|
||||
items: [messagesFeature],
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const proMonthlyV2 = {
|
||||
...proMonthlyV1,
|
||||
version: 2,
|
||||
items: replaceItems({
|
||||
items: proMonthlyV1.items,
|
||||
featureId: TestFeature.Messages,
|
||||
newItem: constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 2000,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
describe(
|
||||
chalk.yellowBright("rcMigration1: RevenueCat customer migration"),
|
||||
() => {
|
||||
const customerId = `${testCase}-customer`;
|
||||
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
let internalCustomerId: string | null = null;
|
||||
let rcClient: RevenueCatWebhookClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
// 1. Configure org with RevenueCat processor config
|
||||
if (
|
||||
ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !==
|
||||
RC_WEBHOOK_SECRET
|
||||
) {
|
||||
await OrgService.update({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
updates: {
|
||||
processor_configs: {
|
||||
...ctx.org.processor_configs,
|
||||
revenuecat: {
|
||||
api_key: encryptData("mock_rc_api_key_live"),
|
||||
sandbox_api_key: encryptData("mock_rc_api_key_sandbox"),
|
||||
project_id: "mock_project_live",
|
||||
sandbox_project_id: "mock_project_sandbox",
|
||||
webhook_secret: RC_WEBHOOK_SECRET,
|
||||
sandbox_webhook_secret: RC_WEBHOOK_SECRET,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create product and mappings
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [proMonthlyV1],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
await RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: proMonthlyV1.id,
|
||||
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Create customer
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
});
|
||||
|
||||
const dbCustomer = await ctx.db.query.customers.findFirst({
|
||||
where: eq(customers.id, customerId),
|
||||
});
|
||||
expect(dbCustomer).toBeDefined();
|
||||
internalCustomerId = dbCustomer!.internal_id;
|
||||
|
||||
// Initialize RevenueCat webhook client
|
||||
rcClient = new RevenueCatWebhookClient({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
webhookSecret: RC_WEBHOOK_SECRET,
|
||||
});
|
||||
});
|
||||
|
||||
test("should create customer with pro monthly v1 product via initial purchase", async () => {
|
||||
const { response, data } = await rcClient.initialPurchase({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "migration_tx_12345",
|
||||
});
|
||||
|
||||
expectWebhookSuccess({ response, data });
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthlyV1.id);
|
||||
|
||||
// Verify cus_product has RevenueCat processor
|
||||
const cusProducts = await CusProductService.list({
|
||||
db: ctx.db,
|
||||
internalCustomerId: internalCustomerId!,
|
||||
inStatuses: [CusProductStatus.Active],
|
||||
});
|
||||
expect(cusProducts).toHaveLength(1);
|
||||
expect(cusProducts[0].processor?.type).toBe(ProcessorType.RevenueCat);
|
||||
});
|
||||
|
||||
test("should create v2 of the product with updated features", async () => {
|
||||
// Create v2 with increased usage
|
||||
const newItems = replaceItems({
|
||||
items: proMonthlyV1.items,
|
||||
featureId: TestFeature.Messages,
|
||||
newItem: constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 2000, // Increased from 1000
|
||||
}),
|
||||
});
|
||||
|
||||
await autumnV1.products.update(proMonthlyV1.id, {
|
||||
items: newItems,
|
||||
});
|
||||
});
|
||||
|
||||
test("should migrate customer from v1 to v2", async () => {
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
value: 500,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
|
||||
// Run migration via API
|
||||
await autumnV1.migrate({
|
||||
from_product_id: proMonthlyV1.id,
|
||||
to_product_id: proMonthlyV1.id,
|
||||
from_version: 1,
|
||||
to_version: 2,
|
||||
});
|
||||
|
||||
await timeout(5000);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthlyV1.id);
|
||||
expect(customer.products[0].version).toBe(2);
|
||||
|
||||
expectFeaturesCorrect({
|
||||
customer,
|
||||
product: proMonthlyV2,
|
||||
usage: [
|
||||
{
|
||||
featureId: TestFeature.Messages,
|
||||
value: 500,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,395 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
ApiVersion,
|
||||
AppEnv,
|
||||
CusProductStatus,
|
||||
customers,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { encryptData } from "@/utils/encryptUtils.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import {
|
||||
expectWebhookSuccess,
|
||||
RevenueCatWebhookClient,
|
||||
} from "./utils/revenue-cat-webhook-client.js";
|
||||
|
||||
const testCase = "rc1";
|
||||
const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345";
|
||||
|
||||
// RevenueCat product IDs (what RC sends in webhooks)
|
||||
const RC_PRO_MONTHLY_ID = "com.app.pro_monthly";
|
||||
const RC_PRO_YEARLY_ID = "com.app.pro_yearly";
|
||||
const RC_ADD_ON_ID = "com.app.add_on_pack";
|
||||
|
||||
// Autumn product definitions
|
||||
const messagesFeature = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
});
|
||||
|
||||
const proMonthly = constructProduct({
|
||||
id: `${testCase}-pro-monthly`,
|
||||
type: "pro",
|
||||
items: [messagesFeature],
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const proYearly = constructProduct({
|
||||
id: `${testCase}-pro-yearly`,
|
||||
type: "pro",
|
||||
isAnnual: true,
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
}),
|
||||
],
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const addOnPack = constructProduct({
|
||||
id: `${testCase}-add-on`,
|
||||
type: "one_off",
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
}),
|
||||
],
|
||||
isAddOn: true,
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => {
|
||||
const customerId = `${testCase}-customer`;
|
||||
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
let proMonthlyCusProductId: string | null = null;
|
||||
let internalCustomerId: string | null = null;
|
||||
let rcClient: RevenueCatWebhookClient;
|
||||
|
||||
const fetchLatestActiveCusProductId = async () => {
|
||||
if (!internalCustomerId) {
|
||||
throw new Error("internalCustomerId not set");
|
||||
}
|
||||
|
||||
const cusProducts = await CusProductService.list({
|
||||
db: ctx.db,
|
||||
internalCustomerId,
|
||||
inStatuses: [
|
||||
CusProductStatus.Active,
|
||||
CusProductStatus.PastDue,
|
||||
CusProductStatus.Scheduled,
|
||||
],
|
||||
});
|
||||
|
||||
const activeSorted = cusProducts
|
||||
.filter(
|
||||
(cp) =>
|
||||
cp.status === CusProductStatus.Active ||
|
||||
cp.status === CusProductStatus.PastDue,
|
||||
)
|
||||
.sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
|
||||
|
||||
expect(
|
||||
activeSorted.length > 0,
|
||||
"Expected at least one active cus_product for customer",
|
||||
).toBe(true);
|
||||
|
||||
// Return the latest active cus_product id for the customer
|
||||
return activeSorted[activeSorted.length - 1]!.id;
|
||||
};
|
||||
|
||||
const fetchLatestCusProductIdAnyStatus = async () => {
|
||||
if (!internalCustomerId) {
|
||||
throw new Error("internalCustomerId not set");
|
||||
}
|
||||
|
||||
const cusProducts = await CusProductService.list({
|
||||
db: ctx.db,
|
||||
internalCustomerId,
|
||||
inStatuses: undefined,
|
||||
});
|
||||
|
||||
if (cusProducts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = [...cusProducts].sort(
|
||||
(a, b) => (a.created_at ?? 0) - (b.created_at ?? 0),
|
||||
);
|
||||
|
||||
return sorted[sorted.length - 1]!.id;
|
||||
};
|
||||
|
||||
const getBaselineCusProductId = () => {
|
||||
if (!proMonthlyCusProductId) {
|
||||
throw new Error(
|
||||
"Baseline CusProduct ID was not set from initial purchase",
|
||||
);
|
||||
}
|
||||
|
||||
return proMonthlyCusProductId;
|
||||
};
|
||||
const updateBaselineCusProductId = (cusProductId: string) => {
|
||||
proMonthlyCusProductId = cusProductId;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// 1. Configure org with RevenueCat processor config
|
||||
if (
|
||||
ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !==
|
||||
RC_WEBHOOK_SECRET
|
||||
) {
|
||||
await OrgService.update({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
updates: {
|
||||
processor_configs: {
|
||||
...ctx.org.processor_configs,
|
||||
revenuecat: {
|
||||
api_key: encryptData("mock_rc_api_key_live"),
|
||||
sandbox_api_key: encryptData("mock_rc_api_key_sandbox"),
|
||||
project_id: "mock_project_live",
|
||||
sandbox_project_id: "mock_project_sandbox",
|
||||
webhook_secret: RC_WEBHOOK_SECRET,
|
||||
sandbox_webhook_secret: RC_WEBHOOK_SECRET,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize RevenueCat webhook client
|
||||
rcClient = new RevenueCatWebhookClient({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
webhookSecret: RC_WEBHOOK_SECRET,
|
||||
});
|
||||
|
||||
// 2-4. Create products, mappings, and customer concurrently
|
||||
await Promise.all([
|
||||
initProductsV0({
|
||||
ctx,
|
||||
products: [proMonthly, proYearly, addOnPack],
|
||||
prefix: testCase,
|
||||
}),
|
||||
RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: proMonthly.id,
|
||||
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
|
||||
},
|
||||
}),
|
||||
RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: addOnPack.id,
|
||||
revenuecat_product_ids: [RC_ADD_ON_ID],
|
||||
},
|
||||
}),
|
||||
RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: proYearly.id,
|
||||
revenuecat_product_ids: [RC_PRO_YEARLY_ID],
|
||||
},
|
||||
}),
|
||||
initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const dbCustomer = await ctx.db.query.customers.findFirst({
|
||||
where: eq(customers.id, customerId),
|
||||
});
|
||||
expect(dbCustomer).toBeDefined();
|
||||
internalCustomerId = dbCustomer!.internal_id;
|
||||
});
|
||||
|
||||
test("should create customer with pro monthly product", async () => {
|
||||
const result = await rcClient.initialPurchase({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
|
||||
proMonthlyCusProductId = await fetchLatestActiveCusProductId();
|
||||
});
|
||||
|
||||
test("should upgrade customer to pro yearly product upon renewal", async () => {
|
||||
const result = await rcClient.renewal({
|
||||
productId: RC_PRO_YEARLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
await fetchLatestActiveCusProductId();
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proYearly.id);
|
||||
});
|
||||
|
||||
test("should downgrade customer to pro monthly product upon initial purchase", async () => {
|
||||
const result = await rcClient.initialPurchase({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
|
||||
const currentCusProductId = await fetchLatestActiveCusProductId();
|
||||
console.log("currentCusProductId", currentCusProductId);
|
||||
expect(currentCusProductId).not.toBe(getBaselineCusProductId());
|
||||
updateBaselineCusProductId(currentCusProductId);
|
||||
});
|
||||
|
||||
test("should go to cancelling state upon cancellation", async () => {
|
||||
const result = await rcClient.cancellation({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
expirationAtMs: Date.now() + 1000 * 60 * 60 * 24 * 30,
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
const canceledAt = customer.products[0].canceled_at ?? 0;
|
||||
expect(typeof canceledAt).toBe("number");
|
||||
expect(Math.abs(Date.now() - canceledAt)).toBeLessThanOrEqual(3000);
|
||||
|
||||
const currentCusProductId = await fetchLatestActiveCusProductId();
|
||||
expect(currentCusProductId).toBe(getBaselineCusProductId());
|
||||
});
|
||||
|
||||
test("should uncancel customer after cancellation event", async () => {
|
||||
const result = await rcClient.uncancellation({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
expect(customer.products[0].canceled_at).toBeNull();
|
||||
|
||||
const currentCusProductId = await fetchLatestActiveCusProductId();
|
||||
expect(currentCusProductId).toBe(getBaselineCusProductId());
|
||||
});
|
||||
|
||||
test("should mark product as past due upon billing issue", async () => {
|
||||
const result = await rcClient.billingIssue({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
expect(String(customer.products[0].status)).toBe("past_due");
|
||||
|
||||
const currentCusProductId = await fetchLatestActiveCusProductId();
|
||||
expect(currentCusProductId).toBe(getBaselineCusProductId());
|
||||
});
|
||||
|
||||
test("should go to expired state upon expiration", async () => {
|
||||
const result = await rcClient.expiration({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(0);
|
||||
|
||||
const latestCusProductId = await fetchLatestCusProductIdAnyStatus();
|
||||
// After expiration, there may no longer be a cus_product row at all. In that
|
||||
// case, we just assert there are no cus_products for this customer anymore.
|
||||
if (latestCusProductId === null) {
|
||||
const allCusProducts = await CusProductService.list({
|
||||
db: ctx.db,
|
||||
internalCustomerId: internalCustomerId!,
|
||||
inStatuses: undefined,
|
||||
});
|
||||
expect(allCusProducts.length).toBe(0);
|
||||
} else {
|
||||
expect(latestCusProductId).toBe(getBaselineCusProductId());
|
||||
}
|
||||
});
|
||||
|
||||
test("should attach add-on product after expiration via non-renewing purchase", async () => {
|
||||
const result = await rcClient.nonRenewingPurchase({
|
||||
productId: RC_ADD_ON_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "add_on_tx_12345",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(addOnPack.id);
|
||||
|
||||
const addOnCusProducts = await CusProductService.getByProductId({
|
||||
db: ctx.db,
|
||||
productId: addOnPack.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
limit: 1,
|
||||
});
|
||||
expect(
|
||||
addOnCusProducts.length > 0,
|
||||
`CusProduct for add-on product ${addOnPack.id} should exist`,
|
||||
).toBe(true);
|
||||
const addOnCusProductId = addOnCusProducts[0]!.id;
|
||||
expect(typeof addOnCusProductId).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -1,395 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
ApiVersion,
|
||||
AppEnv,
|
||||
CusProductStatus,
|
||||
customers,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { encryptData } from "@/utils/encryptUtils.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import {
|
||||
expectWebhookSuccess,
|
||||
RevenueCatWebhookClient,
|
||||
} from "./utils/revenue-cat-webhook-client.js";
|
||||
|
||||
const testCase = "rc1";
|
||||
const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345";
|
||||
|
||||
// RevenueCat product IDs (what RC sends in webhooks)
|
||||
const RC_PRO_MONTHLY_ID = "com.app.pro_monthly";
|
||||
const RC_PRO_YEARLY_ID = "com.app.pro_yearly";
|
||||
const RC_ADD_ON_ID = "com.app.add_on_pack";
|
||||
|
||||
// Autumn product definitions
|
||||
const messagesFeature = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
});
|
||||
|
||||
const proMonthly = constructProduct({
|
||||
id: `${testCase}-pro-monthly`,
|
||||
type: "pro",
|
||||
items: [messagesFeature],
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const proYearly = constructProduct({
|
||||
id: `${testCase}-pro-yearly`,
|
||||
type: "pro",
|
||||
isAnnual: true,
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
}),
|
||||
],
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const addOnPack = constructProduct({
|
||||
id: `${testCase}-add-on`,
|
||||
type: "one_off",
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
}),
|
||||
],
|
||||
isAddOn: true,
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => {
|
||||
const customerId = `${testCase}-customer`;
|
||||
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
let proMonthlyCusProductId: string | null = null;
|
||||
let internalCustomerId: string | null = null;
|
||||
let rcClient: RevenueCatWebhookClient;
|
||||
|
||||
const fetchLatestActiveCusProductId = async () => {
|
||||
if (!internalCustomerId) {
|
||||
throw new Error("internalCustomerId not set");
|
||||
}
|
||||
|
||||
const cusProducts = await CusProductService.list({
|
||||
db: ctx.db,
|
||||
internalCustomerId,
|
||||
inStatuses: [
|
||||
CusProductStatus.Active,
|
||||
CusProductStatus.PastDue,
|
||||
CusProductStatus.Scheduled,
|
||||
],
|
||||
});
|
||||
|
||||
const activeSorted = cusProducts
|
||||
.filter(
|
||||
(cp) =>
|
||||
cp.status === CusProductStatus.Active ||
|
||||
cp.status === CusProductStatus.PastDue,
|
||||
)
|
||||
.sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
|
||||
|
||||
expect(
|
||||
activeSorted.length > 0,
|
||||
"Expected at least one active cus_product for customer",
|
||||
).toBe(true);
|
||||
|
||||
// Return the latest active cus_product id for the customer
|
||||
return activeSorted[activeSorted.length - 1]!.id;
|
||||
};
|
||||
|
||||
const fetchLatestCusProductIdAnyStatus = async () => {
|
||||
if (!internalCustomerId) {
|
||||
throw new Error("internalCustomerId not set");
|
||||
}
|
||||
|
||||
const cusProducts = await CusProductService.list({
|
||||
db: ctx.db,
|
||||
internalCustomerId,
|
||||
inStatuses: undefined,
|
||||
});
|
||||
|
||||
if (cusProducts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = [...cusProducts].sort(
|
||||
(a, b) => (a.created_at ?? 0) - (b.created_at ?? 0),
|
||||
);
|
||||
|
||||
return sorted[sorted.length - 1]!.id;
|
||||
};
|
||||
|
||||
const getBaselineCusProductId = () => {
|
||||
if (!proMonthlyCusProductId) {
|
||||
throw new Error(
|
||||
"Baseline CusProduct ID was not set from initial purchase",
|
||||
);
|
||||
}
|
||||
|
||||
return proMonthlyCusProductId;
|
||||
};
|
||||
const updateBaselineCusProductId = (cusProductId: string) => {
|
||||
proMonthlyCusProductId = cusProductId;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// 1. Configure org with RevenueCat processor config
|
||||
if (
|
||||
ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !==
|
||||
RC_WEBHOOK_SECRET
|
||||
) {
|
||||
await OrgService.update({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
updates: {
|
||||
processor_configs: {
|
||||
...ctx.org.processor_configs,
|
||||
revenuecat: {
|
||||
api_key: encryptData("mock_rc_api_key_live"),
|
||||
sandbox_api_key: encryptData("mock_rc_api_key_sandbox"),
|
||||
project_id: "mock_project_live",
|
||||
sandbox_project_id: "mock_project_sandbox",
|
||||
webhook_secret: RC_WEBHOOK_SECRET,
|
||||
sandbox_webhook_secret: RC_WEBHOOK_SECRET,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize RevenueCat webhook client
|
||||
rcClient = new RevenueCatWebhookClient({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
webhookSecret: RC_WEBHOOK_SECRET,
|
||||
});
|
||||
|
||||
// 2-4. Create products, mappings, and customer concurrently
|
||||
await Promise.all([
|
||||
initProductsV0({
|
||||
ctx,
|
||||
products: [proMonthly, proYearly, addOnPack],
|
||||
prefix: testCase,
|
||||
}),
|
||||
RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: proMonthly.id,
|
||||
revenuecat_product_ids: [RC_PRO_MONTHLY_ID],
|
||||
},
|
||||
}),
|
||||
RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: addOnPack.id,
|
||||
revenuecat_product_ids: [RC_ADD_ON_ID],
|
||||
},
|
||||
}),
|
||||
RCMappingService.upsert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
org_id: ctx.org.id,
|
||||
env: AppEnv.Sandbox,
|
||||
autumn_product_id: proYearly.id,
|
||||
revenuecat_product_ids: [RC_PRO_YEARLY_ID],
|
||||
},
|
||||
}),
|
||||
initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const dbCustomer = await ctx.db.query.customers.findFirst({
|
||||
where: eq(customers.id, customerId),
|
||||
});
|
||||
expect(dbCustomer).toBeDefined();
|
||||
internalCustomerId = dbCustomer!.internal_id;
|
||||
});
|
||||
|
||||
test("should create customer with pro monthly product", async () => {
|
||||
const result = await rcClient.initialPurchase({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
|
||||
proMonthlyCusProductId = await fetchLatestActiveCusProductId();
|
||||
});
|
||||
|
||||
test("should upgrade customer to pro yearly product upon renewal", async () => {
|
||||
const result = await rcClient.renewal({
|
||||
productId: RC_PRO_YEARLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
await fetchLatestActiveCusProductId();
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proYearly.id);
|
||||
});
|
||||
|
||||
test("should downgrade customer to pro monthly product upon initial purchase", async () => {
|
||||
const result = await rcClient.initialPurchase({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
|
||||
const currentCusProductId = await fetchLatestActiveCusProductId();
|
||||
console.log("currentCusProductId", currentCusProductId);
|
||||
expect(currentCusProductId).not.toBe(getBaselineCusProductId());
|
||||
updateBaselineCusProductId(currentCusProductId);
|
||||
});
|
||||
|
||||
test("should go to cancelling state upon cancellation", async () => {
|
||||
const result = await rcClient.cancellation({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
expirationAtMs: Date.now() + 1000 * 60 * 60 * 24 * 30,
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
const canceledAt = customer.products[0].canceled_at ?? 0;
|
||||
expect(typeof canceledAt).toBe("number");
|
||||
expect(Math.abs(Date.now() - canceledAt)).toBeLessThanOrEqual(3000);
|
||||
|
||||
const currentCusProductId = await fetchLatestActiveCusProductId();
|
||||
expect(currentCusProductId).toBe(getBaselineCusProductId());
|
||||
});
|
||||
|
||||
test("should uncancel customer after cancellation event", async () => {
|
||||
const result = await rcClient.uncancellation({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
expect(customer.products[0].canceled_at).toBeNull();
|
||||
|
||||
const currentCusProductId = await fetchLatestActiveCusProductId();
|
||||
expect(currentCusProductId).toBe(getBaselineCusProductId());
|
||||
});
|
||||
|
||||
test("should mark product as past due upon billing issue", async () => {
|
||||
const result = await rcClient.billingIssue({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(proMonthly.id);
|
||||
expect(String(customer.products[0].status)).toBe("past_due");
|
||||
|
||||
const currentCusProductId = await fetchLatestActiveCusProductId();
|
||||
expect(currentCusProductId).toBe(getBaselineCusProductId());
|
||||
});
|
||||
|
||||
test("should go to expired state upon expiration", async () => {
|
||||
const result = await rcClient.expiration({
|
||||
productId: RC_PRO_MONTHLY_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "1234567890",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(0);
|
||||
|
||||
const latestCusProductId = await fetchLatestCusProductIdAnyStatus();
|
||||
// After expiration, there may no longer be a cus_product row at all. In that
|
||||
// case, we just assert there are no cus_products for this customer anymore.
|
||||
if (latestCusProductId === null) {
|
||||
const allCusProducts = await CusProductService.list({
|
||||
db: ctx.db,
|
||||
internalCustomerId: internalCustomerId!,
|
||||
inStatuses: undefined,
|
||||
});
|
||||
expect(allCusProducts.length).toBe(0);
|
||||
} else {
|
||||
expect(latestCusProductId).toBe(getBaselineCusProductId());
|
||||
}
|
||||
});
|
||||
|
||||
test("should attach add-on product after expiration via non-renewing purchase", async () => {
|
||||
const result = await rcClient.nonRenewingPurchase({
|
||||
productId: RC_ADD_ON_ID,
|
||||
appUserId: customerId,
|
||||
originalTransactionId: "add_on_tx_12345",
|
||||
});
|
||||
expectWebhookSuccess(result);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expect(customer).toBeDefined();
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.products).toHaveLength(1);
|
||||
expect(customer.products[0].id).toBe(addOnPack.id);
|
||||
|
||||
const addOnCusProducts = await CusProductService.getByProductId({
|
||||
db: ctx.db,
|
||||
productId: addOnPack.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
limit: 1,
|
||||
});
|
||||
expect(
|
||||
addOnCusProducts.length > 0,
|
||||
`CusProduct for add-on product ${addOnPack.id} should exist`,
|
||||
).toBe(true);
|
||||
const addOnCusProductId = addOnCusProducts[0]!.id;
|
||||
expect(typeof addOnCusProductId).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -1,329 +0,0 @@
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
|
||||
type RevenueCatEventType =
|
||||
| "INITIAL_PURCHASE"
|
||||
| "RENEWAL"
|
||||
| "CANCELLATION"
|
||||
| "UNCANCELLATION"
|
||||
| "EXPIRATION"
|
||||
| "NON_RENEWING_PURCHASE"
|
||||
| "BILLING_ISSUE"
|
||||
| "PRODUCT_CHANGE";
|
||||
|
||||
interface BaseWebhookEvent {
|
||||
product_id: string;
|
||||
app_user_id: string;
|
||||
original_app_user_id?: string;
|
||||
original_transaction_id?: string;
|
||||
}
|
||||
|
||||
interface CancellationEvent extends BaseWebhookEvent {
|
||||
expiration_at_ms: number;
|
||||
}
|
||||
|
||||
interface RenewalEvent extends BaseWebhookEvent {}
|
||||
|
||||
interface ExpirationEvent extends BaseWebhookEvent {
|
||||
expiration_at_ms?: number;
|
||||
}
|
||||
|
||||
interface RevenueCatWebhookClientConfig {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
webhookSecret: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock client for sending RevenueCat webhook events in tests
|
||||
*/
|
||||
export class RevenueCatWebhookClient {
|
||||
private orgId: string;
|
||||
private env: AppEnv;
|
||||
private webhookSecret: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor({
|
||||
orgId,
|
||||
env,
|
||||
webhookSecret,
|
||||
baseUrl = "http://localhost:8080",
|
||||
}: RevenueCatWebhookClientConfig) {
|
||||
this.orgId = orgId;
|
||||
this.env = env;
|
||||
this.webhookSecret = webhookSecret;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private get webhookUrl(): string {
|
||||
return `${this.baseUrl}/webhooks/revenuecat/${this.orgId}/${this.env}`;
|
||||
}
|
||||
|
||||
private async sendEvent({
|
||||
type,
|
||||
event,
|
||||
}: {
|
||||
type: RevenueCatEventType;
|
||||
event: Record<string, unknown>;
|
||||
}): 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)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -39,7 +39,6 @@ import { migrationJobs } from "../models/migrationModels/migrationJobTable.js";
|
||||
import { organizationsRelations } from "../models/orgModels/orgRelations.js";
|
||||
import { organizations } from "../models/orgModels/orgTable.js";
|
||||
import { metadata } from "../models/otherModels/metadataTable.js";
|
||||
import { revenuecatMappings } from "../models/processorModels/revenuecatModels/revenuecatMappingsTable.js";
|
||||
import { vercelResources } from "../models/processorModels/vercelModels/vercelResourcesTable.js";
|
||||
// Product Relations
|
||||
import { entitlementsRelations } from "../models/productModels/entModels/entRelations.js";
|
||||
@@ -104,7 +103,6 @@ export {
|
||||
replaceables,
|
||||
rollovers,
|
||||
vercelResources,
|
||||
revenuecatMappings as revcatMappings,
|
||||
// Auth
|
||||
user,
|
||||
session,
|
||||
|
||||
@@ -167,7 +167,4 @@ export const ErrCode = {
|
||||
VercelSubscriptionAlreadyExists: "vercel_subscription_already_exists",
|
||||
VercelSubscriptionNotFound: "vercel_subscription_not_found",
|
||||
VercelResourceNotFound: "vercel_resource_not_found",
|
||||
|
||||
// Products
|
||||
ProductNotFound: "product_not_found",
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { freeTrials } from "../productModels/freeTrialModels/freeTrialTable.js";
|
||||
import { products } from "../productModels/productTable.js";
|
||||
|
||||
export type CustomerProductProcessor = {
|
||||
type: "stripe" | "revenuecat";
|
||||
type: "stripe";
|
||||
id: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,5 +15,4 @@ export enum Duration {
|
||||
|
||||
export enum ProcessorType {
|
||||
Stripe = "stripe",
|
||||
RevenueCat = "revenuecat",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod/v4";
|
||||
import { ProcessorType } from "./genEnums";
|
||||
|
||||
/**
|
||||
* Customer-level Vercel processor
|
||||
@@ -88,40 +87,13 @@ export const UpsertVercelProcessorConfigSchema = z.object({
|
||||
marketplace_mode: z.enum(VercelMarketplaceMode).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Organization-level RevenueCat processor configuration
|
||||
* Stores API key, project ID, and webhook secret
|
||||
*/
|
||||
export const RevenueCatProcessorConfigSchema = z.object({
|
||||
api_key: z.string(),
|
||||
sandbox_api_key: z.string().optional(),
|
||||
project_id: z.string().optional(),
|
||||
sandbox_project_id: z.string().optional(),
|
||||
webhook_secret: z.string(),
|
||||
sandbox_webhook_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpsertRevenueCatProcessorConfigSchema = z.object({
|
||||
api_key: z.string().min(8).optional(),
|
||||
sandbox_api_key: z.string().min(8).optional(),
|
||||
project_id: z.string().min(1).optional(),
|
||||
sandbox_project_id: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Container for all processor configurations at organization level
|
||||
*/
|
||||
export const ProcessorConfigsSchema = z.object({
|
||||
vercel: VercelProcessorConfigSchema.optional(),
|
||||
revenuecat: RevenueCatProcessorConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export const ExternalSubIDSchema = z.object({
|
||||
type: z.enum(ProcessorType),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type ExternalSubID = z.infer<typeof ExternalSubIDSchema>;
|
||||
// Export inferred types for backward compatibility
|
||||
export type VercelProcessor = z.infer<typeof VercelProcessorSchema>;
|
||||
export type ExternalProcessors = z.infer<typeof ExternalProcessorsSchema>;
|
||||
@@ -129,10 +101,4 @@ export type VercelProcessorConfig = z.infer<typeof VercelProcessorConfigSchema>;
|
||||
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<typeof ProcessorConfigsSchema>;
|
||||
|
||||
@@ -36,18 +36,6 @@ export const FrontendOrgSchema = z.object({
|
||||
custom_payment_method: z.string().optional(),
|
||||
marketplace_mode: z.enum(VercelMarketplaceMode).optional(),
|
||||
}),
|
||||
revenuecat: z.object({
|
||||
connected: z.boolean(),
|
||||
/** These API Keys are also masked in the frontend
|
||||
* - e.g test_******3a
|
||||
*/
|
||||
api_key: z.string().optional(),
|
||||
sandbox_api_key: z.string().optional(),
|
||||
project_id: z.string().optional(),
|
||||
sandbox_project_id: z.string().optional(),
|
||||
webhook_secret: z.string().optional(),
|
||||
sandbox_webhook_secret: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
// Vercel + RevenueCat
|
||||
export * from "./revenuecatModels/revenuecatMappingsTable.js";
|
||||
// Vercel
|
||||
export * from "./vercelModels/vercelResourcesTable.js";
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { AppEnv } from "@models/genModels/genEnums";
|
||||
import { organizations } from "@models/orgModels/orgTable.js";
|
||||
import { foreignKey, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Drizzle table typing workaround for Bun TS2742 error
|
||||
export const revenuecatMappings: any = pgTable(
|
||||
"revenuecat_mappings",
|
||||
{
|
||||
org_id: text("org_id").notNull(),
|
||||
env: text("env").$type<AppEnv>().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;
|
||||
@@ -29,15 +29,6 @@ export const FrontendProductSchema = ProductV2Schema.extend({
|
||||
.enum(["recurring", "one-off", "usage"])
|
||||
.default("recurring")
|
||||
.nullable(),
|
||||
external_processors: z
|
||||
.object({
|
||||
revenuecat: z
|
||||
.object({
|
||||
linked_product_id: z.string().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export type ProductV2 = z.infer<typeof ProductV2Schema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
|
||||
import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
|
||||
|
||||
export const isCanceled = ({ cusProduct }: { cusProduct: FullCusProduct }) => {
|
||||
return cusProduct.canceled;
|
||||
|
||||
@@ -3,11 +3,7 @@ import type { SortCusEntParams } from "../../models/cusProductModels/cusEntModel
|
||||
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
|
||||
import type { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels.js";
|
||||
import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js";
|
||||
import type {
|
||||
CusProduct,
|
||||
FullCusProduct,
|
||||
} from "../../models/cusProductModels/cusProductModels.js";
|
||||
import { ProcessorType } from "../../models/genModels/genEnums.js";
|
||||
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
|
||||
import type { BillingType } from "../../models/productModels/priceModels/priceEnums.js";
|
||||
import type { FullProduct } from "../../models/productModels/productModels.js";
|
||||
import { cusEntMatchesEntity } from "../cusEntUtils/filterCusEntUtils.js";
|
||||
@@ -159,7 +155,3 @@ export const cusProductToProduct = ({
|
||||
free_trial: cusProduct.free_trial,
|
||||
} as FullProduct;
|
||||
};
|
||||
|
||||
export const cusProductToProcessorType = (cusProduct: CusProduct) => {
|
||||
return cusProduct.processor?.type ?? ProcessorType.Stripe;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export const PageSectionHeader = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-0 z-10 border-y bg-stone-100 dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800 pl-10 pr-7 h-10 flex justify-between items-center",
|
||||
"sticky top-0 z-10 border-y bg-stone-100 pl-10 pr-7 h-10 flex justify-between items-center",
|
||||
isOnboarding && "px-2",
|
||||
menuComponent && "pr-0",
|
||||
className,
|
||||
@@ -36,7 +36,7 @@ export const PageSectionHeader = ({
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"text-sm text-t2 dark:text-neutral-100 font-medium",
|
||||
"text-sm text-t2 font-medium",
|
||||
classNames?.title,
|
||||
isSecondary && "text-sm",
|
||||
)}
|
||||
|
||||
@@ -364,31 +364,3 @@ export const FreeTrialIcon = ({
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const RevenueCatIcon = ({
|
||||
size = 32,
|
||||
color = "currentColor",
|
||||
}: {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="RevenueCat"
|
||||
>
|
||||
<path
|
||||
d="M4.45312 5.14566C5.16339 4.97382 6.26308 4.875 7.25602 4.875C9.93964 4.875 11.5462 5.8091 11.5462 8.18654C11.5462 9.64435 10.9564 10.6061 10.0315 11.1299L12.2702 14.9506C12.0961 15.0765 11.7204 15.1489 11.1968 15.1489C10.607 15.1489 10.2181 15.0784 9.93666 14.9506L8.07303 11.6391H7.85834C7.39636 11.6393 6.9349 11.6063 6.47717 11.5403V14.9651C6.28932 15.0784 5.95416 15.1489 5.47171 15.1489C5.00297 15.1489 4.6541 15.0778 4.45312 14.9651V5.14566ZM6.45202 9.74286C6.83305 9.80919 7.21861 9.84224 7.60478 9.84169C8.77126 9.84169 9.48212 9.40107 9.48212 8.29953C9.48212 7.16652 8.73131 6.70073 7.39069 6.70073C7.07607 6.69926 6.76194 6.72727 6.45202 6.78445V9.74286Z"
|
||||
fill={color}
|
||||
></path>
|
||||
<path
|
||||
d="M20.0873 15.4333C20.0873 17.134 19.2255 17.6013 18.5768 17.9154C16.9205 18.7181 14.2672 17.7844 11.4471 17.5002C8.89369 17.1663 6.36123 16.9464 5.96759 17.519C5.81962 17.664 5.78815 18.1312 5.96759 18.3217C6.49705 18.8836 7.87784 18.4919 8.8556 18.3147C9.15896 18.2669 9.46559 18.3796 9.65998 18.6104C9.85436 18.8411 9.90698 19.1549 9.798 19.4334C9.68902 19.712 9.43501 19.913 9.13165 19.9608C8.68965 20.028 8.24297 20.062 7.79558 20.0625C6.84321 20.0625 5.64571 19.8982 4.96608 19.1752C4.62765 18.8156 4.28093 18.1772 4.54815 17.1781C5.05663 15.2847 8.10033 15.7524 11.6266 16.1078C14.0817 16.355 16.7452 17.1781 17.8094 16.4223C18.0807 16.2297 18.5867 15.7979 18.314 15.4333C18.0412 15.0688 17.5947 15.2664 17.008 15.2664C14.0696 15.2664 12.3393 13.3735 12.3393 10.2148C12.3397 7.84182 13.3207 6.18242 14.9197 5.39484C15.6145 5.05261 16.426 4.875 17.3244 4.875C18.1074 4.875 18.9033 5.01239 19.4114 5.31403C19.7272 5.76711 19.8646 6.57678 19.7956 7.18129C19.1366 6.86561 18.5325 6.70074 17.7501 6.70074C15.814 6.70074 14.4819 7.68933 14.4819 10.0774C14.4819 12.4656 15.7999 13.4132 17.6536 13.4132C18.1813 13.4079 18.8953 13.3439 19.7956 13.2212C19.9901 13.9452 20.0873 14.6826 20.0873 15.4333Z"
|
||||
fill={color}
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ export const useAutumnFlags = () => {
|
||||
stripe_key: false,
|
||||
platform: false,
|
||||
vercel: false,
|
||||
revenuecat: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,7 +23,6 @@ export const useAutumnFlags = () => {
|
||||
stripe_key: notNullish(customer.features.stripe_key),
|
||||
platform: notNullish(customer.features.platform),
|
||||
vercel: notNullish(customer.features.vercel),
|
||||
revenuecat: notNullish(customer.features.revenuecat),
|
||||
};
|
||||
|
||||
// Only update storage/state when values actually change
|
||||
@@ -33,8 +31,7 @@ export const useAutumnFlags = () => {
|
||||
flags.webhooks !== nextFlags.webhooks ||
|
||||
flags.stripe_key !== nextFlags.stripe_key ||
|
||||
flags.platform !== nextFlags.platform ||
|
||||
flags.vercel !== nextFlags.vercel ||
|
||||
flags.revenuecat !== nextFlags.revenuecat
|
||||
flags.vercel !== nextFlags.vercel
|
||||
) {
|
||||
setFlags(nextFlags);
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
interface RCMapping {
|
||||
org_id: string;
|
||||
env: string;
|
||||
autumn_product_id: string;
|
||||
revenuecat_product_ids: string[];
|
||||
}
|
||||
|
||||
interface SaveMappingInput {
|
||||
autumn_product_id: string;
|
||||
revenuecat_product_ids: string[];
|
||||
}
|
||||
|
||||
export const useRCMappings = () => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: mappings = [], isLoading } = useQuery({
|
||||
queryKey: ["revenuecat-mappings"],
|
||||
queryFn: async () => {
|
||||
const { data } = await axiosInstance.get<{ mappings: RCMapping[] }>(
|
||||
"/v1/organization/revenuecat/mappings",
|
||||
);
|
||||
return data.mappings;
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (mappingsToSave: SaveMappingInput[]) => {
|
||||
const { data } = await axiosInstance.post<{ mappings: RCMapping[] }>(
|
||||
"/v1/organization/revenuecat/mappings",
|
||||
{ mappings: mappingsToSave },
|
||||
);
|
||||
return data.mappings;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["revenuecat-mappings"] });
|
||||
toast.success("Mappings saved successfully");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to save mappings");
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
mappings,
|
||||
isLoading,
|
||||
saveMappings: saveMutation.mutateAsync,
|
||||
isSaving: saveMutation.isPending,
|
||||
};
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
interface RevenueCatProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RevenueCatProductsResponse {
|
||||
products: RevenueCatProduct[];
|
||||
}
|
||||
|
||||
export const useRCProducts = () => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
|
||||
const fetcher = async () => {
|
||||
try {
|
||||
const { data }: { data: RevenueCatProductsResponse } =
|
||||
await axiosInstance.post("/v1/organization/revenuecat/products");
|
||||
|
||||
return data.products || [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const { data: products = [], isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["revenuecat-products"],
|
||||
queryFn: fetcher,
|
||||
});
|
||||
|
||||
return {
|
||||
products,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
interface RevenueCatConfig {
|
||||
connected: boolean;
|
||||
api_key?: string;
|
||||
sandbox_api_key?: string;
|
||||
project_id?: string;
|
||||
sandbox_project_id?: string;
|
||||
webhook_secret?: string;
|
||||
sandbox_webhook_secret?: string;
|
||||
}
|
||||
|
||||
export const useRevenueCatQuery = () => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const fetcher = async () => {
|
||||
try {
|
||||
const { data }: { data: RevenueCatConfig } = await axiosInstance.get(
|
||||
"/v1/organization/revenuecat",
|
||||
);
|
||||
return data;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["revenuecat"],
|
||||
queryFn: fetcher,
|
||||
});
|
||||
|
||||
return {
|
||||
revenueCatConfig: data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -2,9 +2,6 @@ import type { FullProduct, ProductCounts, ProductV2 } from "@autumn/shared";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
/**
|
||||
* Fetch all products for the current org.
|
||||
*/
|
||||
export const useProductsQuery = () => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -45,7 +42,7 @@ export const useProductsQuery = () => {
|
||||
};
|
||||
|
||||
return {
|
||||
products: data?.products || [],
|
||||
products: (data?.products || []) as ProductV2[],
|
||||
counts: countsData || {},
|
||||
groupToDefaults: data?.groupToDefaults || {},
|
||||
isLoading,
|
||||
|
||||
@@ -17,16 +17,6 @@ export const getStripeCusLink = ({
|
||||
return `${baseUrl}${accountPath}${withTest}/customers/${customerId}`;
|
||||
};
|
||||
|
||||
export const getRevenueCatCusLink = ({
|
||||
customerId,
|
||||
projectId,
|
||||
}: {
|
||||
customerId: string;
|
||||
projectId: string;
|
||||
}) => {
|
||||
return `https://app.revenuecat.com/projects/${projectId}/customers/${customerId}`;
|
||||
};
|
||||
|
||||
export const getStripeSubLink = ({
|
||||
subscriptionId,
|
||||
env,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Feature, FullCusProduct } from "@autumn/shared";
|
||||
import { AppEnv, FeatureUsageType, ProcessorType } from "@autumn/shared";
|
||||
import type { Feature } from "@autumn/shared";
|
||||
import { FeatureUsageType } from "@autumn/shared";
|
||||
import {
|
||||
ArrowSquareOutIcon,
|
||||
CaretDownIcon,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||
import { useDropdownShortcut } from "@/hooks/useDropdownShortcut";
|
||||
@@ -29,7 +28,7 @@ import { CusService } from "@/services/customers/CusService";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { getRevenueCatCusLink, getStripeCusLink } from "@/utils/linkUtils";
|
||||
import { getStripeCusLink } from "@/utils/linkUtils";
|
||||
import { DeleteCustomerDialog } from "@/views/customers/customer/components/DeleteCustomerDialog";
|
||||
import UpdateCustomerDialog from "@/views/customers/customer/components/UpdateCustomerDialog";
|
||||
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
||||
@@ -45,7 +44,6 @@ export function CustomerActions() {
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
const { customer } = useCusQuery();
|
||||
const { features } = useFeaturesQuery();
|
||||
const { org } = useOrg();
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
const env = useEnv();
|
||||
const axiosInstance = useAxiosInstance();
|
||||
@@ -143,8 +141,7 @@ export function CustomerActions() {
|
||||
<UserCircleGearIcon />
|
||||
{portalLoading ? "Opening..." : "Open customer portal"}
|
||||
</DropdownMenuItem>
|
||||
{stripeCustomerId &&
|
||||
customer?.processor?.type === ProcessorType.Stripe && (
|
||||
{stripeCustomerId && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
window.open(
|
||||
@@ -163,37 +160,6 @@ export function CustomerActions() {
|
||||
Open in Stripe
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{((customer?.processor?.id &&
|
||||
customer.processor.type === ProcessorType.RevenueCat) ||
|
||||
customer?.customer_products?.some(
|
||||
(cp: FullCusProduct) =>
|
||||
cp.processor?.type === ProcessorType.RevenueCat,
|
||||
)) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
window.open(
|
||||
getRevenueCatCusLink({
|
||||
customerId: customer.id,
|
||||
projectId:
|
||||
env === AppEnv.Live
|
||||
? (org?.processor_configs?.revenuecat?.project_id?.replace(
|
||||
"proj",
|
||||
"",
|
||||
) ?? "")
|
||||
: (org?.processor_configs?.revenuecat?.sandbox_project_id?.replace(
|
||||
"proj",
|
||||
"",
|
||||
) ?? ""),
|
||||
}),
|
||||
"_blank",
|
||||
);
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<ArrowSquareOutIcon className="size-3.5" />
|
||||
Open in RevenueCat
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
|
||||
import { useDevQuery } from "@/hooks/queries/useDevQuery";
|
||||
import LoadingScreen from "../general/LoadingScreen";
|
||||
import { ApiKeysPage } from "./api-keys/ApiKeysPage";
|
||||
import { ConfigureRevenueCat } from "./configure-revenuecat/ConfigureRevenueCat";
|
||||
import { ConfigureStripe } from "./configure-stripe/ConfigureStripe";
|
||||
import { ConfigureVercel } from "./configure-vercel/ConfigureVercel";
|
||||
import { PublishableKeySection } from "./publishable-key";
|
||||
@@ -18,7 +17,7 @@ export default function DevScreen() {
|
||||
const { queryStates } = useAppQueryStates({ defaultTab: "api_keys" });
|
||||
|
||||
const tab = queryStates.tab;
|
||||
const { pkey, webhooks, vercel, revenuecat } = useAutumnFlags();
|
||||
const { pkey, webhooks, vercel } = useAutumnFlags();
|
||||
|
||||
if (isLoading) return <LoadingScreen />;
|
||||
|
||||
@@ -39,8 +38,6 @@ export default function DevScreen() {
|
||||
)}
|
||||
|
||||
{tab === "vercel" && vercel && <ConfigureVercel />}
|
||||
|
||||
{tab === "revenuecat" && revenuecat && <ConfigureRevenueCat />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useRevenueCatQuery } from "@/hooks/queries/revcat/useRevenueCatQuery";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import LoadingScreen from "@/views/general/LoadingScreen";
|
||||
import { ApiKeyDialog } from "./components/ApiKeyDialog";
|
||||
import { ProjectIdDialog } from "./components/ProjectIdDialog";
|
||||
import { RevenueCatConnectionCard } from "./components/RevenueCatConnectionCard";
|
||||
import { RevenueCatMappingSheet } from "./components/RevenueCatMappingSheet";
|
||||
import { RevenueCatWebhookSecret } from "./components/RevenueCatWebhookSecret";
|
||||
import { RevenueCatWebhookUrl } from "./components/RevenueCatWebhookUrl";
|
||||
|
||||
export const ConfigureRevenueCat = () => {
|
||||
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
|
||||
const [showProjectIdDialog, setShowProjectIdDialog] = useState(false);
|
||||
const [showMappingSheet, setShowMappingSheet] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [projectIdInput, setProjectIdInput] = useState("");
|
||||
|
||||
const { org } = useOrg();
|
||||
const {
|
||||
revenueCatConfig,
|
||||
isLoading: isLoadingRevenueCatAccount,
|
||||
refetch,
|
||||
} = useRevenueCatQuery();
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const env = useEnv();
|
||||
|
||||
const dashboardUrl = "https://app.revenuecat.com/";
|
||||
|
||||
const handleUpdateApiKey = async () => {
|
||||
if (!apiKeyInput.trim()) return;
|
||||
|
||||
setConnecting(true);
|
||||
try {
|
||||
const payload =
|
||||
env === "live"
|
||||
? { api_key: apiKeyInput }
|
||||
: { sandbox_api_key: apiKeyInput };
|
||||
|
||||
await axiosInstance.patch("/v1/organization/revenuecat", payload);
|
||||
|
||||
// Refetch config
|
||||
await refetch();
|
||||
|
||||
setShowApiKeyDialog(false);
|
||||
setApiKeyInput("");
|
||||
} catch (error) {
|
||||
console.error("Failed to update API key:", error);
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProjectId = async () => {
|
||||
if (!projectIdInput.trim()) return;
|
||||
|
||||
setConnecting(true);
|
||||
try {
|
||||
const payload =
|
||||
env === "live"
|
||||
? { project_id: projectIdInput }
|
||||
: { sandbox_project_id: projectIdInput };
|
||||
|
||||
await axiosInstance.patch("/v1/organization/revenuecat", payload);
|
||||
|
||||
// Refetch config
|
||||
await refetch();
|
||||
|
||||
setShowProjectIdDialog(false);
|
||||
setProjectIdInput("");
|
||||
} catch (error) {
|
||||
console.error("Failed to update project ID:", error);
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentWebhookSecret =
|
||||
env === "live"
|
||||
? revenueCatConfig?.webhook_secret
|
||||
: revenueCatConfig?.sandbox_webhook_secret;
|
||||
|
||||
const currentApiKey =
|
||||
env === "live"
|
||||
? revenueCatConfig?.api_key
|
||||
: revenueCatConfig?.sandbox_api_key;
|
||||
|
||||
const currentProjectId =
|
||||
env === "live"
|
||||
? revenueCatConfig?.project_id
|
||||
: revenueCatConfig?.sandbox_project_id;
|
||||
|
||||
const statusDescription = revenueCatConfig?.connected
|
||||
? "Your RevenueCat account is connected."
|
||||
: "Connect your RevenueCat account to start tracking subscriptions.";
|
||||
|
||||
const handleApiKeyClick = useCallback(() => setShowApiKeyDialog(true), []);
|
||||
const handleProjectIdClick = useCallback(
|
||||
() => setShowProjectIdDialog(true),
|
||||
[],
|
||||
);
|
||||
const handleMapProductsClick = useCallback(() => {
|
||||
if (!currentApiKey) {
|
||||
toast.error("You need to link your RevenueCat API Key first");
|
||||
return;
|
||||
}
|
||||
setShowMappingSheet(true);
|
||||
}, [currentApiKey]);
|
||||
|
||||
if (isLoadingRevenueCatAccount) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="px-10 max-w-[600px] flex flex-col gap-4">
|
||||
<RevenueCatConnectionCard
|
||||
isLoading={isLoadingRevenueCatAccount}
|
||||
statusDescription={statusDescription}
|
||||
dashboardUrl={dashboardUrl}
|
||||
currentApiKey={currentApiKey}
|
||||
currentProjectId={currentProjectId}
|
||||
env={env}
|
||||
onApiKeyClick={handleApiKeyClick}
|
||||
onProjectIdClick={handleProjectIdClick}
|
||||
onMapProductsClick={handleMapProductsClick}
|
||||
/>
|
||||
|
||||
<RevenueCatWebhookSecret
|
||||
env={env}
|
||||
webhookSecret={currentWebhookSecret}
|
||||
/>
|
||||
|
||||
<RevenueCatWebhookUrl env={env} orgId={org?.id} />
|
||||
</div>
|
||||
|
||||
<ApiKeyDialog
|
||||
open={showApiKeyDialog}
|
||||
onOpenChange={setShowApiKeyDialog}
|
||||
env={env}
|
||||
currentApiKey={currentApiKey}
|
||||
apiKeyInput={apiKeyInput}
|
||||
onApiKeyInputChange={setApiKeyInput}
|
||||
onSave={handleUpdateApiKey}
|
||||
isLoading={connecting}
|
||||
/>
|
||||
|
||||
<ProjectIdDialog
|
||||
open={showProjectIdDialog}
|
||||
onOpenChange={setShowProjectIdDialog}
|
||||
env={env}
|
||||
currentProjectId={currentProjectId}
|
||||
projectIdInput={projectIdInput}
|
||||
onProjectIdInputChange={setProjectIdInput}
|
||||
onSave={handleUpdateProjectId}
|
||||
isLoading={connecting}
|
||||
/>
|
||||
|
||||
<RevenueCatMappingSheet
|
||||
open={showMappingSheet}
|
||||
onOpenChange={setShowMappingSheet}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/v2/dialogs/Dialog";
|
||||
import { FormLabel } from "@/components/v2/form/FormLabel";
|
||||
import { Input } from "@/components/v2/inputs/Input";
|
||||
|
||||
interface ApiKeyDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
env: string;
|
||||
currentApiKey?: string;
|
||||
apiKeyInput: string;
|
||||
onApiKeyInputChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const ApiKeyDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
env,
|
||||
currentApiKey,
|
||||
apiKeyInput,
|
||||
onApiKeyInputChange,
|
||||
onSave,
|
||||
isLoading,
|
||||
}: ApiKeyDialogProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{currentApiKey ? "Update" : "Add"}{" "}
|
||||
{env === "live" ? "API Key" : "Sandbox API Key"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your RevenueCat {env === "live" ? "" : "sandbox "}API key. You
|
||||
can find this in your RevenueCat dashboard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<FormLabel>
|
||||
<span className="text-t2">
|
||||
{env === "live" ? "API Key" : "Sandbox API Key"}
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => onApiKeyInputChange(e.target.value)}
|
||||
placeholder="sk_..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onApiKeyInputChange("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
isLoading={isLoading}
|
||||
disabled={!apiKeyInput.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/v2/dialogs/Dialog";
|
||||
import { FormLabel } from "@/components/v2/form/FormLabel";
|
||||
import { Input } from "@/components/v2/inputs/Input";
|
||||
|
||||
interface ProjectIdDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
env: string;
|
||||
currentProjectId?: string;
|
||||
projectIdInput: string;
|
||||
onProjectIdInputChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const ProjectIdDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
env,
|
||||
currentProjectId,
|
||||
projectIdInput,
|
||||
onProjectIdInputChange,
|
||||
onSave,
|
||||
isLoading,
|
||||
}: ProjectIdDialogProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{currentProjectId ? "Update" : "Add"}{" "}
|
||||
{env === "live" ? "Project ID" : "Sandbox Project ID"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your RevenueCat {env === "live" ? "" : "sandbox "}project ID.
|
||||
You can find this in your RevenueCat dashboard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<FormLabel>
|
||||
<span className="text-t2">
|
||||
{env === "live" ? "Project ID" : "Sandbox Project ID"}
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
value={projectIdInput}
|
||||
onChange={(e) => onProjectIdInputChange(e.target.value)}
|
||||
placeholder="Enter project ID..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onProjectIdInputChange("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
isLoading={isLoading}
|
||||
disabled={!projectIdInput.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/v2/cards/Card";
|
||||
import { FormLabel } from "@/components/v2/form/FormLabel";
|
||||
|
||||
interface RevenueCatConnectionCardProps {
|
||||
isLoading: boolean;
|
||||
statusDescription: string;
|
||||
dashboardUrl: string;
|
||||
currentApiKey?: string;
|
||||
env: string;
|
||||
onApiKeyClick: () => void;
|
||||
onProjectIdClick: () => void;
|
||||
onMapProductsClick: () => void;
|
||||
currentProjectId?: string;
|
||||
}
|
||||
|
||||
export const RevenueCatConnectionCard = ({
|
||||
isLoading,
|
||||
statusDescription,
|
||||
dashboardUrl,
|
||||
currentApiKey,
|
||||
env,
|
||||
onApiKeyClick,
|
||||
onProjectIdClick,
|
||||
onMapProductsClick,
|
||||
currentProjectId,
|
||||
}: RevenueCatConnectionCardProps) => {
|
||||
return (
|
||||
<Card className="shadow-none bg-interactive-secondary">
|
||||
<CardHeader>
|
||||
<CardTitle>Connect your RevenueCat account</CardTitle>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
statusDescription && (
|
||||
<CardDescription>
|
||||
{statusDescription}
|
||||
{dashboardUrl && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
Visit the RevenueCat dashboard{" "}
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
)
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{currentApiKey && (
|
||||
<div className="mb-0">
|
||||
<FormLabel className="mb-0 text-body">
|
||||
Current API key:{" "}
|
||||
<code className="bg-interactive-secondary px-2 py-1 rounded">
|
||||
{currentApiKey}
|
||||
</code>
|
||||
</FormLabel>
|
||||
</div>
|
||||
)}
|
||||
{currentProjectId && (
|
||||
<div className="mb-2">
|
||||
<FormLabel className="mb-0 text-body">
|
||||
Current project ID:{" "}
|
||||
<code className="bg-interactive-secondary px-2 py-1 rounded">
|
||||
{currentProjectId}
|
||||
</code>
|
||||
</FormLabel>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onApiKeyClick}>
|
||||
{currentApiKey ? "Update API Key" : "Add API Key"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onProjectIdClick}>
|
||||
{currentProjectId ? "Update Project ID" : "Add Project ID"}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onMapProductsClick}>
|
||||
Map Products
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,313 +0,0 @@
|
||||
import { isFeaturePriceItem } from "@autumn/shared";
|
||||
import { X } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/v2/selects/Select";
|
||||
import {
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
} from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet";
|
||||
import { useRCMappings } from "@/hooks/queries/revcat/useRCMappings";
|
||||
import { useRCProducts } from "@/hooks/queries/revcat/useRCProducts";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
|
||||
interface ProductMapping {
|
||||
autumnProductId: string;
|
||||
autumnProductName: string;
|
||||
revenueCatProductIds: string[];
|
||||
}
|
||||
|
||||
interface RevenueCatMappingSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface RCProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const MappingRow = memo(function MappingRow({
|
||||
mapping,
|
||||
rcProducts,
|
||||
mappedRevenueCatProductIds,
|
||||
onAddProduct,
|
||||
onRemoveProduct,
|
||||
}: {
|
||||
mapping: ProductMapping;
|
||||
rcProducts: RCProduct[];
|
||||
mappedRevenueCatProductIds: string[];
|
||||
onAddProduct: (autumnProductId: string, revenueCatProductId: string) => void;
|
||||
onRemoveProduct: (
|
||||
autumnProductId: string,
|
||||
revenueCatProductId: string,
|
||||
) => void;
|
||||
}) {
|
||||
const selectedProducts = mapping.revenueCatProductIds;
|
||||
const availableProducts = rcProducts.filter(
|
||||
(p) =>
|
||||
!mappedRevenueCatProductIds.includes(p.id) &&
|
||||
!selectedProducts.includes(p.id),
|
||||
);
|
||||
const hasNoRcProducts = rcProducts.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 border border-zinc-200 dark:border-zinc-800 rounded-lg">
|
||||
<div className="font-medium text-t2">{mapping.autumnProductName}</div>
|
||||
|
||||
{/* Display selected products as tags */}
|
||||
{selectedProducts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedProducts.map((productId) => {
|
||||
const product = rcProducts.find((p) => p.id === productId);
|
||||
return (
|
||||
<div
|
||||
key={productId}
|
||||
className="flex items-center gap-1 border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 rounded-lg pl-3 pr-2 py-1 text-xs"
|
||||
>
|
||||
<span className="text-tiny">{product?.name || productId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onRemoveProduct(mapping.autumnProductId, productId)
|
||||
}
|
||||
className="hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-sm p-0.5 transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={12} className="size-3 text-t4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Select to add more products */}
|
||||
{hasNoRcProducts ? (
|
||||
<div className="text-t3 text-xs py-1">
|
||||
No RevenueCat products found. Create products in RevenueCat before
|
||||
mapping.
|
||||
</div>
|
||||
) : availableProducts.length === 0 ? (
|
||||
<div className="text-t3 text-xs py-1">
|
||||
All RevenueCat products are already mapped to Autumn products. Remove
|
||||
an existing mapping to change assignments.
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
onAddProduct(mapping.autumnProductId, value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Add product..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProducts.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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<ProductMapping[]>([]);
|
||||
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<string, (typeof products)[0]>();
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex flex-col overflow-hidden">
|
||||
<SheetHeader
|
||||
title="Map RevenueCat Products"
|
||||
description="Connect your Autumn products to RevenueCat products"
|
||||
noSeparator
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pt-4">
|
||||
{mappings.length === 0 ? (
|
||||
<div className="text-t3 text-sm text-center py-8">
|
||||
No products found. Create products to map them to RevenueCat.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{mappings.map((mapping) => (
|
||||
<MappingRow
|
||||
key={mapping.autumnProductId}
|
||||
mapping={mapping}
|
||||
rcProducts={rcProducts}
|
||||
mappedRevenueCatProductIds={getMappedIdsForProduct(
|
||||
mapping.autumnProductId,
|
||||
)}
|
||||
onAddProduct={handleAddProduct}
|
||||
onRemoveProduct={handleRemoveProduct}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<ShortcutButton
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleCancel}
|
||||
singleShortcut="escape"
|
||||
>
|
||||
Cancel
|
||||
</ShortcutButton>
|
||||
<ShortcutButton
|
||||
className="w-full"
|
||||
onClick={handleSave}
|
||||
metaShortcut="enter"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Save mappings
|
||||
</ShortcutButton>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { CopyableSpan } from "@/components/general/CopyablePre";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { FormLabel } from "@/components/v2/form/FormLabel";
|
||||
|
||||
interface RevenueCatWebhookSecretProps {
|
||||
env: string;
|
||||
webhookSecret?: string;
|
||||
}
|
||||
|
||||
export const RevenueCatWebhookSecret = ({
|
||||
env,
|
||||
webhookSecret,
|
||||
}: RevenueCatWebhookSecretProps) => {
|
||||
return (
|
||||
<div>
|
||||
<FormLabel className="mb-1">
|
||||
<span className="text-t2">
|
||||
{env === "live" ? "Webhook Secret" : "Sandbox Webhook Secret"}
|
||||
</span>
|
||||
</FormLabel>
|
||||
<p className="text-t3 text-sm mb-2">
|
||||
This is the webhook secret for RevenueCat events. You must set this
|
||||
value in the RevenueCat console.
|
||||
</p>
|
||||
{webhookSecret ? (
|
||||
<CopyableSpan
|
||||
text={webhookSecret}
|
||||
className="my-1 leading-6 px-2"
|
||||
copySize={18}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import {
|
||||
CodeGroup,
|
||||
CodeGroupCodeSolidColour,
|
||||
CodeGroupContent,
|
||||
CodeGroupCopyButton,
|
||||
CodeGroupList,
|
||||
CodeGroupTab,
|
||||
} from "@/components/v2/CodeGroup";
|
||||
import { FormLabel } from "@/components/v2/form/FormLabel";
|
||||
|
||||
interface RevenueCatWebhookUrlProps {
|
||||
env: string;
|
||||
orgId?: string;
|
||||
}
|
||||
|
||||
export const RevenueCatWebhookUrl = ({
|
||||
env,
|
||||
orgId,
|
||||
}: RevenueCatWebhookUrlProps) => {
|
||||
const webhookUrl = `https://api.useautumn.com/webhooks/revenuecat/${orgId}/${env}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormLabel className="mb-1 text-t2">
|
||||
<span>Webhook URL</span>
|
||||
</FormLabel>
|
||||
<p className="text-t3 text-sm mb-2">
|
||||
This is the webhook URL for your RevenueCat integration. You should
|
||||
provide this to RevenueCat as the webhook URL in your project settings.
|
||||
</p>
|
||||
<CodeGroup value={env} className="w-fit">
|
||||
<CodeGroupList>
|
||||
<CodeGroupTab value={env}>
|
||||
{env === "live" ? "Live" : "Sandbox"}
|
||||
</CodeGroupTab>
|
||||
<CodeGroupCopyButton
|
||||
onCopy={() => navigator.clipboard.writeText(webhookUrl)}
|
||||
/>
|
||||
</CodeGroupList>
|
||||
<CodeGroupContent
|
||||
value={env}
|
||||
copyText={webhookUrl}
|
||||
className="border-t w-fit"
|
||||
>
|
||||
<CodeGroupCodeSolidColour className="text-primary whitespace-nowrap">
|
||||
{webhookUrl}
|
||||
</CodeGroupCodeSolidColour>
|
||||
</CodeGroupContent>
|
||||
</CodeGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RevenueCatIcon } from "@/components/v2/icons/AutumnIcons";
|
||||
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
|
||||
import { useLocalStorage } from "@/hooks/common/useLocalStorage";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
@@ -35,7 +34,6 @@ export const buildDevSubTabs = ({
|
||||
flags: {
|
||||
webhooks: boolean;
|
||||
vercel: boolean;
|
||||
revenuecat: boolean;
|
||||
};
|
||||
}) => {
|
||||
return [
|
||||
@@ -58,16 +56,6 @@ export const buildDevSubTabs = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(flags.revenuecat
|
||||
? [
|
||||
{
|
||||
title: "RevenueCat",
|
||||
value: "revenuecat",
|
||||
// icon: <PawPrintIcon size={16} weight="fill" />,
|
||||
icon: <RevenueCatIcon size={64} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(flags.webhooks
|
||||
? [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user