Revert "feat: 🎸 revenue cat"

This commit is contained in:
John Yeo
2025-12-22 20:59:26 +00:00
committed by GitHub
parent 93b085cf4c
commit 029c50dcf0
69 changed files with 61 additions and 4425 deletions

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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' \

View File

@@ -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",

View File

@@ -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 });
},
});

View File

@@ -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);
},
});

View File

@@ -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 });
},
});

View File

@@ -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;
}
}

View File

@@ -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;
};

View File

@@ -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;
},
};
};

View File

@@ -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 };
};

View File

@@ -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();
};

View File

@@ -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;
};

View File

@@ -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.
}
},
);

View File

@@ -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 };
};

View File

@@ -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,
});
};

View File

@@ -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,
});
};

View File

@@ -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,
});
};

View File

@@ -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,
});
};

View File

@@ -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,
});
};

View File

@@ -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,
});
}
};

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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?
// }

View File

@@ -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,

View File

@@ -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),

View File

@@ -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 =

View File

@@ -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) {

View File

@@ -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,
});
};

View File

@@ -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,
});
};

View File

@@ -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 });
},
});

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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);
};

View File

@@ -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,
},
],
});
});
},
);

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -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)}`,
);
}
};

View File

@@ -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,

View File

@@ -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",
};

View File

@@ -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;
};

View File

@@ -15,5 +15,4 @@ export enum Duration {
export enum ProcessorType {
Stripe = "stripe",
RevenueCat = "revenuecat",
}

View File

@@ -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>;

View File

@@ -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(),
}),
}),
});

View File

@@ -1,3 +1,2 @@
// Vercel + RevenueCat
export * from "./revenuecatModels/revenuecatMappingsTable.js";
// Vercel
export * from "./vercelModels/vercelResourcesTable.js";

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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",
)}

View File

@@ -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>
);
};

View File

@@ -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);
}

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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
? [
{