Merge branch 'staging' into feat/plan-api
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"terminals": []
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
30
AGENTS.md
30
AGENTS.md
@@ -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
|
||||
|
||||
40
CLAUDE.md
40
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
# });
|
||||
11
package.json
11
package.json
@@ -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
21
scripts/package.json
Normal 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
145
scripts/setup-test.ts
Normal 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();
|
||||
128
scripts/setupTestUtils/createTestOrg.ts
Normal file
128
scripts/setupTestUtils/createTestOrg.ts
Normal 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 };
|
||||
87
scripts/setupTestUtils/incrementalEnvUpdate.ts
Normal file
87
scripts/setupTestUtils/incrementalEnvUpdate.ts
Normal 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.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
136
scripts/setupTestUtils/setupPrompts.ts
Normal file
136
scripts/setupTestUtils/setupPrompts.ts
Normal 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;
|
||||
}
|
||||
183
scripts/setupTestUtils/updateEnvFile.ts
Normal file
183
scripts/setupTestUtils/updateEnvFile.ts
Normal 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
279
scripts/test.ts
Normal 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
25
scripts/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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
31
server/register.ts
Normal 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));
|
||||
@@ -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"
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
121
server/src/external/connect/connectUtils.ts
vendored
Normal file
121
server/src/external/connect/connectUtils.ts
vendored
Normal 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;
|
||||
};
|
||||
71
server/src/external/connect/createStripeCli.ts
vendored
Normal file
71
server/src/external/connect/createStripeCli.ts
vendored
Normal 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}`,
|
||||
});
|
||||
};
|
||||
119
server/src/external/connect/initStripeCli.ts
vendored
Normal file
119
server/src/external/connect/initStripeCli.ts
vendored
Normal 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;
|
||||
};
|
||||
44
server/src/external/connect/registerConnectWebhook.ts
vendored
Normal file
44
server/src/external/connect/registerConnectWebhook.ts
vendored
Normal 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;
|
||||
};
|
||||
15
server/src/external/logtail/logtailUtils.ts
vendored
15
server/src/external/logtail/logtailUtils.ts
vendored
@@ -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>;
|
||||
|
||||
16
server/src/external/posthog/createPosthogCli.ts
vendored
16
server/src/external/posthog/createPosthogCli.ts
vendored
@@ -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",
|
||||
});
|
||||
};
|
||||
20
server/src/external/posthog/posthogCapture.ts
vendored
20
server/src/external/posthog/posthogCapture.ts
vendored
@@ -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);
|
||||
}
|
||||
};
|
||||
6
server/src/external/redis/redisUtils.ts
vendored
6
server/src/external/redis/redisUtils.ts
vendored
@@ -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;
|
||||
|
||||
8
server/src/external/resend/loopsUtils.ts
vendored
8
server/src/external/resend/loopsUtils.ts
vendored
@@ -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, {
|
||||
|
||||
273
server/src/external/stripe/handleStripeWebhookEvent.ts
vendored
Normal file
273
server/src/external/stripe/handleStripeWebhookEvent.ts
vendored
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
2
server/src/external/stripe/stripeCusUtils.ts
vendored
2
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -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,
|
||||
|
||||
15
server/src/external/stripe/stripeEnsureUtils.ts
vendored
15
server/src/external/stripe/stripeEnsureUtils.ts
vendored
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
269
server/src/external/stripe/stripeWebhooks.ts
vendored
269
server/src/external/stripe/stripeWebhooks.ts
vendored
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
39
server/src/external/stripe/utils.ts
vendored
39
server/src/external/stripe/utils.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
71
server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubPastDue.ts
vendored
Normal file
71
server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubPastDue.ts
vendored
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
5
server/src/external/svix/svixUtils.ts
vendored
5
server/src/external/svix/svixUtils.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
121
server/src/external/webhooks/connectWebhookRouter.ts
vendored
Normal file
121
server/src/external/webhooks/connectWebhookRouter.ts
vendored
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
231
server/src/honoMiddlewares/errorSkipMiddleware.ts
Normal file
231
server/src/honoMiddlewares/errorSkipMiddleware.ts
Normal 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);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
server/src/internal/auth/UserService.ts
Normal file
11
server/src/internal/auth/UserService.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user