Merge branch 'staging' into feat/plan-api

This commit is contained in:
John Yeo
2025-10-20 12:21:13 +01:00
353 changed files with 7658 additions and 3453 deletions

View File

@@ -1,3 +0,0 @@
{
"terminals": []
}

View File

@@ -1,12 +0,0 @@
{
"name": "Autumn dev container",
"image": "mcr.microsoft.com/devcontainers/node:18",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"forwardPorts": [8080],
// "postCreateCommand": "pnpm install",
"postCreateCommand": "apt update && apt install -y zsh",
"settings": {
"terminal.integrated.shell.linux": "/bin/zsh"
},
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

View File

@@ -19,6 +19,36 @@
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
## Error Handling in API Routes
- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes
- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc.
- For internal/unexpected errors (like missing configuration, database errors, etc.), throw `InternalError` from `@autumn/shared`
- The onError middleware automatically converts these errors to appropriate HTTP responses
- Examples:
```typescript
// ❌ BAD - Don't do this
if (!org) {
return c.json({ message: "Org not found", code: "not_found" }, 404);
}
// ✅ GOOD - Validation/expected errors use RecaseError
if (!org) {
throw new RecaseError({
message: "Org not found",
code: ErrCode.NotFound,
statusCode: 404,
});
}
// ✅ GOOD - Internal/unexpected errors use InternalError
if (!upstash) {
throw new InternalError({
message: "Upstash not configured",
code: "upstash_not_configured",
});
}
```
## Bad example
/ root
-> components

View File

@@ -6,15 +6,53 @@
- When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas.
# Linting and Codebase rules
- You can access the biome linter by running `npx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `npx biome check --write <folder or file path>`
- You can access the biome linter by running `bunx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write <folder or file path>`
- Note, biome does not perform typechecking. In which case you need to, you may run `tsc --noEmit --skipLibCheck <folder or file path>`
- This codebase uses Bun as its preferred package manager and Node runtime.
- **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";`
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
- When creating "hooks" folders, don't nest them under "components"
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
## Error Handling in API Routes
- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes
- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc.
- For internal/unexpected errors (like missing configuration, database errors, etc.), throw `InternalError` from `@autumn/shared`
- The onError middleware automatically converts these errors to appropriate HTTP responses
- Examples:
```typescript
// ❌ BAD - Don't do this
if (!org) {
return c.json({ message: "Org not found", code: "not_found" }, 404);
}
// ✅ GOOD - Validation/expected errors use RecaseError
if (!org) {
throw new RecaseError({
message: "Org not found",
code: ErrCode.NotFound,
statusCode: 404,
});
}
// ✅ GOOD - Internal/unexpected errors use InternalError
if (!upstash) {
throw new InternalError({
message: "Upstash not configured",
code: "upstash_not_configured",
});
}
```
## Bad example
/ root
-> components

View File

@@ -1,79 +0,0 @@
# name: Benchmark Server PR
# on:
# pull_request:
# types: [opened, synchronize, reopened]
# paths:
# - 'server/**'
# - 'pnpm-lock.yaml'
# - 'package.json'
# permissions:
# contents: read
# pull-requests: write
# jobs:
# benchmark:
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
# - name: Setup pnpm
# uses: pnpm/action-setup@v2
# with:
# version: latest
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20'
# cache: 'pnpm'
# - name: Install dependencies
# run: pnpm i --no-frozen-lockfile
# - name: Run benchmark
# id: benchmark
# working-directory: ./server
# run: |
# echo "BENCHMARK_OUTPUT<<EOF" >> $GITHUB_OUTPUT
# FULL_OUTPUT=$(pnpm run benchmark 2>&1)
# FILTERED_OUTPUT=$(echo "$FULL_OUTPUT" | grep -v "^> @.*benchmark" | grep -v "^> tsx benchmarks" | grep -v "Benchmark completed successfully")
# echo "$FILTERED_OUTPUT" >> $GITHUB_OUTPUT
# echo "EOF" >> $GITHUB_OUTPUT
# if echo "$FULL_OUTPUT" | grep -q "Benchmark completed successfully"; then
# echo "BENCHMARK_STATUS=✅ Passed" >> $GITHUB_OUTPUT
# else
# echo "BENCHMARK_STATUS=❌ Failed" >> $GITHUB_OUTPUT
# fi
# - name: Comment PR
# uses: actions/github-script@v7
# with:
# script: |
# const output = `${{ steps.benchmark.outputs.BENCHMARK_OUTPUT }}`;
# const status = `${{ steps.benchmark.outputs.BENCHMARK_STATUS }}`;
# const body = `## 📊 Benchmark Results
# **Benchmark CI:** ${status}
# <details>
# <summary>Click to view benchmark results</summary>
# \`\`\`javascript
# ${output}
# \`\`\`
# </details>
# *Benchmark run for commit ${{ github.sha }}*`;
# github.rest.issues.createComment({
# issue_number: context.issue.number,
# owner: context.repo.owner,
# repo: context.repo.repo,
# body: body
# });

526
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,8 @@
"workspaces": [
"server",
"shared",
"vite"
"vite",
"scripts"
],
"type": "module",
"scripts": {
@@ -12,6 +13,7 @@
"dev:simple": "concurrently \"cd shared && bun dev:watch\" \"cd server && bun dev\" \"cd server && bun workers:dev\" \"cd vite && bun dev\"",
"vite:build": "bun -F @autumn/shared build && bun -F @autumn/vite build:bun",
"vite:start": "bun -F @autumn/vite start:bun",
"shared": "bun -F @autumn/shared build",
"server": "bun -F @autumn/shared build && bun -F @autumn/server start",
"workers": "bun -F @autumn/shared build && bun -F @autumn/server workers",
@@ -20,14 +22,19 @@
"server:cron": "pnpm -F server cron:start",
"server:check": "NODE_ENV=production pnpm -F server check",
"setup": "node scripts/setup.js",
"setup:test": "bun scripts/setup-test.ts",
"tests": "bun scripts/test.ts",
"setupci": "node scripts/setupci.js",
"db:push": " bun -F @autumn/shared db:push",
"db:generate": "bun -F @autumn/shared db:generate",
"db:migrate": " bun -F @autumn/shared db:migrate",
"docker:up": "docker compose -f docker-compose.dev.yml up --build",
"docker:up:unix": "docker compose -f docker-compose.unix.yml up --build",
"docker:up:ci": "docker compose -f docker-compose.ci.yml up --build",
"build:all": "pnpm -F shared build && pnpm -F server prod:build && pnpm -F vite build",
"vite:build:bun": "bun -F @autumn/shared build && bun -F @autumn/vite build:bun",
"vite:start:bun": "bun -F @autumn/shared build && bun -F @autumn/vite start:bun"
},

21
scripts/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@autumn/scripts",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"setup": "tsx setup.js",
"setup-test": "tsx setup-test.ts"
},
"dependencies": {
"@autumn/shared": "workspace:*",
"chalk": "^5.3.0",
"dotenv": "^16.5.0",
"inquirer": "^12.6.3"
},
"devDependencies": {
"@types/inquirer": "^9.0.7",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

145
scripts/setup-test.ts Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env node
import chalk from "chalk";
import inquirer from "inquirer";
import { createTestOrg, TEST_ORG_CONFIG } from "./setupTestUtils/createTestOrg.js";
import {
setupStripeTestKey,
setupTunnelUrl,
setupUpstash,
} from "./setupTestUtils/setupPrompts.js";
import { updateEnvFile } from "./setupTestUtils/updateEnvFile.js";
import {
updateSingleEnvVar,
updateMultipleEnvVars,
} from "./setupTestUtils/incrementalEnvUpdate.js";
async function showPreparationChecklist() {
console.log(
chalk.magentaBright("\n================ Autumn Test Setup ================\n"),
);
console.log(
chalk.cyan("This script will set up a test organization for development.\n"),
);
console.log(chalk.yellowBright("Before you begin, please have the following ready:\n"));
console.log(chalk.whiteBright("1. Stripe Test API Key (sk_test_...)"));
console.log(
chalk.gray(" → Used to link Stripe to your test account for payment processing\n"),
);
console.log(chalk.whiteBright("2. Upstash Redis REST URL and Token"));
console.log(
chalk.gray(" → Used for caching customer objects and testing race conditions\n"),
);
console.log(chalk.whiteBright("3. Tunnel URL (e.g., ngrok URL)"));
console.log(
chalk.gray(
" → Points to localhost:8080 so Stripe webhooks can reach your server\n",
),
);
// Prompt user to continue
const { ready } = await inquirer.prompt([
{
type: "confirm",
name: "ready",
message: chalk.cyan("Ready to begin setup?"),
default: true,
},
]);
if (!ready) {
console.log(chalk.yellow("\nSetup cancelled. Run the script again when you're ready!\n"));
process.exit(0);
}
}
async function main() {
// Show preparation checklist
await showPreparationChecklist();
try {
// Import db from server
const { db } = await import("../server/src/db/initDrizzle.js");
// Step 1: Create test organization in database and get API key
const autumnSecretKey = await createTestOrg({ db });
// Save org details immediately
updateMultipleEnvVars({
TESTS_ORG: TEST_ORG_CONFIG.slug,
TESTS_ORG_ID: TEST_ORG_CONFIG.id,
...(autumnSecretKey && { UNIT_TEST_AUTUMN_SECRET_KEY: autumnSecretKey }),
});
// Step 2: Get Stripe test key
const stripeTestKey = await setupStripeTestKey();
// Save Stripe key immediately
updateSingleEnvVar({ key: "STRIPE_TEST_KEY", value: stripeTestKey });
// Step 3: Get Upstash configuration
const { upstashUrl, upstashToken } = await setupUpstash();
// Save Upstash credentials immediately
updateMultipleEnvVars({
UPSTASH_REDIS_REST_URL: upstashUrl,
UPSTASH_REDIS_REST_TOKEN: upstashToken,
});
// Step 4: Get tunnel URL
const tunnelUrl = await setupTunnelUrl();
// Save tunnel URL immediately
updateSingleEnvVar({ key: "STRIPE_WEBHOOK_URL", value: tunnelUrl });
// Step 5: Final update to ensure proper formatting
updateEnvFile({
testOrgSlug: TEST_ORG_CONFIG.slug,
testOrgId: TEST_ORG_CONFIG.id,
autumnSecretKey,
stripeTestKey,
upstashUrl,
upstashToken,
tunnelUrl,
});
console.log(
chalk.magentaBright(
"\n================ Setup Complete! ================\n",
),
);
console.log(chalk.greenBright("🎉 Test organization setup complete! 🎉\n"));
console.log(chalk.cyan("Test Organization Details:"));
console.log(chalk.whiteBright(` Organization: ${TEST_ORG_CONFIG.slug}`));
console.log(chalk.whiteBright(` ID: ${TEST_ORG_CONFIG.id}`));
if (autumnSecretKey) {
console.log(chalk.whiteBright(` Secret Key: ${autumnSecretKey}\n`));
} else {
console.log(
chalk.whiteBright(" Secret Key: (using existing key from .env)\n"),
);
}
console.log(chalk.cyan("Next steps:"));
console.log(
chalk.whiteBright("1. Start your tunnel (e.g., ngrok http 8080)"),
);
console.log(chalk.whiteBright("2. Start your development server"));
console.log(
chalk.whiteBright("3. Run tests with your new test organization!\n"),
);
process.exit(0);
} catch (error) {
console.error(
chalk.red("\n❌ Setup failed:"),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,128 @@
import { type OrgConfig, member, organizations, user } from "@autumn/shared";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import type { DrizzleCli } from "@server/db/initDrizzle.js";
import { createKey } from "@server/internal/dev/api-keys/apiKeyUtils.js";
const TEST_ORG_CONFIG = {
id: "org_2sWv2S8LJ9iaTjLI6UtNsfL88Kt",
slug: "unit-test-org",
name: "Unit Test Org",
createdAt: new Date(1738583937426).toISOString(),
created_at: 1738583937426,
};
/**
* Creates a test organization in the database and generates an API key
*/
export async function createTestOrg({
db,
}: {
db: DrizzleCli;
}): Promise<string | null> {
console.log(
chalk.magentaBright(
"\n================ Creating Test Organization ================\n",
),
);
// Check if org already exists
const existingOrg = await db.query.organizations.findFirst({
where: eq(organizations.id, TEST_ORG_CONFIG.id),
});
if (existingOrg) {
console.log(
chalk.yellowBright(
`Test organization '${TEST_ORG_CONFIG.slug}' already exists. Creating new API key.`,
),
);
// Always create a new API key for existing org
const apiKey = await createKey({
db,
env: "sandbox" as any,
name: "Unit Test Key",
orgId: TEST_ORG_CONFIG.id,
prefix: "am_sk_test",
meta: {
createdBy: "setup-test-script",
createdAt: new Date().toISOString(),
},
userId: undefined,
});
console.log(chalk.greenBright("✅ Created API key for existing organization"));
return apiKey;
}
// Create the test organization
const org = {
id: TEST_ORG_CONFIG.id,
slug: TEST_ORG_CONFIG.slug,
name: TEST_ORG_CONFIG.name,
createdAt: new Date(TEST_ORG_CONFIG.created_at),
created_at: TEST_ORG_CONFIG.created_at,
stripe_connected: false,
default_currency: "usd",
config: {} as OrgConfig,
onboarded: true,
};
await db.insert(organizations).values(org);
console.log(
chalk.greenBright(
`✅ Created test organization: ${TEST_ORG_CONFIG.slug} (${TEST_ORG_CONFIG.id})`,
),
);
// Get first 5 users from database and create memberships
const users = await db.select().from(user).limit(5);
if (users.length > 0) {
const { generateId } = await import("@server/utils/genUtils.js");
const memberships = users.map((u) => ({
id: generateId("mem"),
organizationId: TEST_ORG_CONFIG.id,
userId: u.id,
role: "owner",
createdAt: new Date(),
}));
await db.insert(member).values(memberships);
console.log(
chalk.greenBright(
`✅ Created ${memberships.length} membership(s) for test organization`,
),
);
} else {
console.log(
chalk.yellowBright(
"⚠ No users found in database. Skipping membership creation.",
),
);
}
// Create API key for the new org
const apiKey = await createKey({
db,
env: "sandbox" as any,
name: "Unit Test Key",
orgId: TEST_ORG_CONFIG.id,
prefix: "am_sk_test",
meta: {
createdBy: "setup-test-script",
createdAt: new Date().toISOString(),
},
userId: undefined,
});
console.log(chalk.greenBright("✅ Created API key for test organization"));
return apiKey;
}
export { TEST_ORG_CONFIG };

View File

@@ -0,0 +1,87 @@
import { readFileSync, writeFileSync } from "node:fs";
import chalk from "chalk";
import { envPath } from "./updateEnvFile.js";
/**
* Incrementally updates a single env variable in the .env file
*/
export function updateSingleEnvVar({
key,
value,
}: {
key: string;
value: string;
}) {
try {
const envContent = readFileSync(envPath, "utf-8");
const lines = envContent.split("\n");
// Check if the key already exists
let found = false;
const updatedLines = lines.map((line) => {
const trimmed = line.trim();
if (trimmed.startsWith(`${key}=`)) {
found = true;
return `${key}=${value}`;
}
return line;
});
// If not found, add it to the end
if (!found) {
updatedLines.push(`${key}=${value}`);
}
writeFileSync(envPath, updatedLines.join("\n"));
console.log(chalk.gray(` ✓ Saved ${key} to .env`));
} catch (error) {
console.log(
chalk.red(
` ⚠ Warning: Could not save ${key} to .env. You may need to add it manually.`,
),
);
}
}
/**
* Updates multiple env variables at once
*/
export function updateMultipleEnvVars(vars: Record<string, string>) {
try {
const envContent = readFileSync(envPath, "utf-8");
const lines = envContent.split("\n");
const keysToUpdate = Object.keys(vars);
const foundKeys = new Set<string>();
// Update existing keys
const updatedLines = lines.map((line) => {
const trimmed = line.trim();
for (const key of keysToUpdate) {
if (trimmed.startsWith(`${key}=`)) {
foundKeys.add(key);
return `${key}=${vars[key]}`;
}
}
return line;
});
// Add new keys that weren't found
for (const key of keysToUpdate) {
if (!foundKeys.has(key)) {
updatedLines.push(`${key}=${vars[key]}`);
}
}
writeFileSync(envPath, updatedLines.join("\n"));
for (const key of keysToUpdate) {
console.log(chalk.gray(` ✓ Saved ${key} to .env`));
}
} catch (error) {
console.log(
chalk.red(
" ⚠ Warning: Could not save variables to .env. You may need to add them manually.",
),
);
}
}

View File

@@ -0,0 +1,136 @@
import inquirer from "inquirer";
import chalk from "chalk";
/**
* Prompts user for Stripe test API key
*/
export async function setupStripeTestKey(): Promise<string> {
console.log(
chalk.magentaBright(
"\n================ Stripe Test API Key Setup ================\n",
),
);
console.log(
chalk.cyan(
"This Stripe test API key will be used to link Stripe to your test account.",
),
);
console.log(
chalk.cyan(
"You can find this in your Stripe Dashboard under Developers > API Keys (Test Mode).\n",
),
);
const { stripeTestKey } = await inquirer.prompt([
{
type: "input",
name: "stripeTestKey",
message: chalk.cyan("Enter your Stripe test secret key (sk_test_...):"),
validate: (input: string) => {
if (!input || input.length < 10) {
return "Please enter a valid Stripe test key";
}
if (!input.startsWith("sk_test_")) {
return "Stripe test keys should start with 'sk_test_'";
}
return true;
},
},
]);
return stripeTestKey;
}
/**
* Prompts user for Upstash configuration
*/
export async function setupUpstash(): Promise<{
upstashUrl: string;
upstashToken: string;
}> {
console.log(
chalk.magentaBright("\n================ Upstash Setup ================\n"),
);
console.log(
chalk.cyan(
"Upstash is used for caching the customer object and is important for testing race conditions.",
),
);
console.log(
chalk.cyan(
"You can create a free Upstash Redis instance at https://upstash.com/\n",
),
);
const { upstashUrl } = await inquirer.prompt([
{
type: "input",
name: "upstashUrl",
message: chalk.cyan("Enter your Upstash Redis REST URL:"),
validate: (input: string) => {
if (!input || input.length < 10) {
return "Please enter a valid Upstash URL";
}
if (!input.startsWith("https://")) {
return "Upstash URL should start with 'https://'";
}
return true;
},
},
]);
const { upstashToken } = await inquirer.prompt([
{
type: "input",
name: "upstashToken",
message: chalk.cyan("Enter your Upstash Redis REST token:"),
validate: (input: string) => {
if (!input || input.length < 10) {
return "Please enter a valid Upstash token";
}
return true;
},
},
]);
return { upstashUrl, upstashToken };
}
/**
* Prompts user for tunnel URL
*/
export async function setupTunnelUrl(): Promise<string> {
console.log(
chalk.magentaBright(
"\n================ Tunnel URL Setup ================\n",
),
);
console.log(
chalk.cyan(
"You need a tunnel that points to localhost:8080 (your server URL) to receive Stripe webhooks.",
),
);
console.log(
chalk.cyan("You can use tools like ngrok, localtunnel, or Cloudflare Tunnel."),
);
console.log(chalk.cyan("Example: https://your-subdomain.ngrok.io\n"));
const { tunnelUrl } = await inquirer.prompt([
{
type: "input",
name: "tunnelUrl",
message: chalk.cyan("Enter your tunnel URL:"),
validate: (input: string) => {
if (!input || input.length < 10) {
return "Please enter a valid tunnel URL";
}
if (!input.startsWith("https://") && !input.startsWith("http://")) {
return "Tunnel URL should start with 'http://' or 'https://'";
}
return true;
},
},
]);
return tunnelUrl;
}

View File

@@ -0,0 +1,183 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import chalk from "chalk";
import { config } from "dotenv";
// Get the directory of this script file
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Find server/.env file robustly - works whether running from root or scripts dir
*/
function findEnvPath(): string {
// Try from script directory (scripts/setup-test.ts -> ../server/.env)
const fromScriptDir = resolve(__dirname, "../../server/.env");
if (existsSync(fromScriptDir)) {
return fromScriptDir;
}
// Try from current working directory
const fromCwd = resolve(process.cwd(), "server/.env");
if (existsSync(fromCwd)) {
return fromCwd;
}
// If neither exists, return the path from script dir (will fail later with clear error)
return fromScriptDir;
}
export const envPath = findEnvPath();
// Load existing env vars
config({ path: envPath });
/**
* Updates server/.env with new test configuration
*/
export function updateEnvFile({
testOrgSlug,
testOrgId,
autumnSecretKey,
stripeTestKey,
upstashUrl,
upstashToken,
tunnelUrl,
}: {
testOrgSlug: string;
testOrgId: string;
autumnSecretKey: string | null;
stripeTestKey: string;
upstashUrl: string;
upstashToken: string;
tunnelUrl: string;
}) {
console.log(
chalk.magentaBright(
"\n================ Updating Environment Variables ================\n",
),
);
// Read existing .env file
let envContent = "";
try {
envContent = readFileSync(envPath, "utf-8");
} catch {
console.log(
chalk.red(
`❌ Could not read server/.env file at ${envPath}. Make sure it exists.`,
),
);
process.exit(1);
}
// Parse existing env vars
const envVars = new Map<string, string>();
const lines = envContent.split("\n");
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const eqIndex = trimmed.indexOf("=");
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex);
const value = trimmed.substring(eqIndex + 1);
envVars.set(key, value);
}
}
// Update with new test variables
envVars.set("TESTS_ORG", testOrgSlug);
envVars.set("TESTS_ORG_ID", testOrgId);
// Only update the secret key if a new one was generated
if (autumnSecretKey) {
envVars.set("UNIT_TEST_AUTUMN_SECRET_KEY", autumnSecretKey);
}
envVars.set("STRIPE_TEST_KEY", stripeTestKey);
envVars.set("UPSTASH_REDIS_REST_URL", upstashUrl);
envVars.set("UPSTASH_REDIS_REST_TOKEN", upstashToken);
envVars.set("STRIPE_WEBHOOK_URL", tunnelUrl);
// Build new env content, preserving structure
const sections: string[][] = [];
let currentSection: string[] = [];
let inTestSection = false;
for (const line of lines) {
const trimmed = line.trim();
// Check if this is a section header
if (trimmed.startsWith("#")) {
if (currentSection.length > 0) {
sections.push(currentSection);
currentSection = [];
}
currentSection.push(line);
inTestSection = trimmed.toLowerCase().includes("test");
continue;
}
// Skip test-related vars from existing content - we'll add them fresh
if (
trimmed.startsWith("TESTS_ORG") ||
trimmed.startsWith("UNIT_TEST_AUTUMN_SECRET_KEY") ||
trimmed.startsWith("STRIPE_TEST_KEY") ||
trimmed.startsWith("UPSTASH_REDIS_REST") ||
(trimmed.startsWith("STRIPE_WEBHOOK_URL") && inTestSection)
) {
continue;
}
currentSection.push(line);
}
if (currentSection.length > 0) {
sections.push(currentSection);
}
// Add test configuration section
const testSection = [
"",
"# Test Configuration",
`TESTS_ORG=${testOrgSlug}`,
`TESTS_ORG_ID=${testOrgId}`,
];
// Only add secret key if it was generated/updated
if (autumnSecretKey) {
testSection.push(`UNIT_TEST_AUTUMN_SECRET_KEY=${autumnSecretKey}`);
} else if (envVars.has("UNIT_TEST_AUTUMN_SECRET_KEY")) {
testSection.push(
`UNIT_TEST_AUTUMN_SECRET_KEY=${envVars.get("UNIT_TEST_AUTUMN_SECRET_KEY")}`,
);
}
testSection.push(
`STRIPE_TEST_KEY=${stripeTestKey}`,
"",
"# Upstash (for caching)",
`UPSTASH_REDIS_REST_URL=${upstashUrl}`,
`UPSTASH_REDIS_REST_TOKEN=${upstashToken}`,
"",
"# Tunnel URL (for Stripe webhooks)",
`STRIPE_WEBHOOK_URL=${tunnelUrl}`,
"",
);
sections.push(testSection);
// Write back to file
const newContent = sections.map((s) => s.join("\n")).join("\n");
writeFileSync(envPath, newContent);
console.log(
chalk.greenBright(`✅ Environment variables updated in ${envPath}`),
);
}

279
scripts/test.ts Normal file
View File

@@ -0,0 +1,279 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import chalk from "chalk";
/**
* Recursively finds all test files in a directory
*/
function findTestFiles({ dir }: { dir: string }): string[] {
const files: string[] = [];
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
files.push(...findTestFiles({ dir: fullPath }));
} else if (
entry.endsWith(".ts") &&
!entry.includes("Utils") &&
!entry.includes("utils")
) {
files.push(fullPath);
}
}
return files;
}
/**
* Fuzzy matches a search term against test file paths and returns a score
*/
function fuzzyMatchScore({
search,
filePath,
}: {
search: string;
filePath: string;
}): number {
const searchLower = search.toLowerCase();
const pathLower = filePath.toLowerCase();
const fileName = pathLower.split("/").pop() || "";
// Check if search matches exactly in filename (highest priority)
if (fileName === `${searchLower}.ts`) {
return 1000;
}
// Check if filename starts with search (high priority)
if (fileName.startsWith(searchLower)) {
return 500;
}
// Check if search is contained in filename
if (fileName.includes(searchLower)) {
return 100;
}
// Simple fuzzy match - check if all characters appear in order
let searchIndex = 0;
let score = 0;
for (
let i = 0;
i < pathLower.length && searchIndex < searchLower.length;
i++
) {
if (pathLower[i] === searchLower[searchIndex]) {
searchIndex++;
score++;
}
}
// Return 0 if not all characters matched
if (searchIndex !== searchLower.length) {
return 0;
}
return score;
}
/**
* Runs shell test scripts from server/shell/ directory or individual test files
*/
async function runTest() {
const scriptName = process.argv[2];
const additionalArgs = process.argv.slice(3);
if (!scriptName) {
console.log(
chalk.red("❌ Please provide a shell script or test file name"),
);
console.log(
chalk.cyan("\nUsage: bun tests <script-name|test-name> [args...]"),
);
console.log(chalk.gray("Examples:"));
console.log(chalk.gray(" bun tests g1"));
console.log(chalk.gray(" bun tests g1 setup"));
console.log(
chalk.gray(" bun tests basic1 # fuzzy matches test file"),
);
console.log(
chalk.gray(" bun tests attach/basic1 # matches path pattern\n"),
);
process.exit(1);
}
const serverDir = resolve(process.cwd(), "server");
const shellScript = resolve(serverDir, "shell", `${scriptName}.sh`);
// First try to find a shell script
if (existsSync(shellScript)) {
const argsDisplay =
additionalArgs.length > 0 ? ` ${additionalArgs.join(" ")}` : "";
console.log(
chalk.cyan(`🧪 Running shell script: ${scriptName}.sh${argsDisplay}\n`),
);
// Create a new process group by spawning with detached: true
const child = spawn("bash", [shellScript, ...additionalArgs], {
cwd: serverDir,
stdio: "inherit",
env: { ...process.env, NODE_ENV: "production" },
detached: true,
});
// Store the process group ID
const pgid = child.pid;
// Forward termination signals to entire process group
const killProcessGroup = () => {
if (pgid) {
try {
// Kill the entire process group with SIGKILL (force kill)
process.kill(-pgid, "SIGKILL");
} catch (_err) {
// Process group might already be dead
}
}
};
process.on("SIGINT", () => {
console.log(chalk.yellow("\n⚠ Received SIGINT, killing all test processes...\n"));
killProcessGroup();
process.exit(130);
});
process.on("SIGTERM", () => {
console.log(chalk.yellow("\n⚠ Received SIGTERM, killing all test processes...\n"));
killProcessGroup();
process.exit(143);
});
process.on("exit", () => {
killProcessGroup();
});
child.on("exit", (code) => {
if (code === 0) {
console.log(
chalk.green(`\n✅ Test ${scriptName} completed successfully`),
);
} else {
console.log(
chalk.red(`\n❌ Test ${scriptName} failed with code ${code}`),
);
process.exit(code || 1);
}
});
child.on("error", (error) => {
console.log(chalk.red(`\n❌ Error running test: ${error.message}`));
process.exit(1);
});
return;
}
// If not a shell script, try fuzzy matching test files
console.log(
chalk.cyan(`🔍 Searching for test file matching: ${scriptName}\n`),
);
const testsDir = resolve(serverDir, "tests");
const allTestFiles = findTestFiles({ dir: testsDir });
// Find matches with scores
const matches = allTestFiles
.map((file) => ({
path: file,
relative: relative(serverDir, file),
score: fuzzyMatchScore({ search: scriptName, filePath: file }),
}))
.filter((match) => match.score > 0)
.sort((a, b) => b.score - a.score);
if (matches.length === 0) {
console.log(chalk.red(`❌ No test file found matching: ${scriptName}`));
console.log(chalk.gray(` Searched in: ${testsDir}\n`));
process.exit(1);
}
const bestMatch = matches[0];
const otherMatches = matches.slice(1, 5);
if (otherMatches.length > 0) {
console.log(
chalk.yellow(`⚠️ Multiple matches found, picking best match:\n`),
);
console.log(chalk.green(`${bestMatch.relative} (selected)`));
for (const match of otherMatches) {
console.log(chalk.gray(` ${match.relative}`));
}
console.log();
}
const testFile = bestMatch;
console.log(chalk.green(`✓ Found: ${testFile.relative}\n`));
console.log(chalk.cyan(`🧪 Running test file...\n`));
// Run the test file with mocha
const child = spawn("bunx", ["mocha", "--timeout", "0", testFile.path], {
cwd: serverDir,
stdio: "inherit",
env: { ...process.env, NODE_ENV: "production" },
detached: true,
});
// Store the process group ID
const pgid = child.pid;
// Forward termination signals to entire process group
const killProcessGroup = () => {
if (pgid) {
try {
// Kill the entire process group with SIGKILL (force kill)
process.kill(-pgid, "SIGKILL");
} catch (_err) {
// Process group might already be dead
}
}
};
process.on("SIGINT", () => {
console.log(chalk.yellow("\n⚠ Received SIGINT, killing test process...\n"));
killProcessGroup();
process.exit(130);
});
process.on("SIGTERM", () => {
console.log(chalk.yellow("\n⚠ Received SIGTERM, killing test process...\n"));
killProcessGroup();
process.exit(143);
});
process.on("exit", () => {
killProcessGroup();
});
child.on("exit", (code) => {
if (code === 0) {
console.log(
chalk.green(`\n✅ Test ${scriptName} completed successfully`),
);
} else {
console.log(
chalk.red(`\n❌ Test ${scriptName} failed with code ${code}`),
);
process.exit(code || 1);
}
});
child.on("error", (error) => {
console.log(chalk.red(`\n❌ Error running test: ${error.message}`));
process.exit(1);
});
}
runTest();

25
scripts/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": false,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"types": ["node"],
"baseUrl": ".",
"paths": {
"@server/*": ["../server/src/*"],
"@shared/*": ["../shared/*"]
}
},
"include": ["**/*.ts", "**/*.js"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -24,6 +24,7 @@
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "^1.2.10",
"@amplitude/analytics-node": "^1.5.18",
"@anthropic-ai/sdk": "^0.32.1",
"@autumn/shared": "workspace:*",
"@axiomhq/pino": "^1.3.1",
@@ -95,7 +96,7 @@
"recaseai": "^0.0.37",
"resend": "^4.1.1",
"semver": "^7.7.2",
"stripe": "^18.4.0",
"stripe": "18.4.0-beta.2",
"svix": "^1.45.1",
"tsc-alias": "^1.8.16",
"ws": "^8.18.0",

31
server/register.ts Normal file
View File

@@ -0,0 +1,31 @@
import "dotenv/config";
import Stripe from "stripe";
const main = async () => {
const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || "");
const result = await stripe.webhookEndpoints.create({
url: "https://api.useautumn.com/webhooks/connect/sandbox",
enabled_events: [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.discount.deleted",
"invoice.paid",
"invoice.upcoming",
"invoice.created",
"invoice.finalized",
"invoice.updated",
"subscription_schedule.canceled",
"subscription_schedule.updated",
],
connect: true,
});
console.log(result);
};
main()
.catch(console.error)
.then(() => process.exit(0));

View File

@@ -1,4 +1,4 @@
#!/bin/bash
MOCHA_SETUP="bunx mocha tests/00_setup.ts"
MOCHA_SETUP="bunx mocha --timeout 10000000 tests/00_setup.ts"
MOCHA_CMD="bunx mocha --parallel -j 6 --timeout 10000000 --ignore tests/00_setup.ts"

View File

@@ -7,20 +7,19 @@ source "$(dirname "$0")/config.sh"
if [[ "$1" == *"setup"* ]]; then
MOCHA_PARALLEL=true $MOCHA_SETUP
fi
# $MOCHA_CMD 'tests/advanced/referrals/*.ts' 'tests/advanced/coupons/*.ts'
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
# 'tests/advanced/coupons/*.ts' \
# 'tests/attach/updateQuantity/*.ts' \
# 'tests/advanced/referrals/*.ts' \
# 'tests/advanced/referrals/paid/*.ts' \
# 'tests/advanced/rollovers/*.ts' \
# 'tests/advanced/customInterval/*.ts'
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
'tests/advanced/coupons/*.ts' \
'tests/attach/updateQuantity/*.ts' \
'tests/advanced/referrals/*.ts' \
'tests/advanced/referrals/paid/*.ts' \
'tests/advanced/rollovers/*.ts' \
'tests/advanced/customInterval/*.ts'
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
# 'tests/advanced/usageLimit/*.ts'
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
'tests/advanced/usageLimit/*.ts'
# $MOCHA_CMD 'tests/advanced/usage/*.ts'
$MOCHA_CMD 'tests/advanced/usage/*.ts'

View File

@@ -15,8 +15,8 @@ import {
import type Stripe from "stripe";
import { initDrizzle } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";

View File

@@ -12,7 +12,7 @@ import { UTCDate } from "@date-fns/utc";
import chalk from "chalk";
import { format, getDate, getMonth, setDate } from "date-fns";
import { Decimal } from "decimal.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";

View File

@@ -0,0 +1,121 @@
import { AppEnv, InternalError, type Organization } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { decryptData } from "@/utils/encryptUtils.js";
import type { Logger } from "../logtail/logtailUtils.js";
import { initMasterStripe } from "./initStripeCli.js";
export const orgToAccountId = ({
org,
env,
noDefaultAccount = false,
}: {
org: Organization;
env: AppEnv;
noDefaultAccount?: boolean;
}): string | undefined => {
if (env === AppEnv.Sandbox) {
const config = org.test_stripe_connect;
if (noDefaultAccount) {
return config?.account_id;
}
return config?.account_id || config?.default_account_id;
} else {
return org.live_stripe_connect?.account_id;
}
};
export const deauthorizeAccount = async ({
accountId,
env,
logger,
}: {
accountId: string;
env: AppEnv;
logger: Logger;
}) => {
// OAuth-connected accounts must be deauthorized, not deleted
// Platform-managed accounts can be deleted
const masterStripe = initMasterStripe({ env });
try {
await masterStripe.oauth.deauthorize({
client_id:
env === AppEnv.Live
? process.env.STRIPE_LIVE_CLIENT_ID || ""
: process.env.STRIPE_SANDBOX_CLIENT_ID || "",
stripe_user_id: accountId,
});
logger.info(`Deauthorized account ${accountId} for ${env}`);
} catch (error) {
// If deauthorization fails, the account might have already been disconnected
// or it's a platform-managed account that needs to be deleted
logger.error("Failed to deauthorize account, attempting deletion:", error);
}
};
export const deleteConnectedAccount = async ({
accountId,
env,
logger,
}: {
accountId: string;
env: AppEnv;
logger: Logger;
}) => {
const masterStripe = initMasterStripe({ env });
try {
await masterStripe.accounts.del(accountId);
logger.info(`Deleted account ${accountId} for ${env}`);
} catch (error) {
logger.error(`Failed to delete account ${accountId} for ${env}`, error);
}
};
export const shouldUseMaster = ({
org,
env,
}: {
org: Organization;
env: AppEnv;
}) => {
const useMasterOrg =
env === AppEnv.Sandbox
? Boolean(org.test_stripe_connect?.master_org_id) &&
Boolean(org.test_stripe_connect?.account_id)
: Boolean(org.live_stripe_connect?.master_org_id) &&
Boolean(org.live_stripe_connect?.account_id);
if (useMasterOrg && !org.master) {
throw new InternalError({
message: `Master organization not found for ${env} org ${org.id}`,
});
}
if (!useMasterOrg) return false;
return useMasterOrg;
};
export const getConnectWebhookSecret = async ({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) => {
const org = await OrgService.get({ db, orgId });
const prefix = env === AppEnv.Sandbox ? "test" : "live";
const secret = org.stripe_config?.[`${prefix}_connect_webhook_secret`];
if (!secret) {
throw new InternalError({
message: `Connect webhook secret not found for ${env} org ${orgId}`,
});
}
const decrypted = decryptData(secret);
return decrypted;
};

View File

@@ -0,0 +1,71 @@
import {
AppEnv,
ErrCode,
InternalError,
type Organization,
RecaseError,
} from "@autumn/shared";
import Stripe from "stripe";
import { isStripeConnected } from "@/internal/orgs/orgUtils.js";
import { decryptData } from "@/utils/encryptUtils.js";
import { orgToAccountId, shouldUseMaster } from "./connectUtils.js";
import { initMasterStripe, initPlatformStripe } from "./initStripeCli.js";
export const createStripeCli = ({
org,
env,
legacyVersion,
throughSecretKey = false,
}: {
org: Organization;
env: AppEnv;
legacyVersion?: boolean;
throughSecretKey?: boolean;
}) => {
// Try secret key first.
if (isStripeConnected({ org, env, throughSecretKey: true })) {
// Secret key flow
const encrypted =
env === AppEnv.Sandbox
? org.stripe_config?.test_api_key
: org.stripe_config?.live_api_key;
if (!encrypted) {
throw new RecaseError({
message: `Please connect your Stripe ${env === AppEnv.Sandbox ? "test" : "live"} secret key. You can find it here: https://dashboard.stripe.com${env === AppEnv.Sandbox ? "/test" : ""}/apikeys`,
code: ErrCode.StripeConfigNotFound,
statusCode: 400,
});
}
const decrypted = decryptData(encrypted);
return new Stripe(decrypted, {
apiVersion: legacyVersion
? ("2025-02-24.acacia" as any)
: "2025-07-30.basil",
});
}
// Then try account ID
const accountId = orgToAccountId({ org, env });
if (accountId && !throughSecretKey) {
// Check if this org has a master_org_id (platform flow)
const useMaster = shouldUseMaster({ org, env });
if (useMaster) {
return initPlatformStripe({
masterOrg: org.master,
env,
accountId,
legacyVersion,
});
}
// Standard flow - use Autumn's master Stripe keys
return initMasterStripe({ accountId, legacyVersion, env });
}
throw new InternalError({
message: `No stripe account linked to organization ${org.id}`,
});
};

View File

@@ -0,0 +1,119 @@
import {
AppEnv,
InternalError,
type Organization,
RecaseError,
} from "@autumn/shared";
import { decryptData } from "@/utils/encryptUtils.js";
import "dotenv/config";
import Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getConnectWebhookSecret } from "./connectUtils.js";
export const initMasterStripe = (params?: {
accountId?: string;
legacyVersion?: boolean;
env?: AppEnv;
}) => {
let secretKey: string;
if (params?.env === AppEnv.Live) {
if (!process.env.STRIPE_LIVE_SECRET_KEY) {
throw new InternalError({
message: "STRIPE_LIVE_SECRET_KEY env variable is not set",
});
}
secretKey = process.env.STRIPE_LIVE_SECRET_KEY;
} else {
if (!process.env.STRIPE_SANDBOX_SECRET_KEY) {
throw new InternalError({
message: "STRIPE_SANDBOX_SECRET_KEY env variable is not set",
});
}
secretKey = process.env.STRIPE_SANDBOX_SECRET_KEY;
}
// if (!params) {
// return new Stripe(secretKey);
// }
return new Stripe(secretKey, {
stripeAccount: params?.accountId,
apiVersion: params?.legacyVersion
? ("2025-02-24.acacia" as any)
: undefined,
});
};
export const initPlatformStripe = ({
masterOrg,
env,
accountId,
legacyVersion,
}: {
masterOrg: Organization | null;
env: AppEnv;
accountId?: string;
legacyVersion?: boolean;
}) => {
if (!masterOrg) {
throw new InternalError({
message: "Master organization is undefined in initPlatformStripe",
});
}
// Get master org's secret key and validate access to the account
const encrypted =
env === AppEnv.Sandbox
? masterOrg.stripe_config?.test_api_key
: masterOrg.stripe_config?.live_api_key;
if (!encrypted) {
const envLabel = env === AppEnv.Sandbox ? "test" : "live";
throw new RecaseError({
message: `Master organization must have Stripe ${envLabel} secret key connected`,
});
}
const decrypted = decryptData(encrypted);
if (!decrypted) {
throw new InternalError({
message: `Failed to decrypt master organization's Stripe secret key`,
});
}
return new Stripe(decrypted, {
stripeAccount: accountId || undefined,
apiVersion: legacyVersion ? ("2025-02-24.acacia" as any) : undefined,
});
};
export const getStripeWebhookSecret = async ({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId?: string;
env: AppEnv;
}) => {
// If org ID...
if (orgId) {
return await getConnectWebhookSecret({ db, orgId, env });
}
let secret: string;
if (env === AppEnv.Live) {
secret = process.env.STRIPE_LIVE_WEBHOOK_SECRET || "";
} else {
secret = process.env.STRIPE_SANDBOX_WEBHOOK_SECRET || "";
}
if (!secret) {
throw new InternalError({
message: `STRIPE_WEBHOOK_SECRET env variable is not set (${env})`,
});
}
return secret;
};

View File

@@ -0,0 +1,44 @@
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { WEBHOOK_EVENTS } from "@/utils/constants.js";
import { encryptData } from "@/utils/encryptUtils.js";
import { initPlatformStripe } from "./initStripeCli.js";
export const registerConnectWebhook = async ({
ctx,
}: {
ctx: AutumnContext;
}) => {
const { db, org, env, logger } = ctx;
// Init master stripe
const stripeCli = initPlatformStripe({ masterOrg: org, env });
const curWebhookEndpoints = await stripeCli.webhookEndpoints.list();
const backendUrl = process.env.SERVER_URL || process.env.STRIPE_WEBHOOK_URL;
const webhookUrl = `${backendUrl}/webhooks/connect/${env}?org_id=${org.id}`;
if (curWebhookEndpoints.data.some((webhook) => webhook.url === webhookUrl))
return;
const webhook = await stripeCli.webhookEndpoints.create({
url: webhookUrl,
enabled_events:
WEBHOOK_EVENTS as Stripe.WebhookEndpointCreateParams.EnabledEvent[],
connect: true,
});
logger.info(`Registered connect webhook for ${org.slug} ${env}`);
await OrgService.updateConnectWebhookSecret({
db,
orgId: org.id,
env,
secret: encryptData(webhook.secret as string),
});
logger.info(`Updated connect webhook secret for ${org.slug} ${env}`);
return webhook;
};

View File

@@ -103,20 +103,5 @@ export const createLogger = () => {
return createLoggerStructure(pinoLogger);
};
// export const createLogtailAll = () => {
// if (
// !process.env.LOGTAIL_ALL_SOURCE_TOKEN ||
// !process.env.LOGTAIL_ALL_INGESTING_HOST
// ) {
// return null;
// }
// const logtail = new Logtail(process.env.LOGTAIL_ALL_SOURCE_TOKEN!, {
// endpoint: process.env.LOGTAIL_ALL_INGESTING_HOST!,
// });
// return logtail;
// };
export const logger = createLogger();
export type Logger = ReturnType<typeof createLogger>;

View File

@@ -1,16 +0,0 @@
import dotenv from "dotenv";
dotenv.config();
import { PostHog } from "posthog-node";
import { logger } from "../logtail/logtailUtils.js";
export const createPosthogCli = () => {
if (!process.env.POSTHOG_API_KEY) {
logger.warn("POSTHOG_API_KEY not set, skipping posthog");
return null;
}
return new PostHog(process.env.POSTHOG_API_KEY, {
host: process.env.POSTHOG_HOST_URL ?? "https://us.i.posthog.com",
});
};

View File

@@ -1,20 +0,0 @@
import { EventMessage, PostHog } from "posthog-node";
export const posthogCapture = ({
posthog,
params,
}: {
posthog?: PostHog;
params: EventMessage;
}) => {
try {
if (process.env.NODE_ENV === "development" || !posthog) {
return;
}
posthog.capture(params);
} catch (error) {
console.error("Failed to capture posthog event", params);
console.error(error);
}
};

View File

@@ -29,9 +29,9 @@ export const handleAttachRaceCondition = async ({
const originalJson = res.json;
res.json = async function (body: any) {
try {
await clearLock({ lockKey, logger: req.logtail });
await clearLock({ lockKey, logger: req.logger });
} catch (error) {
req.logtail.warn("❗️❗️ Error clearing lock", {
req.logger.warn("❗️❗️ Error clearing lock", {
error,
});
}
@@ -44,7 +44,7 @@ export const handleAttachRaceCondition = async ({
throw error;
}
req.logtail.warn("❗️❗️ Error acquiring lock", {
req.logger.warn("❗️❗️ Error acquiring lock", {
error,
});
return null;

View File

@@ -1,6 +1,6 @@
import type { User } from "better-auth";
import { LoopsClient } from "loops";
import { logger } from "../logtail/logtailUtils.js";
import { User } from "better-auth";
const createLoopsCli = () => {
return new LoopsClient(process.env.LOOPS_API_KEY || "");
@@ -10,9 +10,9 @@ export const createLoopsContact = async (user: User) => {
if (!process.env.LOOPS_API_KEY) return;
try {
let email = user.email;
let firstName = user.name?.split(" ")[0] || "";
let lastName = user.name?.split(" ")[1] || "";
const email = user.email;
const firstName = user.name?.split(" ")[0] || "";
const lastName = user.name?.split(" ")[1] || "";
const loops = createLoopsCli();
const resp = await loops.createContact(email, {

View File

@@ -0,0 +1,273 @@
import type { AppEnv, Organization } from "@autumn/shared";
import chalk from "chalk";
import { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusService } from "@/internal/customers/CusService.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { Logger } from "../logtail/logtailUtils.js";
import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js";
import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js";
import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js";
import { handleInvoiceFinalized } from "./webhookHandlers/handleInvoiceFinalized.js";
import { handleInvoicePaid } from "./webhookHandlers/handleInvoicePaid.js";
import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js";
import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js";
import { handleSubDeleted } from "./webhookHandlers/handleSubDeleted.js";
import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js";
import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js";
const logStripeWebhook = ({
logger,
org,
event,
}: {
logger: Logger;
org: Organization;
event: Stripe.Event;
}) => {
logger.info(
`${chalk.yellow("STRIPE").padEnd(18)} ${event.type.padEnd(30)} ${org.slug} | ${event.id}`,
);
};
const coreEvents = [
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.paid",
"invoice.created",
"invoice.finalized",
"subscription_schedule.canceled",
"checkout.session.completed",
];
const handleStripeWebhookRefresh = async ({
eventType,
data,
db,
org,
env,
logger,
}: {
eventType: string;
data: any;
db: DrizzleCli;
org: Organization;
env: AppEnv;
logger: any;
}) => {
if (coreEvents.includes(eventType)) {
const stripeCusId = data.object.customer;
if (!stripeCusId) {
logger.warn(
`stripe webhook cache refresh, object doesn't contain customer id`,
{
data: {
eventType,
object: data.object,
},
},
);
return;
}
const cus = await CusService.getByStripeId({
db,
stripeId: stripeCusId,
});
if (!cus) {
logger.warn(
`Searched for customer by stripe id, but not found: ${stripeCusId}`,
);
return;
}
await deleteCusCache({
db,
customerId: cus.id!,
org,
env,
});
}
};
/**
* Handles Stripe webhook events after org/env extraction
*/
export const handleStripeWebhookEvent = async ({
event,
db,
org,
env,
logger,
req,
}: {
event: Stripe.Event;
db: DrizzleCli;
org: Organization;
env: AppEnv;
logger: Logger;
req: ExtendedRequest;
}) => {
logStripeWebhook({ logger, org, event });
try {
const stripeCli = createStripeCli({ org, env });
switch (event.type) {
case "customer.subscription.created":
await handleSubCreated({
db,
org,
subData: event.data.object,
env,
logger,
});
break;
case "customer.subscription.updated": {
const subscription = event.data.object;
await handleSubscriptionUpdated({
req,
db,
org,
subscription,
previousAttributes: event.data.previous_attributes,
env,
logger,
});
break;
}
case "customer.subscription.deleted":
await handleSubDeleted({
req,
stripeCli,
data: event.data.object,
logger,
});
break;
case "checkout.session.completed": {
const checkoutSession = event.data.object;
await handleCheckoutSessionCompleted({
req,
db,
data: checkoutSession,
org,
env,
logger,
});
break;
}
case "invoice.paid": {
const invoice = event.data.object;
await handleInvoicePaid({
db,
org,
invoiceData: invoice,
env,
event,
req,
});
break;
}
case "invoice.updated":
await handleInvoiceUpdated({
stripeCli,
env,
event,
req,
});
break;
case "invoice.created": {
const createdInvoice = event.data.object;
await handleInvoiceCreated({
db,
org,
data: createdInvoice,
env,
logger,
});
break;
}
case "invoice.finalized": {
const finalizedInvoice = event.data.object;
await handleInvoiceFinalized({
db,
org,
data: finalizedInvoice,
env,
logger,
});
break;
}
case "subscription_schedule.canceled": {
const canceledSchedule = event.data.object;
await handleSubscriptionScheduleCanceled({
db,
org,
env,
schedule: canceledSchedule,
logger,
});
break;
}
case "customer.discount.deleted":
await handleCusDiscountDeleted({
db,
org,
discount: event.data.object,
env,
logger,
res: req,
});
break;
}
} catch (error) {
if (error instanceof Stripe.errors.StripeError) {
if (error.message.includes("No such customer")) {
logger.warn(`stripe customer missing: ${error.message}`);
return { success: true };
}
if (error.message.includes("Expired API Key provided")) {
await unsetOrgStripeKeys({
db,
org,
env,
});
return { success: true };
}
}
logger.error(`Stripe webhook, error: ${error}`, { error });
throw error;
}
try {
await handleStripeWebhookRefresh({
eventType: event.type,
data: event.data,
db,
org,
env,
logger,
});
} catch (error) {
logger.error(`Stripe webhook, error refreshing cache!`, { error });
return { success: true };
}
return { success: true };
};

View File

@@ -12,9 +12,9 @@ import {
RewardType,
type UsagePriceConfig,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { createStripeCli } from "../utils.js";
const couponToStripeDuration = ({
coupon,

View File

@@ -8,9 +8,9 @@ import {
import { StatusCodes } from "http-status-codes";
import type { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusService } from "@/internal/customers/CusService.js";
import RecaseError from "@/utils/errorUtils.js";
import { createStripeCli } from "./utils.js";
export const getStripeCus = async ({
stripeCli,

View File

@@ -1,11 +1,10 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { Stripe } from "stripe";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AppEnv, Organization, products } from "@autumn/shared";
import type { AppEnv, Organization } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { initProductInStripe } from "@/internal/products/productUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { createStripeCli } from "./utils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
export async function ensureStripeProducts({
db,
@@ -55,9 +54,9 @@ export async function ensureStripeProductsWithEnv({
const updatedOrg = await OrgService.get({ db, orgId: req.org.id });
const batchInit: Promise<void>[] = [];
for (let fullProduct of fullProducts) {
for (const fullProduct of fullProducts) {
const initProduct = async () => {
let existsInStripe = products.data.find(
const existsInStripe = products.data.find(
(p) => p.id === fullProduct.processor?.id,
);

View File

@@ -26,7 +26,7 @@ export const billingIntervalToStripe = ({
}: {
interval: BillingInterval;
intervalCount?: number | null;
}): Stripe.PriceCreateParams.Recurring => {
}): Stripe.PriceCreateParams.Recurring | Record<string, any> => {
const finalCount = intervalCount ?? 1;
switch (interval) {
case BillingInterval.Week:
@@ -55,7 +55,8 @@ export const billingIntervalToStripe = ({
interval_count: finalCount,
};
default:
throw new Error(`billingIntervalToStripe: invalid interval ${interval}`);
// throw new Error(`billingIntervalToStripe: invalid interval ${interval}`);
return {};
}
};

View File

@@ -5,8 +5,8 @@ import {
type Product,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import RecaseError from "@/utils/errorUtils.js";
import { createStripeCli } from "./utils.js";
export const createStripeProduct = async (
org: Organization,

View File

@@ -1,53 +1,22 @@
import express, { Router } from "express";
import stripe, { Stripe } from "stripe";
import chalk from "chalk";
import { AuthType, type Organization } from "@autumn/shared";
import express, { type Router } from "express";
import stripe, { type Stripe } from "stripe";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { AppEnv, AuthType, Organization } from "@autumn/shared";
import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js";
import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js";
import { handleSubDeleted } from "./webhookHandlers/handleSubDeleted.js";
import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js";
import {
getStripeWebhookSecret,
isStripeConnected,
unsetOrgStripeKeys,
} from "@/internal/orgs/orgUtils.js";
import { handleInvoicePaid } from "./webhookHandlers/handleInvoicePaid.js";
import { handleRequestError } from "@/utils/errorUtils.js";
import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js";
import { handleInvoiceFinalized } from "./webhookHandlers/handleInvoiceFinalized.js";
import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js";
import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { createStripeCli } from "./utils.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { CusService } from "@/internal/customers/CusService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js";
import { disconnectStripe } from "@/internal/orgs/handlers/handleDeleteStripe.js";
import { handleStripeWebhookEvent } from "./handleStripeWebhookEvent.js";
export const stripeWebhookRouter: Router = express.Router();
const logStripeWebhook = ({
req,
event,
}: {
req: ExtendedRequest;
event: Stripe.Event;
}) => {
req.logtail.info(
`${chalk.yellow("STRIPE").padEnd(18)} ${event.type.padEnd(30)} ${req.org.slug} | ${event.id}`,
);
};
stripeWebhookRouter.post(
"/:orgId/:env",
express.raw({ type: "application/json" }),
async (request: any, response: any) => {
const sig = request.headers["stripe-signature"];
let event;
let event: Stripe.Event;
const { orgId, env } = request.params;
const { db } = request;
@@ -103,13 +72,13 @@ stripeWebhookRouter.post(
// event = request.body;
request.logtail = request.logtail.child({
request.logger = request.logger.child({
context: {
context: {
// body: request.body,
event_type: event.type,
event_id: event.id,
// @ts-ignore
// @ts-expect-error
object_id: `${event.data?.object?.id}` || "N/A",
authType: AuthType.Stripe,
org_id: orgId,
@@ -119,229 +88,25 @@ stripeWebhookRouter.post(
},
});
let logger = request.logtail;
logStripeWebhook({ req: request, event });
const logger = request.logger;
try {
const stripeCli = createStripeCli({ org, env });
switch (event.type) {
case "customer.subscription.created":
await handleSubCreated({
db,
org,
subData: event.data.object,
env,
logger,
});
break;
case "customer.subscription.updated":
const subscription = event.data.object;
await handleSubscriptionUpdated({
req: request,
db,
org,
subscription,
previousAttributes: event.data.previous_attributes,
env,
logger,
});
break;
case "customer.subscription.deleted":
await handleSubDeleted({
req: request,
stripeCli,
data: event.data.object,
logger,
});
break;
case "checkout.session.completed":
const checkoutSession = event.data.object;
await handleCheckoutSessionCompleted({
req: request,
db,
data: checkoutSession,
org,
env,
logger,
});
break;
// Triggered when payment through Stripe is successful
case "invoice.paid":
const invoice = event.data.object;
await handleInvoicePaid({
db,
org,
invoiceData: invoice,
env,
event,
req: request,
});
break;
case "invoice.updated":
await handleInvoiceUpdated({
stripeCli,
env,
event,
req: request,
});
break;
case "invoice.created":
const createdInvoice = event.data.object;
await handleInvoiceCreated({
db,
org,
data: createdInvoice,
env,
logger,
});
break;
case "invoice.finalized":
const finalizedInvoice = event.data.object;
await handleInvoiceFinalized({
db,
org,
data: finalizedInvoice,
env,
logger,
});
break;
case "subscription_schedule.canceled":
const canceledSchedule = event.data.object;
await handleSubscriptionScheduleCanceled({
db,
org,
env,
schedule: canceledSchedule,
logger,
});
break;
case "customer.discount.deleted":
await handleCusDiscountDeleted({
db,
org,
discount: event.data.object,
env,
logger,
res: response,
});
break;
}
await handleStripeWebhookEvent({
event,
db,
org,
env,
logger,
req: request,
});
response.status(200).send();
} catch (error) {
if (error instanceof Stripe.errors.StripeError) {
if (error.message.includes("No such customer")) {
logger.warn(`stripe customer missing: ${error.message}`);
response.status(200).json({ message: "ok" });
return;
}
if (error.message.includes("Expired API Key provided")) {
// Disconnect Stripe
await unsetOrgStripeKeys({
db,
org,
env,
});
response.status(200).json({ message: "ok" });
return;
}
}
handleRequestError({
req: request,
error,
res: response,
action: "stripe webhook",
});
return;
}
try {
await handleStripeWebhookRefresh({
eventType: event.type,
data: event.data,
db,
org,
env,
logger,
});
} catch (error) {
logger.error(`Stripe webhook, error refreshing cache!`, { error });
}
// DO NOT DELETE -- RESPONSIBLE FOR SENDING SUCCESSFUL RESPONSE TO STRIPE...
response.status(200).send();
},
);
const coreEvents = [
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.paid",
"invoice.created",
"invoice.finalized",
"subscription_schedule.canceled",
"checkout.session.completed",
];
export const handleStripeWebhookRefresh = async ({
eventType,
data,
db,
org,
env,
logger,
}: {
eventType: string;
data: any;
db: DrizzleCli;
org: Organization;
env: AppEnv;
logger: any;
}) => {
if (coreEvents.includes(eventType)) {
let stripeCusId = data.object.customer;
if (!stripeCusId) {
logger.warn(
`stripe webhook cache refresh, object doesn't contain customer id`,
{
data: {
eventType,
object: data.object,
},
},
);
return;
}
let cus = await CusService.getByStripeId({
db,
stripeId: stripeCusId,
});
if (!cus) {
logger.warn(
`Searched for customer by stripe id, but not found: ${stripeCusId}`,
);
return;
}
// logger.info(`Deleting cache for customer ${cus.id}`);
await deleteCusCache({
db,
customerId: cus.id!,
org,
env,
});
}
};

View File

@@ -1,47 +1,10 @@
import {
AppEnv,
BillingInterval,
ErrCode,
type Feature,
Infinite,
type Organization,
type UsagePriceConfig,
} from "@autumn/shared";
import Stripe from "stripe";
import { decryptData } from "@/utils/encryptUtils.js";
import RecaseError from "@/utils/errorUtils.js";
export const createStripeCli = ({
org,
env,
// apiVersion,
legacyVersion,
}: {
org: Organization;
env: AppEnv;
// apiVersion?: string;
legacyVersion?: boolean;
}) => {
const encrypted =
env === AppEnv.Sandbox
? org.stripe_config?.test_api_key
: org.stripe_config?.live_api_key;
if (!encrypted) {
throw new RecaseError({
message: `Please connect your Stripe ${env === AppEnv.Sandbox ? "test" : "live"} secret key. You can find it here: https://dashboard.stripe.com${env === AppEnv.Sandbox ? "/test" : ""}/apikeys`,
code: ErrCode.StripeConfigNotFound,
statusCode: 400,
});
}
const decrypted = decryptData(encrypted);
return new Stripe(decrypted, {
apiVersion: legacyVersion
? ("2025-02-24.acacia" as any)
: "2025-07-30.basil",
});
};
import type Stripe from "stripe";
export const calculateMetered1Price = ({
product,

View File

@@ -7,6 +7,7 @@ import {
} from "@autumn/shared";
import type { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { CusService } from "@/internal/customers/CusService.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
@@ -16,10 +17,8 @@ import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtil
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { getEarliestPeriodEnd } from "../stripeSubUtils/convertSubUtils.js";
import { createStripeCli } from "../utils.js";
import { getOptionsFromCheckoutSession } from "./handleCheckoutCompleted/getOptionsFromCheckout.js";
import { handleCheckoutSub } from "./handleCheckoutCompleted/handleCheckoutSub.js";
import { handleRemainingSets } from "./handleCheckoutCompleted/handleRemainingSets.js";

View File

@@ -1,5 +1,6 @@
import { AttachBranch } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { handleAddProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js";
import { handleOneOffFunction } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.js";
import { getDefaultAttachConfig } from "@/internal/customers/attach/attachUtils/getAttachConfig.js";
@@ -7,7 +8,6 @@ import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams
import { isOneOff } from "@/internal/products/productUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { getCusPaymentMethod } from "../../stripeCusUtils.js";
import { createStripeCli } from "../../utils.js";
export const handleSetupCheckout = async ({
req,

View File

@@ -1,10 +1,10 @@
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusService } from "@/internal/customers/CusService.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { notNullish } from "@/utils/genUtils.js";
import { createStripeCli } from "../utils.js";
export async function handleCusDiscountDeleted({
db,

View File

@@ -10,6 +10,7 @@ import {
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { EntityService } from "@/internal/api/entities/EntityService.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
@@ -25,7 +26,6 @@ import {
} from "../../stripeInvoiceUtils.js";
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
import { getStripeSubs } from "../../stripeSubUtils.js";
import { createStripeCli } from "../../utils.js";
import { handleContUsePrices } from "./handleContUsePrices.js";
import { handlePrepaidPrices } from "./handlePrepaidPrices.js";
import { handleUsagePrices } from "./handleUsagePrices.js";

View File

@@ -1,23 +1,23 @@
import {
AppEnv,
type AppEnv,
CusProductStatus,
FullCustomerPrice,
InvoiceStatus,
Organization,
type FullCustomerPrice,
type InvoiceStatus,
type Organization,
} from "@autumn/shared";
import Stripe from "stripe";
import { createStripeCli } from "../utils.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import {
getFullStripeInvoice,
getStripeExpandedInvoice,
invoiceToSubId,
updateInvoiceIfExists,
} from "../stripeInvoiceUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
export const handleInvoiceFinalized = async ({
db,
@@ -68,11 +68,11 @@ export const handleInvoiceFinalized = async ({
return;
}
let prices = activeProducts.flatMap((cp) =>
const prices = activeProducts.flatMap((cp) =>
cp.customer_prices.map((cpr: FullCustomerPrice) => cpr.price),
);
let invoiceItems = await getInvoiceItems({
const invoiceItems = await getInvoiceItems({
stripeInvoice: invoice,
prices: prices,
logger,

View File

@@ -7,6 +7,7 @@ import type {
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { handleInvoiceCheckoutPaid } from "@/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
@@ -22,7 +23,6 @@ import {
} from "../stripeInvoiceUtils.js";
import { lineItemInCusProduct } from "../stripeSubUtils/stripeSubItemUtils.js";
import { getStripeSubs } from "../stripeSubUtils.js";
import { createStripeCli } from "../utils.js";
import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js";
const handleOneOffInvoicePaid = async ({
@@ -153,7 +153,7 @@ export const handleInvoicePaid = async ({
env: AppEnv;
event: Stripe.Event;
}) => {
const logger = req.logtail;
const logger = req.logger;
const stripeCli = createStripeCli({ org, env });
const invoice = await getFullStripeInvoice({
stripeCli,

View File

@@ -10,6 +10,7 @@ import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { generateId } from "@/utils/genUtils.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
@@ -18,7 +19,6 @@ import {
deleteCouponFromSub,
} from "../stripeCouponUtils/deleteCouponFromCus.js";
import { invoiceToSubId } from "../stripeInvoiceUtils.js";
import { createStripeCli } from "../utils.js";
export const handleInvoicePaidDiscount = async ({
db,

View File

@@ -1,27 +1,26 @@
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
type AppEnv,
BillingType,
CusProductStatus,
FullCusProduct,
FullCustomerPrice,
Organization,
Price,
type FullCusProduct,
type FullCustomerPrice,
type Organization,
type Price,
} from "@autumn/shared";
import { AppEnv } from "@autumn/shared";
import Stripe from "stripe";
import { createStripeCli } from "../utils.js";
import { getStripeExpandedInvoice } from "../stripeInvoiceUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { generateId } from "@/utils/genUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getFullStripeSub } from "../stripeSubUtils.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { generateId } from "@/utils/genUtils.js";
import { getStripeExpandedInvoice } from "../stripeInvoiceUtils.js";
import {
getEarliestPeriodEnd,
getEarliestPeriodStart,
} from "../stripeSubUtils/convertSubUtils.js";
import { getFullStripeSub } from "../stripeSubUtils.js";
export const handleSubCreated = async ({
db,
@@ -56,7 +55,7 @@ export const handleSubCreated = async ({
}
// Update autumn sub
let autumnSub = await SubService.getFromScheduleId({
const autumnSub = await SubService.getFromScheduleId({
db,
scheduleId: subscription.schedule as string,
});
@@ -105,9 +104,9 @@ export const handleSubCreated = async ({
cusProds.length,
);
let batchUpdate = [];
const batchUpdate = [];
for (const cusProd of cusProds) {
let subIds = cusProd.subscription_ids
const subIds = cusProd.subscription_ids
? [...cusProd.subscription_ids]
: [];
subIds.push(subscription.id);
@@ -128,7 +127,7 @@ export const handleSubCreated = async ({
stripeInvoiceId: subscription.latest_invoice as string,
});
let invoiceItems = await getInvoiceItems({
const invoiceItems = await getInvoiceItems({
stripeInvoice: invoice,
prices: cusProd.customer_prices.map(
(cpr: FullCustomerPrice) => cpr.price,
@@ -155,19 +154,19 @@ export const handleSubCreated = async ({
}
// Get cus prods for sub
let cusProds = await CusProductService.getByStripeSubId({
const cusProds = await CusProductService.getByStripeSubId({
db,
stripeSubId: subscription.id,
orgId: org.id,
env,
});
let handleInArrearWithEntity = async (cusProd: FullCusProduct) => {
const handleInArrearWithEntity = async (cusProd: FullCusProduct) => {
if (!cusProd.internal_entity_id) {
return;
}
let arrearPrices = cusProd.customer_prices
const arrearPrices = cusProd.customer_prices
.map((cp) => cp.price)
.filter(
(p: Price) =>
@@ -178,9 +177,9 @@ export const handleSubCreated = async ({
return;
}
let itemsToDelete = [];
const itemsToDelete = [];
for (const arrearPrice of arrearPrices) {
let subItem = subscription.items.data.find(
const subItem = subscription.items.data.find(
(i) => i.price.id == arrearPrice.config?.stripe_price_id,
);
@@ -211,7 +210,7 @@ export const handleSubCreated = async ({
}
};
let batchUpdate = [];
const batchUpdate = [];
for (const cusProd of cusProds) {
batchUpdate.push(handleInArrearWithEntity(cusProd));
}

View File

@@ -1,9 +1,7 @@
import { AppEnv } from "@autumn/shared";
import Stripe from "stripe";
import { Organization } from "@autumn/shared";
import { createStripeCli } from "../utils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import type { AppEnv, Organization } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
export const handleSubscriptionScheduleCanceled = async ({

View File

@@ -5,15 +5,13 @@ import {
type Organization,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { createStripeCli } from "../utils.js";
import { handleSchedulePhaseCompleted } from "./handleSubUpdated/handleSchedulePhaseCompleted.js";
import {
handleSubCanceled,
isSubCanceled,
} from "./handleSubUpdated/handleSubCanceled.js";
import { handleSubCanceled } from "./handleSubUpdated/handleSubCanceled.js";
import { handleSubPastDue } from "./handleSubUpdated/handleSubPastDue.js";
import { handleSubRenewed } from "./handleSubUpdated/handleSubRenewed.js";
export const handleSubscriptionUpdated = async ({
@@ -66,24 +64,12 @@ export const handleSubscriptionUpdated = async ({
past_due: CusProductStatus.PastDue,
};
// 1. Fetch subscription
const { canceled, canceledAt } = isSubCanceled({
previousAttributes,
sub: fullSub,
});
const updatedCusProducts = await CusProductService.updateByStripeSubId({
db,
stripeSubId: subscription.id,
updates: {
status: subStatusMap[subscription.status] || CusProductStatus.Unknown,
collection_method: fullSub.collection_method as CollectionMethod,
// canceled_at: canceled ? canceledAt : null,
// trial_ends_at:
// previousAttributes.status === "trialing" &&
// subscription.status === "active"
// ? null
// : undefined,
},
});
@@ -103,6 +89,14 @@ export const handleSubscriptionUpdated = async ({
org,
});
await handleSubPastDue({
req,
previousAttributes,
sub: fullSub,
updatedCusProducts,
org,
});
await handleSubRenewed({
req,
prevAttributes: previousAttributes,

View File

@@ -4,6 +4,7 @@ import {
cusProductToProduct,
} from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { activateFutureProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
@@ -11,7 +12,6 @@ import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { createStripeCli } from "../../utils.js";
export const handleSchedulePhaseCompleted = async ({
req,

View File

@@ -69,10 +69,16 @@ const updateCusProductCanceled = async ({
`Updating cus products for sub ${sub.id} to canceled | canceled_at: ${canceledAt}`,
);
const cancelsAt = sub.cancel_at ? sub.cancel_at * 1000 : undefined;
await CusProductService.updateByStripeSubId({
db,
stripeSubId: sub.id,
updates: { canceled_at: canceledAt || Date.now(), canceled: true },
updates: {
canceled_at: canceledAt || Date.now(),
canceled: true,
ended_at: cancelsAt,
},
});
};
@@ -102,7 +108,7 @@ export const handleSubCanceled = async ({
const canceledFromPortal = canceled && !isAutumnDowngrade;
const { db, env, logtail: logger } = req;
const { db, env, logger } = req;
if (!canceledFromPortal || updatedCusProducts.length === 0) return;

View File

@@ -0,0 +1,71 @@
import {
AttachScenario,
type FullCusProduct,
type Organization,
} from "@autumn/shared";
import type Stripe from "stripe";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
export const isSubPastDue = ({
previousAttributes,
sub,
}: {
previousAttributes: any;
sub: Stripe.Subscription;
}) => {
const wasPastDue = previousAttributes.status === "past_due";
const isPastDue = sub.status === "past_due";
return {
pastDue: !wasPastDue && isPastDue,
};
};
export const handleSubPastDue = async ({
req,
previousAttributes,
org,
sub,
updatedCusProducts,
}: {
req: ExtendedRequest;
previousAttributes: any;
sub: Stripe.Subscription;
org: Organization;
updatedCusProducts: FullCusProduct[];
}) => {
const { pastDue } = isSubPastDue({
previousAttributes,
sub,
});
const { env, logger } = req;
if (!pastDue || updatedCusProducts.length === 0) return;
logger.info(
`Subscription ${sub.id} is now past due, firing webhooks for ${updatedCusProducts.length} customer product(s)`,
);
if (!org.config.sync_status) return;
for (const cusProd of updatedCusProducts) {
try {
await addProductsUpdatedWebhookTask({
req,
internalCustomerId: cusProd.internal_customer_id,
org,
env,
customerId: null,
logger,
scenario: AttachScenario.PastDue,
cusProduct: cusProd,
});
} catch (error) {
logger.error("Failed to add products updated webhook task to queue", {
error,
});
}
}
};

View File

@@ -1,13 +1,14 @@
import { AttachScenario, type FullCusProduct } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { isMultiProductSub } from "@/internal/customers/attach/mergeUtils/mergeUtils.js";
import { getSubScenarioFromCache } from "@/internal/customers/cusCache/subCacheUtils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachScenario, FullCusProduct } from "@autumn/shared";
import Stripe from "stripe";
import { isMultiProductSub } from "@/internal/customers/attach/mergeUtils/mergeUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getSubScenarioFromCache } from "@/internal/customers/cusCache/subCacheUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
const isSubRenewed = ({
previousAttributes,
sub,
@@ -62,21 +63,21 @@ export const handleSubRenewed = async ({
sub: Stripe.Subscription;
updatedCusProducts: FullCusProduct[];
}) => {
const { db, org, env, logtail: logger } = req;
const { db, org, env, logger } = req;
const { renewed } = isSubRenewed({
previousAttributes: prevAttributes,
sub,
});
if (!renewed || updatedCusProducts.length == 0) return;
if (!renewed || updatedCusProducts.length === 0) return;
const subScenario = await getSubScenarioFromCache({ subId: sub.id });
console.log(`Renewed: ${renewed}, subScenario: ${subScenario}`);
if (subScenario === AttachScenario.Renew) return;
const customer = updatedCusProducts[0].customer;
let cusProducts = await CusProductService.list({
const cusProducts = await CusProductService.list({
db,
internalCustomerId: customer!.internal_id,
});
@@ -88,18 +89,18 @@ export const handleSubRenewed = async ({
await CusProductService.updateByStripeSubId({
db,
stripeSubId: sub.id,
updates: { canceled_at: null, canceled: false },
updates: { canceled_at: null, canceled: false, ended_at: null },
});
if (!org.config.sync_status) return;
let { curScheduledProduct } = getExistingCusProducts({
const { curScheduledProduct } = getExistingCusProducts({
product: updatedCusProducts[0].product,
cusProducts,
internalEntityId: updatedCusProducts[0].internal_entity_id,
});
let deletedCusProducts: FullCusProduct[] = [];
const deletedCusProducts: FullCusProduct[] = [];
if (curScheduledProduct) {
logger.info(
@@ -115,7 +116,7 @@ export const handleSubRenewed = async ({
}
try {
for (let cusProd of updatedCusProducts) {
for (const cusProd of updatedCusProducts) {
await addProductsUpdatedWebhookTask({
req,
internalCustomerId: cusProd.internal_customer_id,

View File

@@ -1,5 +1,4 @@
import { AppEnv } from "@autumn/shared";
import { Organization } from "@autumn/shared";
import { AppEnv, type Organization } from "@autumn/shared";
import { Svix } from "svix";
import { logger } from "../logtail/logtailUtils.js";
@@ -35,7 +34,7 @@ export const getSvixAppId = ({
env: AppEnv;
}) => {
const svixConfig = org.svix_config;
return env == AppEnv.Live
return env === AppEnv.Live
? svixConfig?.live_app_id
: svixConfig?.sandbox_app_id;
};

View File

@@ -0,0 +1,121 @@
import {
type AppEnv,
AuthType,
type Feature,
type Organization,
} from "@autumn/shared";
import express, { type Router } from "express";
import type { Context } from "hono";
import type { Stripe } from "stripe";
import {
getStripeWebhookSecret,
initMasterStripe,
} from "@/external/connect/initStripeCli.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { handleStripeWebhookEvent } from "../stripe/handleStripeWebhookEvent.js";
export const connectWebhookRouter: Router = express.Router();
export const handleConnectWebhook = async (c: Context<HonoEnv>) => {
const ctx = c.get("ctx");
const { db, logger } = ctx;
const { env } = c.req.param() as { env: AppEnv };
// Initial logging of event body...
const body = await c.req.json();
logger.info(`connect webhook received (${env})`, {
body,
});
let masterStripe: Stripe;
try {
masterStripe = initMasterStripe();
} catch (error) {
logger.error(`Failed to initialize master stripe client ${error}`);
return c.json(200);
}
let event: Stripe.Event;
// Step 1: Get webhook secret
const webhookSecret = await getStripeWebhookSecret({
db,
orgId: c.req.query("org_id"),
env,
});
// Step 2: Verify webhook event
try {
const rawBody = await c.req.text();
const signature = c.req.header("stripe-signature") || "";
event = await masterStripe.webhooks.constructEventAsync(
rawBody,
signature,
webhookSecret,
);
} catch (err: any) {
logger.error(`Webhook verification error: ${err.message}`);
return c.json({ error: err.message }, 400);
}
// Step 3: Get org and features
const accountId = event.account;
if (!accountId) {
logger.error(`Account ID not found in webhook event`);
return c.json({ error: "Account ID not found" }, 200);
}
let org: Organization;
let features: Feature[];
try {
const data = await OrgService.getByAccountId({
db,
accountId,
});
org = data.org;
features = data.features;
} catch {
logger.error(
`Account ID ${accountId} not linked to any org, skipping Stripe webhook`,
);
return c.json(
{ message: "Account ID not linked to any org, skipping Stripe webhook" },
200,
);
}
ctx.org = org;
ctx.features = features;
ctx.env = env as AppEnv;
ctx.logger = ctx.logger.child({
context: {
context: {
event_type: event.type,
event_id: event.id,
// @ts-expect-error
object_id: `${event.data?.object?.id}` || "N/A",
authType: AuthType.Stripe,
org_id: org.id,
org_slug: org.slug,
env,
},
},
});
try {
await handleStripeWebhookEvent({
event,
db,
org,
env: env as AppEnv,
logger,
req: ctx as ExtendedRequest,
});
return c.json({ message: "Webhook received" }, 200);
} catch (error) {
logger.error(`Stripe webhook, error: ${error}`, { error });
return c.json({ message: "Webhook received, internal server error" }, 200);
}
};

View File

@@ -1,7 +1,6 @@
import express, { Router } from "express";
import { stripeWebhookRouter } from "../stripe/stripeWebhooks.js";
import express, { type Router } from "express";
import { autumnWebhookRouter } from "../autumn/autumnWebhookRouter.js";
import { stripeWebhookRouter } from "../stripe/stripeWebhooks.js";
const webhooksRouter: Router = express.Router();
@@ -9,4 +8,6 @@ webhooksRouter.use("/stripe", stripeWebhookRouter);
webhooksRouter.use("/autumn", autumnWebhookRouter);
// webhooksRouter.use("/connect", connectWebhookRouter);
export default webhooksRouter;

View File

@@ -5,126 +5,7 @@ import Stripe from "stripe";
import { ZodError } from "zod/v4";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import RecaseError, { formatZodError } from "@/utils/errorUtils.js";
import { matchRoute } from "./middlewareUtils.js";
/**
* Handle special error cases that should use warn instead of error logging
* Returns a response if the error matches a special case, null otherwise
*/
const handleSpecialErrorCases = (
err: Error,
c: Context<HonoEnv>,
ctx: any,
logger: any,
) => {
const url = c.req.url;
// Special case 1: EntityNotFound - use warn instead of error
if (err instanceof RecaseError && err.code === ErrCode.EntityNotFound) {
logger.warn(`${err.message}, org: ${ctx.org?.slug || "unknown"}`);
return c.json(
{
message: err.message,
code: err.code,
env: ctx.env,
},
404,
);
}
// Special case 2: Stripe exchange router invalid API key
if (
err instanceof Stripe.errors.StripeError &&
url.includes("/exchange") &&
err.message.includes("Invalid API Key provided")
) {
logger.warn("Exchange router, invalid API Key provided");
return c.json(
{
message: err.message,
code: ErrCode.InvalidRequest,
env: ctx.env,
},
400,
);
}
// Special case 3: Billing portal config error
if (
err instanceof Stripe.errors.StripeError &&
url.includes("/billing_portal") &&
err.message.includes("Provide a configuration or create your default")
) {
logger.warn(`Billing portal config error, org: ${ctx.org?.slug}`);
return c.json(
{
message: err.message,
code: ErrCode.InvalidRequest,
env: ctx.env,
},
404,
);
}
// Special case 4: Billing portal return_url error
if (
err instanceof Stripe.errors.StripeError &&
url.includes("/billing_portal") &&
err.message.includes("Invalid URL: An explicit scheme (such as https)")
) {
logger.warn(`Billing portal return_url error, org: ${ctx.org?.slug}`);
return c.json(
{
message: err.message,
code: ErrCode.InvalidRequest,
env: ctx.env,
},
400,
);
}
// Special case 5: Zod error on /attach - convert to RecaseError
if (err instanceof ZodError && url.includes("/attach")) {
const formattedError = formatZodError(err);
logger.warn(
`ATTACH ZOD ERROR (${ctx.org?.slug || "unknown"}): ${formattedError}`,
);
return c.json(
{
message: formattedError,
code: ErrCode.InvalidInputs,
env: ctx.env,
},
400,
);
}
// Special case 6: CustomerNotFound on customer routes
const pathname = new URL(url).pathname;
if (
err instanceof RecaseError &&
err.code === ErrCode.CustomerNotFound &&
matchRoute({
url: pathname,
method: c.req.method,
pattern: { url: "/customers/:customer_id", method: "GET" },
})
) {
logger.warn(`${err.message}, org: ${ctx.org?.slug || "unknown"}`);
return c.json(
{
message: err.message,
code: err.code,
env: ctx.env,
},
404,
);
}
// No special case matched
return null;
};
import { handleErrorSkip } from "./errorSkipMiddleware.js";
/**
* Hono error handler middleware
@@ -146,9 +27,9 @@ export const errorMiddleware = (err: Error, c: Context<HonoEnv>) => {
);
}
// Check for special error cases first
const specialCaseResponse = handleSpecialErrorCases(err, c, ctx, logger);
if (specialCaseResponse) return specialCaseResponse;
// Check for error skip cases first (warn-level errors)
const skipResponse = handleErrorSkip(err, c);
if (skipResponse) return skipResponse;
// 1. Handle RecaseError (our custom errors)
if (err instanceof RecaseError || err instanceof SharedRecaseError) {

View File

@@ -0,0 +1,231 @@
import {
CusErrorCode,
ErrCode,
ProductErrorCode,
RecaseError as SharedRecaseError,
} from "@autumn/shared";
import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import Stripe from "stripe";
import { ZodError } from "zod/v4";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import RecaseError, { formatZodError } from "@/utils/errorUtils.js";
import { matchRoute } from "./middlewareUtils.js";
// ============================================================================
// ERROR SKIP CONFIGURATION
// ============================================================================
/**
* Simple route-based error code skipping
* Add routes here to skip specific error codes (logged as warnings, returns appropriate status)
*/
const ROUTE_ERROR_SKIP_MAP = [
// {
// route: "/products/:productId/count",
// method: "GET",
// skipErrorCodes: [ProductErrorCode.ProductNotFound],
// },
{
route: "/customers/:customer_id/aksdjnalksjnd",
method: "GET",
skipErrorCodes: [ErrCode.CustomerNotFound],
},
] as const;
/** Global error codes that should be logged as warnings instead of errors (all routes) */
const GLOBAL_WARN_ERROR_CODES: string[] = [
ProductErrorCode.ProductNotFound,
CusErrorCode.CustomerNotFound,
ErrCode.CustomerNotFound,
ErrCode.EntityNotFound,
];
/** Advanced route-specific error handling rules (for complex matching logic) */
const ROUTE_SPECIFIC_RULES: Array<{
name: string;
match: (err: Error, c: Context<HonoEnv>) => boolean;
statusCode: ContentfulStatusCode;
}> = [];
/** Stripe-specific error handling rules */
const STRIPE_RULES = [
{
name: "Exchange router invalid API key",
match: (err: Error, c: Context<HonoEnv>) =>
err instanceof Stripe.errors.StripeError &&
c.req.url.includes("/exchange") &&
err.message.includes("Invalid API Key provided"),
statusCode: 400,
code: ErrCode.InvalidRequest,
},
{
name: "Billing portal config error",
match: (err: Error, c: Context<HonoEnv>) =>
err instanceof Stripe.errors.StripeError &&
c.req.url.includes("/billing_portal") &&
err.message.includes("Provide a configuration or create your default"),
statusCode: 404,
code: ErrCode.InvalidRequest,
},
{
name: "Billing portal return_url error",
match: (err: Error, c: Context<HonoEnv>) =>
err instanceof Stripe.errors.StripeError &&
c.req.url.includes("/billing_portal") &&
err.message.includes("Invalid URL: An explicit scheme (such as https)"),
statusCode: 400,
code: ErrCode.InvalidRequest,
},
] as const;
/** Zod-specific error handling rules */
const ZOD_RULES = [
{
name: "Zod error on /attach",
match: (err: Error, c: Context<HonoEnv>) =>
err instanceof ZodError && c.req.url.includes("/attach"),
statusCode: 400,
format: (err: ZodError) => formatZodError(err),
},
] as const;
const createErrorResponse = ({
c,
ctx,
message,
code,
statusCode,
}: {
c: Context<HonoEnv>;
ctx: any;
message: string;
code: string;
statusCode: ContentfulStatusCode;
}) => {
return c.json(
{
message,
code,
env: ctx.env,
},
statusCode,
);
};
/**
* Handles special error cases that should use warn logging instead of error logging.
* Returns a response if handled, null otherwise to continue to main error handler.
*/
export const handleErrorSkip = (err: Error, c: Context<HonoEnv>) => {
const ctx = c.get("ctx");
const logger = ctx?.logger;
if (!logger) {
return null; // Let main error handler deal with this
}
// 1. Check route-based error code skipping (simplest case)
if (err instanceof RecaseError || err instanceof SharedRecaseError) {
const pathname = new URL(c.req.url).pathname;
for (const skipRule of ROUTE_ERROR_SKIP_MAP) {
if (
skipRule.skipErrorCodes.includes(err.code as any) &&
matchRoute({
url: pathname,
method: c.req.method,
pattern: { url: skipRule.route, method: skipRule.method },
})
) {
logger.warn(
`${err.message}, org: ${ctx.org?.slug || "unknown"} [route skip: ${skipRule.route}]`,
);
return createErrorResponse({
c,
ctx,
message: err.message,
code: err.code,
statusCode: 404,
});
}
}
}
// 2. Check global warn-level error codes
if (
(err instanceof RecaseError || err instanceof SharedRecaseError) &&
GLOBAL_WARN_ERROR_CODES.includes(err.code)
) {
logger.warn(
`${err.message}, org: ${ctx.org?.slug || "unknown"}, path: ${c.req.path}`,
);
return createErrorResponse({
c,
ctx,
message: err.message,
code: err.code,
statusCode: 404,
});
}
// 3. Check advanced route-specific rules
for (const rule of ROUTE_SPECIFIC_RULES) {
if (rule.match(err, c)) {
const recaseErr = err as RecaseError;
logger.warn(`${recaseErr.message}, org: ${ctx.org?.slug || "unknown"}`);
return createErrorResponse({
c,
ctx,
message: recaseErr.message,
code: recaseErr.code,
statusCode: rule.statusCode,
});
}
}
// 4. Check Stripe-specific rules
for (const rule of STRIPE_RULES) {
if (rule.match(err, c)) {
const stripeErr = err as Stripe.errors.StripeError;
logger.warn(`${rule.name}, org: ${ctx.org?.slug || "unknown"}`);
return createErrorResponse({
c,
ctx,
message: stripeErr.message,
code: rule.code,
statusCode: rule.statusCode,
});
}
}
// 5. Check Zod-specific rules
for (const rule of ZOD_RULES) {
if (rule.match(err, c)) {
const zodErr = err as ZodError;
const formattedError = rule.format(zodErr);
logger.warn(
`ZOD ERROR (${ctx.org?.slug || "unknown"}): ${formattedError}`,
);
return createErrorResponse({
c,
ctx,
message: formattedError,
code: ErrCode.InvalidInputs,
statusCode: rule.statusCode,
});
}
}
// No special case matched - continue to main error handler
return null;
};
/**
* Middleware wrapper for error skip handling
* Note: This doesn't actually prevent errors from reaching onError handler
* It's used within the main error handler to check for skip cases
*/
export const errorSkipMiddleware = (err: Error, c: Context<HonoEnv>) => {
return handleErrorSkip(err, c);
};

View File

@@ -14,6 +14,7 @@ type ValidatedContext<
E extends Env,
Body extends ZodType | undefined = undefined,
Query extends ZodType | undefined = undefined,
Params extends ZodType | undefined = undefined,
> = Context<
E,
any,
@@ -21,10 +22,12 @@ type ValidatedContext<
in: {
json: Body extends ZodType ? z.infer<Body> : unknown;
query: Query extends ZodType ? z.infer<Query> : unknown;
param: Params extends ZodType ? z.infer<Params> : unknown;
};
out: {
json: Body extends ZodType ? z.infer<Body> : unknown;
query: Query extends ZodType ? z.infer<Query> : unknown;
param: Params extends ZodType ? z.infer<Params> : unknown;
};
}
>;
@@ -41,14 +44,18 @@ type VersionedSchemas<T extends ZodType> = Partial<
/**
* Create a type-safe route with validation that preserves full type inference!
*
* Supports two patterns:
* Supports validation for body, query, and params:
*
* **Pattern 1: Single version (most endpoints)**
* ```ts
* export const createProduct = createRoute({
* body: CreateProductSchema,
* query: ProductQuerySchema,
* params: ProductParamsSchema,
* handler: async (c) => {
* const body = c.req.valid("json"); // ✅ Fully typed!
* const body = c.req.valid("json"); // ✅ Fully typed!
* const query = c.req.valid("query"); // ✅ Fully typed!
* const params = c.req.valid("param"); // ✅ Fully typed!
* return c.json({ success: true });
* }
* });
@@ -74,15 +81,17 @@ type VersionedSchemas<T extends ZodType> = Partial<
export function createRoute<
Body extends ZodType | undefined = undefined,
Query extends ZodType | undefined = undefined,
Params extends ZodType | undefined = undefined,
>(opts: {
body?: Body;
versionedBody?: Body extends ZodType ? VersionedSchemas<Body> : never;
query?: Query;
versionedQuery?: Query extends ZodType ? VersionedSchemas<Query> : never;
params?: Params;
resource?: AffectedResource;
withTx?: boolean;
handler: (
c: ValidatedContext<HonoEnv, Body, Query>,
c: ValidatedContext<HonoEnv, Body, Query, Params>,
) => Response | Promise<Response>;
}) {
const middlewares: MiddlewareHandler[] = [];
@@ -114,7 +123,14 @@ export function createRoute<
middlewares.push(validator("query", opts.query));
}
const wrappedHandler = async (c: ValidatedContext<HonoEnv, Body, Query>) => {
// Params validator (no versioned variant)
if (opts.params) {
middlewares.push(validator("param", opts.params));
}
const wrappedHandler = async (
c: ValidatedContext<HonoEnv, Body, Query, Params>,
) => {
c.set("validated", true);
if (opts.withTx) {

View File

@@ -29,7 +29,6 @@ import { client, db } from "./db/initDrizzle.js";
import { CacheManager } from "./external/caching/CacheManager.js";
import { ClickHouseManager } from "./external/clickhouse/ClickHouseManager.js";
import { logger } from "./external/logtail/logtailUtils.js";
import { createPosthogCli } from "./external/posthog/createPosthogCli.js";
import webhooksRouter from "./external/webhooks/webhooksRouter.js";
import { redirectToHono } from "./initHono.js";
import { apiRouter } from "./internal/api/apiRouter.js";
@@ -38,7 +37,6 @@ import { QueueManager } from "./queue/QueueManager.js";
import { auth } from "./utils/auth.js";
import { generateId } from "./utils/genUtils.js";
import { checkEnvVars } from "./utils/initUtils.js";
const tracer = trace.getTracer("express");
checkEnvVars();
@@ -128,8 +126,6 @@ const init = async () => {
app.all("/api/auth/*", toNodeHandler(auth));
const posthog = createPosthogCli();
// Initialize managers in parallel for faster startup
await Promise.all([
QueueManager.getInstance(),
@@ -141,7 +137,6 @@ const init = async () => {
req.env = req.env = req.headers.app_env || AppEnv.Sandbox;
req.db = db;
req.clickhouseClient = await ClickHouseManager.getClient();
req.posthog = posthog;
req.id = req.headers["rndr-id"] || generateId("local_req");
req.timestamp = Date.now();
@@ -165,12 +160,11 @@ const init = async () => {
// Store span on request for potential use in other middleware/handlers
req.span = span;
req.logtail = logger.child({
req.logger = logger.child({
context: {
req: reqContext,
},
});
req.logger = req.logtail;
const endSpan = () => {
try {
@@ -203,7 +197,7 @@ const init = async () => {
app.use(express.json());
app.use(async (req: any, res: any, next: any) => {
req.logtail.info(`${req.method} ${req.originalUrl}`, {
req.logger.info(`${req.method} ${req.originalUrl}`, {
context: {
body: req.body,
},

View File

@@ -1,9 +1,11 @@
import { getRequestListener } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { handleConnectWebhook } from "./external/webhooks/connectWebhookRouter.js";
import { analyticsMiddleware } from "./honoMiddlewares/analyticsMiddleware.js";
import { apiVersionMiddleware } from "./honoMiddlewares/apiVersionMiddleware.js";
import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js";
import { betterAuthMiddleware } from "./honoMiddlewares/betterAuthMiddleware.js";
import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
import { orgConfigMiddleware } from "./honoMiddlewares/orgConfigMiddleware.js";
import { queryMiddleware } from "./honoMiddlewares/queryMiddleware.js";
@@ -12,7 +14,12 @@ import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { cusRouter } from "./internal/customers/cusRouter.js";
import { internalCusRouter } from "./internal/customers/internalCusRouter.js";
import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js";
import { honoOrgRouter } from "./internal/orgs/orgRouter.js";
import { honoPlatformRouter } from "./internal/platform/honoPlatformRouter.js";
import { platformBetaRouter } from "./internal/platform/platformBeta/platformBetaRouter.js";
import { internalProductRouter } from "./internal/products/internalProductRouter.js";
import { honoProductRouter } from "./internal/products/productRouter.js";
import { auth } from "./utils/auth.js";
@@ -66,35 +73,37 @@ export const createHonoApp = () => {
return auth.handler(c.req.raw);
});
// Step 1: Base middleware - sets up ctx (db, logger, etc.) - only for v1 routes
app.use("/v1/*", baseMiddleware);
// OAuth callback (needs to be before middleware)
app.get("/stripe/oauth_callback", handleOAuthCallback);
// Step 2: Tracing middleware - handles OpenTelemetry spans - only for v1 routes
app.use("/v1/*", traceMiddleware);
// Step 1: Base middleware - sets up ctx (db, logger, etc.)
app.use("*", baseMiddleware);
app.use("*", traceMiddleware);
// Step 3: Auth middleware - verifies secret key and populates auth context
// Webhook routes
app.post("/webhooks/connect/:env", handleConnectWebhook);
// API Middleware
app.use("/v1/*", secretKeyMiddleware);
// Step 4: Org config middleware - allows config overrides via header
app.use("/v1/*", orgConfigMiddleware);
// Step 5: API Version middleware - validates x-api-version header
app.use("/v1/*", apiVersionMiddleware);
// Step 6: Refresh cache middleware - clears customer cache after successful mutations
app.use("/v1/*", refreshCacheMiddleware);
// Step 7: Analytics middleware - enriches logger context and logs responses
app.use("/v1/*", analyticsMiddleware);
// Step 8: Query middleware - handles query parsing and validation
app.use("/v1/*", queryMiddleware());
// API Routes
app.route("v1/customers", cusRouter);
app.route("v1/products", honoProductRouter);
app.route("v1/platform", honoPlatformRouter);
app.route("v1/platform/beta", platformBetaRouter);
app.route("v1/organization", honoOrgRouter);
// Internal/dashboard routes - use betterAuthMiddleware for session auth
app.use("/products/*", betterAuthMiddleware);
app.route("/products", internalProductRouter);
app.use("/customers/*", betterAuthMiddleware);
app.route("/customers", internalCusRouter);
// Error handler - must be defined after all routes and middleware
app.onError(errorMiddleware);
// Create request listener for integration with Express

View File

@@ -5,7 +5,7 @@ import { ADMIN_USER_IDs } from "@/utils/constants.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
export const withAdminAuth = async (req: any, res: any, next: NextFunction) => {
const { logtail: logger, userId } = req as ExtendedRequest;
const { logger } = req as ExtendedRequest;
try {
const data = await auth.api.getSession({

View File

@@ -13,10 +13,8 @@ import { handleCreateBillingPortal } from "../customers/handlers/handleCreateBil
import { featureRouter } from "../features/featureRouter.js";
import { internalFeatureRouter } from "../features/internalFeatureRouter.js";
import { migrationRouter } from "../migrations/migrationRouter.js";
import { handleConnectStripe } from "../orgs/handlers/handleConnectStripe.js";
import { handleDeleteStripe } from "../orgs/handlers/handleDeleteStripe.js";
import { handleGetOrg } from "../orgs/handlers/handleGetOrg.js";
import { platformRouter } from "../platform/platformRouter.js";
import { platformRouter } from "../platform/platformLegacy/platformRouter.js";
import { productBetaRouter, productRouter } from "../products/productRouter.js";
import { componentRouter } from "./components/componentRouter.js";
import { entityRouter } from "./entities/entityRouter.js";
@@ -68,9 +66,9 @@ apiRouter.post("/billing_portal", handleCreateBillingPortal);
apiRouter.use("/query", analyticsRouter);
apiRouter.use("/platform", platformRouter);
// Used for tests...
apiRouter.post("/organization/stripe", handleConnectStripe);
apiRouter.delete("/organization/stripe", handleDeleteStripe);
// // Used for tests...
// apiRouter.post("/organization/stripe", ...handleConnectStripe);
// apiRouter.delete("/organization/stripe", ...handleDeleteStripe);
apiRouter.get("/organization", handleGetOrg);
export { apiRouter };

View File

@@ -72,7 +72,7 @@ export const handleBatchCustomers = async (req: any, res: any) =>
offset: query.offset,
features: req.features,
statuses: query.statuses ?? [],
logger: req.logtail,
logger: req.logger,
apiVersion: req.apiVersion,
});
},

View File

@@ -1,6 +1,21 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import {
type AppEnv,
BillingType,
type Customer,
type Entitlement,
type Entity,
EntityExpand,
ErrCode,
type Feature,
type FullCustomerEntitlement,
type FullCustomerPrice,
type Organization,
type UsagePriceConfig,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { submitUsageToStripe } from "@/external/stripe/stripeMeterUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import {
getBillingType,
@@ -8,21 +23,6 @@ import {
} from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
AppEnv,
BillingType,
Customer,
Entitlement,
Entity,
EntityExpand,
ErrCode,
Feature,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
UsagePriceConfig,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
export const getLinkedCusEnt = ({
linkedFeature,
@@ -32,7 +32,7 @@ export const getLinkedCusEnt = ({
cusEnts: any;
}) => {
// Get linked cus ent...
let linkedCusEnt = cusEnts.find(
const linkedCusEnt = cusEnts.find(
(e: any) => e.entitlement.feature.id === linkedFeature.id,
);
@@ -48,7 +48,7 @@ export const entityFeatureIdExists = ({
}: {
cusEnt: FullCustomerEntitlement;
}) => {
let ent = cusEnt.entitlement;
const ent = cusEnt.entitlement;
return notNullish(ent.entity_feature_id);
};
@@ -102,7 +102,7 @@ export const removeEntityFromCusEnt = async ({
env: AppEnv;
}) => {
// isLinked
let isLinked = isLinkedToEntity({
const isLinked = isLinkedToEntity({
cusEnt,
entity,
});
@@ -111,22 +111,22 @@ export const removeEntityFromCusEnt = async ({
return;
}
let entitlement = cusEnt.entitlement;
const entitlement = cusEnt.entitlement;
console.log(
`Linked cus ent: ${entitlement.feature.id}, isLinked: ${isLinked}`,
);
// Delete cus ent ids
let newEntities = structuredClone(cusEnt.entities!);
const newEntities = structuredClone(cusEnt.entities!);
// TODO: Send usage to stripe if cus price exists
let stripeCli = createStripeCli({
const stripeCli = createStripeCli({
org,
env,
});
if (cusPrice) {
let config = cusPrice.price.config as UsagePriceConfig;
let billingType = getBillingType(config);
const config = cusPrice.price.config as UsagePriceConfig;
const billingType = getBillingType(config);
if (billingType == BillingType.UsageInArrear) {
let usage = -newEntities[entity.id]?.balance;
@@ -163,8 +163,8 @@ export const removeEntityFromCusEnt = async ({
export const parseEntityExpand = (expand: string): EntityExpand[] => {
if (expand) {
let options = expand.split(",");
let result: EntityExpand[] = [];
const options = expand.split(",");
const result: EntityExpand[] = [];
for (const option of options) {
if (!Object.values(EntityExpand).includes(option as EntityExpand)) {
throw new RecaseError({

View File

@@ -1,25 +1,25 @@
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { EntityService } from "../EntityService.js";
import { StatusCodes } from "http-status-codes";
import { CusProductStatus, ErrCode } from "@autumn/shared";
import { CusService } from "@/internal/customers/CusService.js";
import { adjustAllowance } from "@/trigger/adjustAllowance.js";
import { StatusCodes } from "http-status-codes";
import { handleCustomerRaceCondition } from "@/external/redis/redisUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import {
findLinkedCusEnts,
findMainCusEntForFeature,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js";
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js";
import {
deleteEntityFromCusEnt,
replaceEntityInCusEnt,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/linkedCusEntUtils.js";
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js";
import { cancelSubsForEntity } from "@/internal/entities/handlers/handleDeleteEntity/cancelSubsForEntity.js";
import { adjustAllowance } from "@/trigger/adjustAllowance.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { EntityService } from "../EntityService.js";
export const handleDeleteEntity = async (req: any, res: any) => {
try {
const { org, env, db, logtail: logger, features } = req;
const { org, env, db, logger, features } = req;
const { customer_id, entity_id } = req.params;
await handleCustomerRaceCondition({
@@ -73,9 +73,9 @@ export const handleDeleteEntity = async (req: any, res: any) => {
const feature = features.find((f: any) => f.id === entity?.feature_id);
for (const cusProduct of cusProducts) {
let cusEnts = cusProduct.customer_entitlements;
const cusEnts = cusProduct.customer_entitlements;
let mainCusEnt = findMainCusEntForFeature({
const mainCusEnt = findMainCusEntForFeature({
cusEnts,
feature,
});
@@ -97,12 +97,12 @@ export const handleDeleteEntity = async (req: any, res: any) => {
logger,
});
let linkedCusEnts = findLinkedCusEnts({
const linkedCusEnts = findLinkedCusEnts({
cusEnts: cusProduct.customer_entitlements,
feature: mainCusEnt.entitlement.feature,
});
let replaceable =
const replaceable =
newReplaceables && newReplaceables.length > 0
? newReplaceables[0]
: null;
@@ -121,14 +121,14 @@ export const handleDeleteEntity = async (req: any, res: any) => {
for (const linkedCusEnt of linkedCusEnts) {
let newEntities;
if (replaceable) {
let { newEntities: newEntities_ } = replaceEntityInCusEnt({
const { newEntities: newEntities_ } = replaceEntityInCusEnt({
cusEnt: linkedCusEnt,
entityId: entity.id,
replaceable,
});
newEntities = newEntities_;
} else {
let { newEntities: newEntities_ } = deleteEntityFromCusEnt({
const { newEntities: newEntities_ } = deleteEntityFromCusEnt({
cusEnt: linkedCusEnt,
entityId: entity.id,
});

View File

@@ -27,7 +27,7 @@ checkRouter.post("", async (req: any, res: any) => {
entity_id,
} = req.body;
const { logtail: logger, db } = req;
const { logger, db } = req;
if (!customer_id) {
throw new RecaseError({

View File

@@ -1,8 +1,11 @@
import { CusProductStatus, FullCusProduct, SuccessCode } from "@autumn/shared";
import { notNullish } from "@/utils/genUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import {
CusProductStatus,
type FullCusProduct,
SuccessCode,
} from "@autumn/shared";
import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js";
import { getOrgAndFeatures } from "@/internal/orgs/orgUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { notNullish } from "@/utils/genUtils.js";
import { getProductCheckPreview } from "./getProductCheckPreview.js";
@@ -21,12 +24,10 @@ export const handleProductCheck = async ({
with_preview,
entity_data,
} = req.body;
const { orgId, env, logtail: logger, db } = req;
let { org, features } = await getOrgAndFeatures({ req });
const { orgId, env, logger, db } = req;
// 1. Get customer and org
let [customer, product] = await Promise.all([
const [customer, product] = await Promise.all([
getOrCreateCustomer({
req,
customerId: customer_id,
@@ -53,15 +54,15 @@ export const handleProductCheck = async ({
if (customer.entity) {
cusProducts = cusProducts.filter(
(cusProduct: FullCusProduct) =>
cusProduct.internal_entity_id == customer.entity!.internal_id,
cusProduct.internal_entity_id === customer.entity!.internal_id,
);
}
let cusProduct: FullCusProduct | undefined = cusProducts.find(
const cusProduct: FullCusProduct | undefined = cusProducts.find(
(cusProduct: FullCusProduct) => cusProduct.product.id === product_id,
);
let preview = with_preview
const preview = with_preview
? await getProductCheckPreview({
req,
customer,
@@ -96,7 +97,7 @@ export const handleProductCheck = async ({
return;
}
let onTrial =
const onTrial =
notNullish(cusProduct.trial_ends_at) &&
cusProduct.trial_ends_at! > Date.now();

View File

@@ -178,7 +178,7 @@ export const handleEventSent = async ({
customer_id,
customer_data,
event_data,
logger: req.logtail,
logger: req.logger,
entityId: event_data.entity_id,
entityData: event_data.entity_data,
features,

View File

@@ -141,7 +141,7 @@ export const handleUsageEvent = async ({
entity_id,
idempotency_key,
} = req.body;
const { logtail: logger } = req;
const { logger } = req;
if (!customer_id || !feature_id) {
throw new RecaseError({

View File

@@ -1,8 +1,7 @@
import { createStripeCli } from "@/external/stripe/utils.js";
import { Router } from "express";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { handleRequestError } from "@/utils/errorUtils.js";
import { Router } from "express";
export const invoiceRouter: Router = Router();

View File

@@ -10,8 +10,8 @@ import { OrgService } from "@/internal/orgs/OrgService.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { triggerRedemption } from "@/internal/rewards/referralUtils.js";
import { triggerFreeProduct } from "@/internal/rewards/referralUtils/triggerFreeProduct.js";
import { triggerRedemption } from "@/internal/rewards/referralUtils.js";
import { getRewardCat } from "@/internal/rewards/rewardUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
@@ -24,7 +24,7 @@ export default async (req: any, res: any) =>
res,
action: "redeem referral code",
handler: async (req, res) => {
const { orgId, env, logtail: logger, db } = req;
const { orgId, env, logger, db } = req;
const { code, customer_id: customerId } = req.body;
// 1. Get redeemed by customer, and referral code

View File

@@ -23,7 +23,7 @@ export default async (req: any, res: any) =>
res,
action: "create coupon",
handler: async (req, res) => {
const { db, orgId, env, logtail: logger } = req;
const { db, orgId, env, logger } = req;
const rewardBody = req.body;
const rewardData = CreateRewardSchema.parse(rewardBody);

View File

@@ -1,5 +1,5 @@
import { ErrCode } from "@autumn/shared";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import RecaseError from "@/utils/errorUtils.js";

View File

@@ -1,6 +1,6 @@
import { ErrCode, PriceType, RewardCategory } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
@@ -16,7 +16,7 @@ export default async (req: any, res: any) =>
action: "update coupon",
handler: async (req, res) => {
const { internalId } = req.params;
const { orgId, env, db, logtail: logger } = req;
const { orgId, env, db, logger } = req;
const rewardBody = req.body;
const org = await OrgService.getFromReq(req);

View File

@@ -0,0 +1,11 @@
import { user as userTable } from "@autumn/shared";
import { eq } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
export class UserService {
static async getByEmail({ db, email }: { db: DrizzleCli; email: string }) {
return await db.query.user.findFirst({
where: eq(userTable.email, email),
});
}
}

View File

@@ -5,8 +5,8 @@ import {
SuccessCode,
} from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js";
import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
@@ -31,7 +31,7 @@ export const handleCreateCheckout = async ({
config: AttachConfig;
returnCheckout?: boolean;
}) => {
const { db, logtail: logger } = req;
const { db, logger } = req;
const { customer, org, freeTrial, successUrl, rewards } = attachParams;

View File

@@ -41,7 +41,7 @@ export const handleRenewProduct = async ({
attachParams: AttachParams;
config: AttachConfig;
}) => {
const logger = req.logtail;
const logger = req.logger;
const { stripeCli } = attachParams;
let { curScheduledProduct } = attachParamToCusProducts({ attachParams });
@@ -119,6 +119,7 @@ export const handleRenewProduct = async ({
updates: {
canceled: false,
canceled_at: null,
ended_at: null,
},
});
} else {
@@ -159,6 +160,7 @@ export const handleRenewProduct = async ({
scheduled_ids: [schedule.id],
canceled: false,
canceled_at: null,
ended_at: null,
},
});
} else {
@@ -181,6 +183,7 @@ export const handleRenewProduct = async ({
updates: {
canceled: false,
canceled_at: null,
ended_at: null,
},
});
}
@@ -209,6 +212,7 @@ export const handleRenewProduct = async ({
updates: {
canceled: false,
canceled_at: null,
ended_at: null,
},
});
}

View File

@@ -8,6 +8,7 @@ import {
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import {
@@ -29,7 +30,6 @@ import { paramsToScheduleItems } from "../../mergeUtils/paramsToScheduleItems.js
import { getCurrentPhaseIndex } from "../../mergeUtils/phaseUtils/phaseUtils.js";
import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js";
import { updateCurSchedule } from "../../mergeUtils/updateCurSchedule.js";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
export const handleScheduleFunction2 = async ({
req,
@@ -81,8 +81,10 @@ export const handleScheduleFunction2 = async ({
subItemInCusProduct({ cusProduct: curCusProduct, subItem: item }),
);
if (subItems.length == 0) {
logger.error(`SCHEDULE FLOW: subItems is empty, curCusProduct: ${curCusProduct.product.name}`);
if (subItems.length === 0) {
logger.error(
`SCHEDULE FLOW: subItems is empty, curCusProduct: ${curCusProduct.product.name}`,
);
throw new InternalError({
message: `SCHEDULE FLOW: subItems is empty, curCusProduct: ${curCusProduct.product.name}`,
});

View File

@@ -177,6 +177,7 @@ export const handleUpgradeFlow = async ({
updates: {
subscription_ids: canceled ? undefined : [],
status: CusProductStatus.Expired,
ended_at: Date.now(),
},
});
@@ -229,8 +230,6 @@ export const handleUpgradeFlow = async ({
}
if (res) {
if (req.apiVersion.gte(ApiVersion.V1_1)) {
res.status(200).json(
AttachResultSchema.parse({

View File

@@ -5,12 +5,12 @@ import {
type FullCusProduct,
} from "@autumn/shared";
import { Router } from "express";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
import {
createStripeCusIfNotExists,
getCusPaymentMethod,
} from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { CusService } from "@/internal/customers/CusService.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import {
@@ -140,7 +140,7 @@ export const checkStripeConnections = async ({
useCheckout?: boolean;
}) => {
const { org, customer, products, stripeCus, stripeCli } = attachParams;
const logger = req.logtail;
const logger = req.logger;
const env = customer.env;
// 2. If invoice only and no email, save email

View File

@@ -1,5 +1,5 @@
import type { FullCustomer, FullProduct } from "@autumn/shared";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { getFreeTrialAfterFingerprint } from "@/internal/products/free-trials/freeTrialUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";

View File

@@ -12,7 +12,7 @@ import {
type Organization,
} from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import type {
AttachParams,
InsertCusProductParams,

View File

@@ -1,6 +1,6 @@
import { type AttachBody, ErrCode } from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";

View File

@@ -1,6 +1,6 @@
import { ErrCode } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";

View File

@@ -1,9 +1,13 @@
import { createStripeCli } from "@/external/stripe/utils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { CusProductStatus, FullCusProduct, FullCustomer } from "@autumn/shared";
import { cusProductToSub } from "../cusProducts/cusProductUtils/convertCusProduct.js";
import {
CusProductStatus,
type FullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { cusProductToSub } from "../cusProducts/cusProductUtils/convertCusProduct.js";
export const cancelEndOfCycle = async ({
req,

View File

@@ -1,20 +1,18 @@
import { createStripeCli } from "@/external/stripe/utils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
AttachScenario,
CusProductStatus,
FullCusProduct,
FullCustomer,
cusProductToProduct,
type FullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import { cusProductToProduct } from "@autumn/shared";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { activateDefaultProduct } from "../cusProducts/cusProductUtils.js";
import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { cusProductToSub } from "../cusProducts/cusProductUtils/convertCusProduct.js";
import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { activateDefaultProduct } from "../cusProducts/cusProductUtils.js";
export const cancelImmediately = async ({
req,

View File

@@ -1,6 +1,10 @@
import { createStripeCli } from "@/external/stripe/utils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { FullCusProduct, FullCustomer, CusProductStatus } from "@autumn/shared";
import {
CusProductStatus,
type FullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { cusProductToSchedule } from "../cusProducts/cusProductUtils/convertCusProduct.js";

View File

@@ -11,7 +11,7 @@ import {
ProrationBehavior,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";

View File

@@ -1,14 +1,10 @@
import {
CusExpand,
FullCusEntWithFullCusProduct,
Organization,
} from "@autumn/shared";
import { AppEnv } from "autumn-js";
import { type CusExpand, type Organization } from "@autumn/shared";
import type { AppEnv } from "autumn-js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { logger } from "@/external/logtail/logtailUtils.js";
import { buildBaseCusCacheKey } from "./cusCacheUtils.js";
import { getCusWithCache } from "./getCusWithCache.js";
import { initUpstash } from "./upstashUtils.js";
import { logger } from "@/external/logtail/logtailUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
export const refreshCusCache = async ({
db,
@@ -43,14 +39,14 @@ export const refreshCusCache = async ({
for (const key of list) {
const refresh = async () => {
const keyName = key;
let params = keyName.split(":");
let expandParam = params.find((p) => p.startsWith("expand_"));
let expand = expandParam
const params = keyName.split(":");
const expandParam = params.find((p) => p.startsWith("expand_"));
const expand = expandParam
? expandParam.replace("expand_", "").split(",")
: [];
let entityIdParam = params.find((p) => p.startsWith("entity_"));
let entityId = entityIdParam
const entityIdParam = params.find((p) => p.startsWith("entity_"));
const entityId = entityIdParam
? entityIdParam.replace("entity_", "")
: undefined;

View File

@@ -15,7 +15,7 @@ import {
} from "@autumn/shared";
import { logger } from "better-auth";
import { Decimal } from "decimal.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import {

View File

@@ -19,8 +19,8 @@ import {
type UsagePriceConfig,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { ProductService } from "@/internal/products/ProductService.js";
import {

View File

@@ -2,8 +2,8 @@ import { ErrCode } from "@autumn/shared";
import { Router } from "express";
import { Hono } from "hono";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { CusSearchService } from "@/internal/customers/CusSearchService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
@@ -101,7 +101,7 @@ expressCusRouter.get(
org,
env: req.env,
customer,
logger: req.logtail,
logger: req.logger,
});
if (!newCus) {

View File

@@ -8,7 +8,7 @@ import {
type FullCustomer,
type FullProduct,
} from "@autumn/shared";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { addCustomerCreatedTask } from "@/internal/analytics/handlers/handleCustomerCreated.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";

View File

@@ -1,7 +1,11 @@
import {
type AppEnv,
CusExpand,
type FullCustomer,
type Organization,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AppEnv, CusExpand, FullCustomer, Organization } from "@autumn/shared";
export const getCusPaymentMethodRes = async ({
org,
@@ -18,12 +22,12 @@ export const getCusPaymentMethodRes = async ({
return undefined;
}
let stripeCli = createStripeCli({
const stripeCli = createStripeCli({
org,
env,
});
let paymentMethod = await getCusPaymentMethod({
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: fullCus.processor?.id,
errorIfNone: false,

View File

@@ -7,8 +7,8 @@ import {
RewardType,
} from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
export const getCusRewards = async ({
org,

View File

@@ -8,10 +8,10 @@ import {
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { lineItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { stripeDiscountToResponse } from "./stripeDiscountToResponse.js";
export const getCusUpcomingInvoice = async ({

View File

@@ -1,7 +1,7 @@
import { ErrCode } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";

View File

@@ -1,8 +1,8 @@
import { ErrCode } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js";
@@ -81,7 +81,7 @@ export const handleCreateBillingPortal = async (req: any, res: any) =>
org,
env: req.env,
customer,
logger: req.logtail,
logger: req.logger,
});
if (!newCus) {
@@ -120,14 +120,14 @@ export const handleCreateBillingPortal = async (req: any, res: any) =>
) {
try {
// Create a default billing portal configuration
req.logtail?.info(
req.logger?.info(
`Creating default billing portal configuration for customer ${customer.id}`,
);
const configuration =
await createDefaultBillingPortalConfiguration(stripeCli);
req.logtail?.info(
req.logger?.info(
"Successfully created billing portal configuration",
{
configurationId: configuration.id,
@@ -142,13 +142,10 @@ export const handleCreateBillingPortal = async (req: any, res: any) =>
configuration: configuration.id,
});
} catch (configError: any) {
req.logtail?.error(
"Failed to create billing portal configuration",
{
error: configError.message,
orgId: org.id,
},
);
req.logger?.error("Failed to create billing portal configuration", {
error: configError.message,
orgId: org.id,
});
throw new RecaseError({
message: `Failed to create billing portal configuration: ${configError.message}`,
code: ErrCode.StripeError,

View File

@@ -1,30 +1,24 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import {
CusProductStatus,
cusProductToPrices,
ErrCode,
type FullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import {
ACTIVE_STATUSES,
CusProductService,
} from "@/internal/customers/cusProducts/CusProductService.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import {
cancelCusProductSubscriptions,
expireAndActivate,
fullCusProductToProduct,
} from "@/internal/customers/cusProducts/cusProductUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
ErrCode,
CusProductStatus,
FullCusProduct,
Organization,
AppEnv,
FullCustomer,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { CusService } from "../CusService.js";
import { cusProductToPrices } from "@autumn/shared";
export const expireCusProduct = async ({
req,
cusProduct, // cus product to expire
@@ -61,8 +55,8 @@ export const expireCusProduct = async ({
// }
// 1. If main product, can't expire if there's scheduled product
let isMain = !cusProduct.product.is_add_on;
let { curScheduledProduct: futureProduct } = getExistingCusProducts({
const isMain = !cusProduct.product.is_add_on;
const { curScheduledProduct: futureProduct } = getExistingCusProducts({
product: cusProduct.product,
cusProducts: fullCus.customer_products,
internalEntityId: cusProduct.internal_entity_id,
@@ -173,7 +167,7 @@ export const handleCusProductExpired = async (req: any, res: any) => {
const { db } = req;
const customerProductId = req.params.customer_product_id;
let cusProduct = await CusProductService.get({
const cusProduct = await CusProductService.get({
db,
id: customerProductId,
orgId: req.orgId,

View File

@@ -1,12 +1,15 @@
import { type AppEnv, ErrCode, type Organization } from "@autumn/shared";
import chalk from "chalk";
import RecaseError from "@/utils/errorUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { StatusCodes } from "http-status-codes";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { deleteStripeCustomer } from "@/external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import RecaseError from "@/utils/errorUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { AppEnv, ErrCode, Organization } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
export const deleteCusById = async ({
db,
@@ -40,7 +43,7 @@ export const deleteCusById = async ({
});
}
let response = {
const response = {
customer,
success: true,
};
@@ -80,7 +83,7 @@ export const handleDeleteCustomer = async (req: any, res: any) =>
res,
action: "delete customer",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { env, logtail: logger, db, org } = req;
const { env, logger, db, org } = req;
const { delete_in_stripe } = req.query;
const data = await deleteCusById({

Some files were not shown because too many files have changed in this diff Show More