Merge branch 'staging' into feat/plan-editor

This commit is contained in:
John Yeo
2025-10-14 20:18:04 +01:00
61 changed files with 3454 additions and 489 deletions

View File

@@ -155,7 +155,6 @@
"dependencies": {
"@autumn/shared": "workspace:*",
"@better-auth/stripe": "^1.2.12",
"@clerk/clerk-react": "^5.24.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@heroicons/react": "^2.2.0",
@@ -338,9 +337,13 @@
"@clerk/backend": ["@clerk/backend@2.17.2", "", { "dependencies": { "@clerk/shared": "^3.27.3", "@clerk/types": "^4.92.0", "cookie": "1.0.2", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-zgKySfoOXySYOMEDc+S2vXLchCldwPVb85tyvP1NnmxvgyAm10yCA+xd4No4dNWm+lwkwHuNWX3rM9ro8khSmw=="],
<<<<<<< HEAD
"@clerk/clerk-react": ["@clerk/clerk-react@5.51.0", "", { "dependencies": { "@clerk/shared": "^3.27.3", "@clerk/types": "^4.92.0", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-jBreKiUS4DKm+JUIt59B1699aRr3wQmQ1O+DlWCEY3iEz+F6ETir1SJcRNG5mHVoA9EGn+m93USJSUWZBRG8yQ=="],
"@clerk/express": ["@clerk/express@1.7.37", "", { "dependencies": { "@clerk/backend": "^2.17.2", "@clerk/shared": "^3.27.3", "@clerk/types": "^4.92.0", "tslib": "2.8.1" }, "peerDependencies": { "express": "^4.17.0 || ^5.0.0" } }, "sha512-SGDz+hyP7aXwQ7JhVxf4ANpJ8gIFIp0a2gKxc6uFCeIszFsAKE5kfVtnXF5bhdCgtVRUCvHF09WYNpeygIPHqQ=="],
=======
"@clerk/express": ["@clerk/express@1.7.21", "", { "dependencies": { "@clerk/backend": "^2.9.1", "@clerk/shared": "^3.21.0", "@clerk/types": "^4.78.0", "tslib": "2.8.1" }, "peerDependencies": { "express": "^4.17.0 || ^5.0.0" } }, "sha512-ALtBBhaYU+gfRoJp25hC3OntbNNgcrvLSyIYVShSCQ8gmyFjrJe/u6wb2PJalKtiE0SJUZPCAPCr8QpWSXEIsg=="],
>>>>>>> staging
"@clerk/shared": ["@clerk/shared@3.27.3", "", { "dependencies": { "@clerk/types": "^4.92.0", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-OJqWwlQGi6XMVWJVtY1YmOESAkEAflDrynFSjwQQ/sC8c4hmUukIq07XTOlcv6j4u1i4akhtNwy40B1qiRrLdg=="],

View File

@@ -1,10 +1,51 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { detectAndSetPorts } from "./detect-ports.js";
/**
* Read environment variable from .env file
*/
function getEnvVariable(filePath, key) {
if (!fs.existsSync(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const [envKey, ...valueParts] = trimmed.split("=");
if (envKey && envKey.trim() === key) {
return valueParts.join("=");
}
}
}
return null;
}
async function startDev() {
try {
// Detect and set ports
const { vitePort, serverPort } = await detectAndSetPorts();
// Check if using remote backend (api.useautumn.com)
const rootDir = path.dirname(new URL(import.meta.url).pathname);
const projectRoot = path.join(rootDir, "..");
const viteEnvPath = path.join(projectRoot, "vite", ".env");
const backendUrl =
process.env.VITE_BACKEND_URL || getEnvVariable(viteEnvPath, "VITE_BACKEND_URL");
const isUsingRemoteBackend = backendUrl && backendUrl.includes("api.useautumn.com");
let vitePort = 3000;
let serverPort = 8080;
if (isUsingRemoteBackend) {
console.log("\n🌐 Using remote backend (api.useautumn.com)");
console.log("⏭️ Skipping port detection...\n");
} else {
// Detect and set ports
const ports = await detectAndSetPorts();
vitePort = ports.vitePort;
serverPort = ports.serverPort;
}
// Step 1: Build shared package first (initial build)
console.log("\n📦 Building shared package...\n");

View File

@@ -1,17 +1,52 @@
// @ts-nocheck
await autumn.customerProducts.create({
customer_id: "",
product_id: "pro",
import { customers, } from "@autumn/shared";
const entitiesCTE = cte({
name: 'entities',
from: entities,
where: eq(entities.internal_customer_id, customer.internal_id),
limit: 100,
})
await autumn.customerLicenses.create({
customer_id: "",
product_id: "pro",
quantity: 5,
license_to: ["ent_1"]
const cusProductsCTE = cte({
name: 'customer_products',
from: customer_products,
with: {
product: cte({
from: products,
where: eq(products.internal_id, customer_products.internal_product_id),
}),
customer_prices: cte({
from: customer_prices,
where: eq(customer_prices.customer_product_id, customer_products.id),
with: {
price: join({
from: prices,
where: eq(prices.id, customer_prices.price_id),
})
}
}),
free_trial: cte({
from: free_trials,
where: eq(free_trials.id, customer_products.free_trial_id),
})
},
// where: eq(customer_products.internal_customer_id, customer.internal_id),
limit: 100,
})
const fullCustomerCTE = cte({
name: 'full_customer',
from: customers, // drizzle table
with: {
entities: entitiesCTE(),
customer_products: cusProductsCTE(),
organization: organizationsCTE(), // this is not an array, but buildCTE should be dynamic enough to handle this?
}
});
await fullCustomerCTE.execute();
const childProduct = {
product_id: "user_seat",
entity: {

View File

@@ -9,18 +9,18 @@ if [[ "$1" == *"setup"* ]]; then
fi
# $MOCHA_CMD 'tests/advanced/referrals/*.ts' 'tests/advanced/coupons/*.ts'
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
'tests/advanced/coupons/*.ts' \
'tests/attach/updateQuantity/*.ts' \
'tests/advanced/referrals/*.ts' \
'tests/advanced/referrals/paid/*.ts' \
'tests/advanced/rollovers/*.ts' \
'tests/advanced/customInterval/*.ts'
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
# 'tests/advanced/coupons/*.ts' \
# 'tests/attach/updateQuantity/*.ts' \
# 'tests/advanced/referrals/*.ts' \
# 'tests/advanced/referrals/paid/*.ts' \
# 'tests/advanced/rollovers/*.ts' \
# 'tests/advanced/customInterval/*.ts'
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
'tests/advanced/usageLimit/*.ts'
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
# 'tests/advanced/usageLimit/*.ts'
$MOCHA_CMD 'tests/advanced/usage/*.ts'
# $MOCHA_CMD 'tests/advanced/usage/*.ts'

View File

@@ -36,16 +36,16 @@ import { checkCusSubCorrect } from "./utils/checkUtils/checkCustomerCorrect.js";
const { db } = initDrizzle({ maxConnections: 5 });
let orgSlugs = process.env.ORG_SLUGS!.split(",");
const orgSlugs = process.env.ORG_SLUGS!.split(",");
const skipEmails = process.env.SKIP_EMAILS!.split(",");
const skipIds = [
"cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx",
"DxG668K7uDd0Vahk54YWjvCGVgf2",
];
orgSlugs = ["supermemory"];
let customerId = null;
customerId = "EBbxiRv9QJKXFy5WiAKeHi";
// orgSlugs = ["lumenary"];
const customerId = null;
// customerId = "EBbxiRv9QJKXFy5WiAKeHi";
const getSingleCustomer = async ({
stripeCli,

View File

@@ -0,0 +1,858 @@
# CTE Builder - Technical Documentation
## Overview
A declarative TypeScript-native query builder for PostgreSQL CTEs (Common Table Expressions) with automatic join inference from Drizzle relations. Enables building complex nested queries with a Drizzle-like syntax while maintaining type safety and composability.
**Performance**: The CTE Builder is **42.8% faster** than handwritten queries in production (see [OPTIMIZATION_STATUS.md](./OPTIMIZATION_STATUS.md) for benchmarks).
## Table of Contents
1. [Quick Start](#quick-start)
2. [Architecture Overview](#architecture-overview)
3. [Core Systems](#core-systems)
4. [Query Strategies](#query-strategies)
5. [Advanced Usage](#advanced-usage)
6. [Extension Guide](#extension-guide)
7. [Common Pitfalls](#common-pitfalls)
8. [Debugging](#debugging)
---
## Quick Start
### Basic Query
```typescript
import { cte } from "./db/cteUtils/buildCte.js";
import { users, posts } from "@autumn/shared";
import { eq } from "drizzle-orm";
const usersWithPosts = cte({
from: users,
where: eq(users.org_id, orgId),
with: {
posts: cte({ from: posts }) // Join inferred automatically
}
});
const { data, count } = await usersWithPosts.execute({ db });
```
### Nested Relations (4 levels deep)
```typescript
const customersWithProducts = cte({
from: customers,
where: eq(customers.org_id, orgId),
limit: 100,
with: {
customer_products: cte({
from: customerProducts,
with: {
product: cte({ from: products }),
customer_entitlements: cte({
from: customerEntitlements,
with: {
entitlement: cte({
from: entitlements,
with: {
feature: cte({ from: features })
}
})
}
})
}
})
}
});
```
### Additional Filters
```typescript
with: {
active_posts: cte({
from: posts,
where: eq(posts.status, 'active'), // Added to join condition with AND
orderBy: [desc(posts.created_at)],
limit: 10
})
}
```
---
## Architecture Overview
The CTE Builder uses a **dual-strategy architecture** that automatically selects the optimal SQL generation approach:
```
┌─────────────────────────────────────────────────┐
│ CTEBuilder.toSQL() │
│ │
│ 1. Parse config & detect complexity │
│ 2. shouldUseJoinStrategy()? │
│ ├─ No → Correlated Subquery Strategy │
│ └─ Yes → JOIN + GROUP BY Strategy │
└─────────────────────────────────────────────────┘
```
### File Structure
```
cteUtils/
├── buildCte.ts # Main entry point, CTEBuilder class
├── relationUtils.ts # Drizzle relation path finding
├── typeDetection.ts # Array vs row mode inference
├── sqlGenerators.ts # SQL building utilities
├── strategies/
│ ├── strategySelector.ts # Auto-detection logic
│ ├── relationGraph.ts # Relation tree mapping
│ └── joinGroupByStrategy.ts # JOIN optimization (future)
├── README.md # This file
├── OPTIMIZATION_STATUS.md # Performance benchmarks
└── REFACTOR_PLAN.md # Original optimization plan
```
---
## Core Systems
### 1. Relations Extraction (`buildCte.ts:22-94`)
**Problem**: Drizzle stores relations as lazy-loaded functions, not plain objects.
**Solution**: Mock the `one()` and `many()` helpers to extract relation metadata:
```typescript
const mockHelpers = {
one: (table, config) => ({
referencedTable: () => table, // Function returning table
fields: config?.fields || [], // Foreign key columns
references: config?.references || [], // Referenced columns
isOne: true,
withFieldName: (name) => {
relation.fieldName = name;
return relation;
}
}),
many: (table) => ({
referencedTable: () => table,
isMany: true,
withFieldName: (name) => { ... }
})
}
```
**Key Insight**: Relations are indexed by **actual table name** (snake_case from DB), not the TypeScript export name (camelCase).
```typescript
// Indexed as "customer_products", not "customerProducts"
const relations = {
"customer_products": { /* relation metadata */ },
"customers": { /* relation metadata */ }
};
```
### 2. CTEBuilder Class (`buildCte.ts:124-223`)
The main class that orchestrates CTE generation.
**Core Method**: `toSQL()` generates the CTE definition.
```typescript
class CTEBuilder {
toSQL(): SQL {
// 1. Check if cached
if (this.sqlCache) return this.sqlCache;
// 2. Strategy selection
const useJoinStrategy = shouldUseJoinStrategy({ config: this.config });
if (useJoinStrategy) {
// Use optimized JOIN + GROUP BY strategy (future)
return buildJoinGroupByQuery({ config, relations, extractJoinCondition });
}
// 3. Use correlated subquery strategy (current)
// ... build query with nested subqueries
}
}
```
**Execution Flow**:
1. Select all base columns: `SELECT *`
2. Add nested fields as JSON subqueries: `SELECT *, (nested_subquery) AS field_name`
3. Apply WHERE, ORDER BY, LIMIT
4. Wrap in CTE definition: `cte_name AS (query)`
### 3. Automatic Join Inference (`buildCte.ts:305-375`)
**Two-step process**:
#### Step 1: Find Relation Path (`relationUtils.ts:23-88`)
```typescript
function findRelationPath({ from, to, relations }): RelationPath {
// 1. Check direct one() relationship → returns "row"
// 2. Check direct many() relationship → returns "array"
// 3. Check many-to-many through junction table → returns "array" with junction
// 4. Throw error if no path found
}
```
#### Step 2: Extract Join Condition (`buildCte.ts:305-375`)
```typescript
function extractJoinCondition({ parentTable, targetTable, fieldName }): SQL {
// 1. Look up relation from parent table
const relation = relations[parentTableName][fieldName];
// 2. Forward relation (has fields + references)
if (relation.fields.length > 0) {
return sql`${targetTable}.${reference} = ${parentTable}.${field}`;
}
// 3. Reverse relation (only defined on child)
const reverseRelation = findReverseRelation(targetTable, parentTable);
return sql`${targetTable}.${field} = ${parentTable}.${reference}`;
}
```
**Critical**: Call `referencedTable()` function to get actual table object, not the function itself.
### 4. Deep Nesting (`buildCte.ts:210-303`)
**Problem**: `row_to_json(table_name)` only serializes base columns, not nested relations.
**Solution**: Build custom SELECT with nested subqueries:
```sql
SELECT
target_table.*, -- All base columns
(nested_subquery_1) AS nested_field_1,
(nested_subquery_2) AS nested_field_2
FROM target_table
WHERE join_condition
```
Then wrap in aggregation:
- **Array mode**: `SELECT json_agg(row_to_json(sub)) FROM (inner_select) sub`
- **Row mode**: `SELECT row_to_json(sub) FROM (inner_select) sub`
**Recursion**: `buildNestedField()` calls itself for each level of nesting.
### 5. Mode Detection (`typeDetection.ts:16-49`)
Determines whether a nested field should return `array` or `row`:
```typescript
function inferMode(config: ModeDetectionConfig): CTEMode {
// 1. Explicit mode always wins
if (config.mode) return config.mode;
// 2. Has through? → array (many-to-many)
if (config.through) return "array";
// 3. Has limit > 1? → array
if (config.limit !== undefined && config.limit !== 1) return "array";
// 4. Has orderBy? → array (ordering implies multiple results)
if (config.orderBy?.length > 0) return "array";
// 5. Plural field name? → array (posts, users, entities)
// Excludes words ending in 'ss' (address, process)
if (fieldName?.endsWith("s") && !fieldName.endsWith("ss")) return "array";
// 6. Default: row (safer for 1:1 relationships)
return "row";
}
```
---
## Query Strategies
### Current: Correlated Subquery Strategy (Default)
**Performance**: ~1097ms for 100 customers with 4 levels of nesting ✅
**Generated SQL Pattern**:
```sql
SELECT
customers.*,
COALESCE((
SELECT json_agg(row_to_json(sub))
FROM (
SELECT cp.*,
COALESCE((
SELECT json_agg(row_to_json(sub2))
FROM (
SELECT ce.* FROM customer_entitlements ce
WHERE ce.customer_product_id = cp.id
) sub2
), '[]'::json) AS customer_entitlements
FROM customer_products cp
WHERE cp.customer_id = customers.id
) sub
), '[]'::json) AS customer_products
FROM customers
WHERE org_id = $1
LIMIT 100
```
**Characteristics**:
- ✅ Simple, predictable SQL generation
- ✅ Consistent performance (143ms variance)
- ✅ Works with any nesting depth
- ✅ No complex JOIN logic needed
- ⚠️ Uses correlated subqueries (one per parent row per nested field)
**When PostgreSQL optimizes this well**:
- Modern PostgreSQL versions (12+) with good statistics
- Proper indexes on foreign key columns
- Warm cache scenarios
- Moderate result set sizes (100-500 rows)
### Future: JOIN + GROUP BY Strategy
**Status**: Implemented but not enabled (see `strategies/joinGroupByStrategy.ts`)
**Target Performance**: Potentially faster for very large datasets or cold cache
**Generated SQL Pattern**:
```sql
WITH customer_products_agg AS (
SELECT
cp.internal_customer_id,
json_agg(
jsonb_build_object(
'id', cp.id,
'product', row_to_json(p),
'customer_entitlements', ce_agg.entitlements
) ORDER BY cp.created_at DESC
) AS customer_products
FROM customer_products cp
LEFT JOIN products p ON p.internal_id = cp.internal_product_id
LEFT JOIN customer_entitlements_agg ce_agg ON ce_agg.customer_product_id = cp.id
WHERE cp.internal_customer_id IN (SELECT id FROM customers WHERE org_id = $1)
GROUP BY cp.internal_customer_id
)
SELECT
c.*,
COALESCE(cpa.customer_products, '[]'::json) AS customer_products
FROM customers c
LEFT JOIN customer_products_agg cpa ON cpa.internal_customer_id = c.id
WHERE c.org_id = $1
LIMIT 100
```
**Characteristics**:
- ✅ Single JOIN pass for all rows
- ✅ Efficient GROUP BY aggregation
- ✅ Better for very large datasets
- ⚠️ More complex SQL generation
- ⚠️ Requires careful NULL handling
**To Enable**: See [OPTIMIZATION_STATUS.md](./OPTIMIZATION_STATUS.md) for instructions.
---
## Advanced Usage
### Configuration Options
```typescript
interface CTEConfig {
name?: string; // Custom CTE name
from: PgTable | CTEBuilder; // Source table or nested CTE
with?: Record<string, CTEConfig | CTEBuilder>; // Nested relations
where?: SQL; // Filter conditions
orderBy?: SQL[]; // Sorting
limit?: number; // Result limit
offset?: number; // Result offset
mode?: "array" | "row"; // Force array/row mode
through?: ThroughConfig; // Many-to-many config
filter?: SQL; // Additional filter (deprecated, use where)
distinct?: boolean; // DISTINCT results
strategy?: "correlated" | "join_group_by" | "auto"; // Force strategy
}
```
### Explicit Strategy Selection
```typescript
// Force JOIN strategy (requires fixes, see OPTIMIZATION_STATUS.md)
const query = cte({
from: customers,
strategy: "join_group_by", // ← Explicit override
with: { /* ... */ }
});
// Force correlated strategy (default)
const query = cte({
from: customers,
strategy: "correlated", // ← Explicit override
with: { /* ... */ }
});
```
### Many-to-Many Relationships
```typescript
const usersWithOrganizations = cte({
from: users,
with: {
organizations: cte({
from: organizations,
through: {
table: members,
from: sql`${members.user_id} = ${users.id}`,
to: sql`${organizations.id} = ${members.org_id}`
}
})
}
});
```
### Custom Mode Override
```typescript
with: {
// Force array mode even for singular name
address: cte({
from: addresses,
mode: "array" // ← Override inference
}),
// Force row mode even for plural name
latest_posts: cte({
from: posts,
limit: 1,
mode: "row" // ← Override inference
})
}
```
---
## Extension Guide
### Adding a New Strategy
To add a new query generation strategy:
1. **Create strategy file** in `strategies/`:
```typescript
// strategies/myNewStrategy.ts
import { type SQL, sql } from "drizzle-orm";
import type { CTEConfig } from "../buildCte.js";
export function buildMyNewStrategy({
config,
relations,
extractJoinCondition,
}: {
config: CTEConfig;
relations: Record<string, any>;
extractJoinCondition: (params: {
parentTable: PgTable;
targetTable: PgTable;
fieldName: string;
}) => SQL | undefined;
}): SQL {
// Your SQL generation logic here
return sql`SELECT ...`;
}
```
2. **Update strategy selector** in `strategies/strategySelector.ts`:
```typescript
export function shouldUseMyNewStrategy({ config }: { config: CTEConfig }): boolean {
// Your detection logic
return config.limit > 1000;
}
```
3. **Integrate in CTEBuilder** in `buildCte.ts`:
```typescript
toSQL(): SQL {
if (shouldUseMyNewStrategy({ config: this.config })) {
return buildMyNewStrategy({ config, relations, extractJoinCondition });
}
// ... existing strategies
}
```
4. **Add tests** to verify correctness and performance.
### Adding Support for New Relation Types
To support new relationship patterns:
1. **Extend relation detection** in `relationUtils.ts`:
```typescript
export function findRelationPath({ from, to, relations }): RelationPath {
// ... existing checks
// Add your new pattern
const customPath = findCustomRelation(from, to, relations);
if (customPath) {
return { type: "array", path: customPath };
}
throw new Error("No relationship found");
}
```
2. **Update join condition extraction** in `buildCte.ts`:
```typescript
private extractJoinCondition({ parentTable, targetTable, fieldName }): SQL {
// ... existing logic
// Handle new relation type
if (isCustomRelation(relation)) {
return buildCustomJoinCondition(relation);
}
}
```
3. **Update mode detection** if needed in `typeDetection.ts`:
```typescript
export function inferMode(config: ModeDetectionConfig): CTEMode {
// ... existing checks
// Add custom mode detection
if (isCustomRelationType(config)) return "array";
}
```
### Adding SQL Generation Helpers
Add reusable SQL builders to `sqlGenerators.ts`:
```typescript
export function generateCustomAggregation({
table,
groupBy,
aggregateField,
}: CustomAggregationConfig): SQL {
const tableName = getTableName(table);
return sql`
SELECT ${groupBy},
json_agg(row_to_json(${sql.identifier(tableName)})) AS ${sql.identifier(aggregateField)}
FROM ${sql.identifier(tableName)}
GROUP BY ${groupBy}
`;
}
```
---
## Common Pitfalls
### 1. Wrong Table Reference in WHERE
**Don't do this**:
```typescript
with: {
posts: cte({
from: posts,
// Wrong: Referencing parent table directly
where: eq(posts.user_id, users.id)
})
}
```
**Do this instead**:
```typescript
with: {
posts: cte({
from: posts,
// Join is inferred automatically
// Only add filters for the child table
where: eq(posts.status, 'published')
})
}
```
### 2. Missing Drizzle Relations
If you get `"No relationship found"`, ensure relations are defined:
```typescript
// In your schema file
export const customersRelations = relations(customers, ({ many }) => ({
customer_products: many(customerProducts)
}));
export const customerProductsRelations = relations(customerProducts, ({ one }) => ({
customer: one(customers, {
fields: [customerProducts.internal_customer_id],
references: [customers.internal_id]
})
}));
```
### 3. Function vs Table
Relations store `referencedTable` as a **function**, not a table:
**Wrong**:
```typescript
const targetTable = relation.referencedTable; // Returns function
const name = getTableName(targetTable); // Fails!
```
**Correct**:
```typescript
const targetTable = relation.referencedTable(); // Call the function
const name = getTableName(targetTable); // Works!
```
### 4. Table Name Mismatch
Relations are indexed by **database table name**, not TypeScript export name:
```typescript
// TypeScript export name
import { customerProducts } from "@autumn/shared";
// Database table name (used in relations index)
const tableName = "customer_products"; // ← snake_case
// Access relations
const rels = relations["customer_products"]; // ✅ Correct
const rels = relations["customerProducts"]; // ❌ Wrong
```
### 5. Mode Detection Confusion
Be aware of automatic mode inference:
```typescript
// These are inferred as "array"
posts: cte({ from: posts }) // Plural name
posts: cte({ from: posts, limit: 10 }) // Limit > 1
posts: cte({ from: posts, orderBy }) // Has ordering
// This is inferred as "row"
latest_post: cte({ from: posts, limit: 1 }) // Limit = 1, singular name
// Override if needed
latest_post: cte({
from: posts,
limit: 1,
mode: "row" // ← Explicit
})
```
---
## Key Drizzle Internals
### Relation Object Structure
```typescript
// Created by relations(table, callback)
{
table: PgTable, // The table this relation is defined on
config: (helpers) => { // Lazy function called with {one, many}
return {
fieldName: {
referencedTable: () => targetTable, // Function, not table!
fields: [column1, column2], // Only on one() relations
references: [refCol1, refCol2], // Only on one() relations
isOne: true, // or isMany: true
fieldName: "fieldName",
relationName: "fieldName"
}
}
}
}
```
### Table Name Extraction
```typescript
// Drizzle stores table name in a Symbol
const tableName = (table as any)[Symbol.for("drizzle:Name")];
// Returns: "table_name" (database name, not TS export name)
```
### Many-to-Many Pattern Detection
A many-to-many relationship is detected when:
1. Parent has `many()` relationship to junction table
2. Junction has `one()` relationship back to parent
3. Junction has `one()` relationship to target table
```typescript
// Example: users ↔ members ↔ organizations
// In users schema
export const usersRelations = relations(users, ({ many }) => ({
members: many(members)
}));
// In members schema (junction table)
export const membersRelations = relations(members, ({ one }) => ({
user: one(users, { fields: [members.user_id], references: [users.id] }),
organization: one(organizations, { fields: [members.org_id], references: [organizations.id] })
}));
// In organizations schema
export const organizationsRelations = relations(organizations, ({ many }) => ({
members: many(members)
}));
```
---
## Performance Characteristics
### Current Production Metrics
**Tested with**: 100 customers, 4 levels of nesting (customer → product → entitlement → feature)
| Metric | Value |
|--------|-------|
| **Average Query Time** | 1097ms |
| **Cold Cache** | 1025ms |
| **Warm Cache** | 1168ms |
| **Cache Variance** | 143ms (very consistent) |
| **vs Handwritten Queries** | 42.8% faster |
### Optimization Notes
1. **Single Query**: No N+1 queries - all data fetched in one database round trip
2. **Correlated Subqueries**: One subquery per nested field per parent row
3. **PostgreSQL Caching**: Warm cache provides ~10% speedup
4. **Index Importance**: Foreign key indexes are critical for performance
5. **Scalability**: Linear scaling with row count (tested up to 500 rows)
### When to Use CTE Builder vs Handwritten SQL
**Use CTE Builder** ✅:
- Any nesting depth (1-5+ levels) - proven performant
- Any dataset size (10-500 rows) - benchmarked
- Type-safe query composition needed
- Rapid development and iteration
- Code maintainability is priority
- Production endpoints (validated performance)
**Use Handwritten SQL** (Optional):
- Very specific optimization requirements
- Non-standard aggregation patterns not supported by CTE builder
- Extreme performance requirements (sub-100ms)
- Custom window functions or advanced PostgreSQL features
---
## Debugging
### Enable SQL Logging
```typescript
const query = cte({ /* config */ });
// Log generated SQL
const sql = query.toSQL();
console.log("CTE SQL:", sql.toQuery());
// Log execution
const { data, count } = await query.execute({ db });
console.log(`Fetched ${count} rows in ${performance.now() - start}ms`);
```
### Add Relation Tracing
```typescript
// In buildCte.ts:extractJoinCondition()
console.log("Looking for relation:", {
parent: getTableName(parentTable),
target: getTableName(targetTable),
fieldName,
found: !!relation
});
```
### Inspect Relation Graph
```typescript
// In strategies/relationGraph.ts:buildRelationGraph()
console.log("Built relation node:", {
tableName: node.tableName,
mode: node.mode,
parentKey: node.parentKey,
childKey: node.childKey,
nestedFieldCount: Object.keys(node.nestedFields).length
});
```
### Common Debug Points
1. **`buildNestedField()`**: See which relations are being processed
2. **`findRelationPath()`**: Check if relations are found correctly
3. **`extractJoinCondition()`**: Verify JOIN conditions
4. **`inferMode()`**: Understand array vs row decisions
5. **`shouldUseJoinStrategy()`**: See which strategy was selected
### Performance Profiling
```typescript
console.time("CTE Build");
const query = cte({ /* config */ });
console.timeEnd("CTE Build");
console.time("CTE Execute");
const { data, count } = await query.execute({ db });
console.timeEnd("CTE Execute");
console.log("Result size:", JSON.stringify(data).length, "bytes");
```
---
## Additional Resources
- **[OPTIMIZATION_STATUS.md](./OPTIMIZATION_STATUS.md)** - Detailed performance benchmarks and strategy comparison
- **[REFACTOR_PLAN.md](./REFACTOR_PLAN.md)** - Original optimization plan and future improvements
- **Drizzle ORM Docs** - https://orm.drizzle.team/docs/rqb
- **PostgreSQL CTE Docs** - https://www.postgresql.org/docs/current/queries-with.html
---
## Contributing
When extending the CTE builder:
1. **Maintain backward compatibility** - existing queries should continue to work
2. **Add tests** - validate both correctness and performance
3. **Document decisions** - explain why new features are needed
4. **Update benchmarks** - measure impact of changes
5. **Follow patterns** - use existing code style and patterns
### Quick Contribution Checklist
- [ ] Read this README completely
- [ ] Review [OPTIMIZATION_STATUS.md](./OPTIMIZATION_STATUS.md)
- [ ] Understand the dual-strategy architecture
- [ ] Add tests for new features
- [ ] Run benchmarks before/after changes
- [ ] Update documentation
- [ ] Ensure backward compatibility
---
**Last Updated**: 2025-10-14
**Performance Validated**: 100 customers, 4 levels nesting, ~1097ms average
**Production Status**: ✅ Ready - 42.8% faster than handwritten queries

View File

@@ -0,0 +1,637 @@
import { schemas } from "@autumn/shared";
import { type SQL, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import {
findRelationPath,
getJunctionFields,
type RelationPath,
} from "./relationUtils.js";
import { buildJoinGroupByQuery } from "./strategies/joinGroupByStrategy.js";
import { shouldUseJoinStrategy } from "./strategies/strategySelector.js";
import { type CTEMode, inferMode } from "./typeDetection.js";
/**
* Extract table name from Drizzle table object
*/
function getTableName(table: PgTable | any): string {
return (table as any)[Symbol.for("drizzle:Name")] || String(table);
}
/**
* Helper to extract relation metadata by calling the config function
* with mock helpers that capture the relation definitions
*/
function extractRelationMetadata(relationObj: any): Record<string, any> {
if (!relationObj || typeof relationObj.config !== "function") {
return {};
}
// Mock helpers that capture relation metadata
const mockHelpers = {
one: (table: any, config?: any) => {
const relation = {
relationName: "",
referencedTable: () => table,
referencedTableName:
(table as any)[Symbol.for("drizzle:Name")] || table,
isOne: true,
fieldName: "",
fields: config?.fields || [],
references: config?.references || [],
table,
withFieldName: (name: string) => {
relation.fieldName = name;
relation.relationName = name;
return relation;
},
};
return relation;
},
many: (table: any) => {
const relation = {
relationName: "",
referencedTable: () => table,
referencedTableName:
(table as any)[Symbol.for("drizzle:Name")] || table,
isMany: true,
fieldName: "",
table,
withFieldName: (name: string) => {
relation.fieldName = name;
relation.relationName = name;
return relation;
},
};
return relation;
},
};
try {
const result = relationObj.config(mockHelpers);
return result || {};
} catch (error) {
console.warn(
`Failed to extract relation metadata:`,
error instanceof Error ? error.message : error,
);
return {};
}
}
// Extract relations from schema, indexed by actual table name
const relations = Object.entries(schemas).reduce(
(acc, [key, value]) => {
if (key.endsWith("Relations")) {
const relationMetadata = extractRelationMetadata(value);
// Index by the actual table name, not the key name
const table = (value as any).table;
if (table) {
const tableName = getTableName(table);
acc[tableName] = relationMetadata;
}
}
return acc;
},
{} as Record<string, any>,
);
export interface ThroughConfig {
table: PgTable;
from: SQL;
to: SQL;
}
export interface CTEConfig {
name?: string;
from: PgTable | CTEBuilder;
with?: Record<string, CTEConfig | CTEBuilder>;
where?: SQL;
orderBy?: SQL[];
limit?: number;
offset?: number;
mode?: CTEMode;
through?: ThroughConfig;
filter?: SQL;
distinct?: boolean;
strategy?: "correlated" | "join_group_by" | "auto";
}
export interface CTEExecuteOptions {
db: any;
}
/**
* Main CTE builder class
* Handles recursive CTE composition, dependency tracking, and SQL generation
*/
export class CTEBuilder {
name: string;
config: CTEConfig;
dependencies: CTEBuilder[] = [];
private sqlCache?: SQL;
constructor(config: CTEConfig) {
this.config = config;
this.name = config.name || this.generateName();
// Track dependencies if 'from' is another CTE
if (config.from instanceof CTEBuilder) {
this.dependencies.push(config.from);
}
// Track dependencies in nested 'with' CTEs
if (config.with) {
for (const nestedCTE of Object.values(config.with)) {
// If the nested value is a CTEBuilder, add it as a dependency
if (nestedCTE instanceof CTEBuilder) {
this.dependencies.push(nestedCTE);
} else if (nestedCTE.from instanceof CTEBuilder) {
this.dependencies.push(nestedCTE.from);
}
}
}
}
/**
* Generate CTE definition SQL
*/
toSQL(): SQL {
if (this.sqlCache) return this.sqlCache;
// Check if we should use the optimized JOIN strategy
const useJoinStrategy = shouldUseJoinStrategy({ config: this.config });
if (useJoinStrategy) {
// Use optimized JOIN + GROUP BY strategy
const query = buildJoinGroupByQuery({
config: this.config,
relations,
extractJoinCondition: this.extractJoinCondition.bind(this),
});
this.sqlCache = sql`${sql.identifier(this.name)} AS (${query})`;
return this.sqlCache;
}
// Fall back to correlated subquery strategy
const fromTable = this.getSourceTable();
const tableName = getTableName(fromTable);
// Build SELECT clause
// Note: We select all columns without alias to avoid table reference issues in WHERE clauses
const selectFields: SQL[] = [sql`*`];
// Add nested fields from 'with'
if (this.config.with) {
for (const [fieldName, nestedConfig] of Object.entries(
this.config.with,
)) {
const nestedSQL = this.buildNestedField({
fieldName,
nestedConfig,
parentTable: fromTable,
});
selectFields.push(sql`${nestedSQL} AS ${sql.identifier(fieldName)}`);
}
}
// Build the query
let query = sql`SELECT ${sql.join(selectFields, sql`, `)} FROM ${sql.identifier(tableName)}`;
// Add WHERE clause
if (this.config.where) {
query = sql`${query} WHERE ${this.config.where}`;
}
// Add ORDER BY clause
if (this.config.orderBy && this.config.orderBy.length > 0) {
query = sql`${query} ORDER BY ${sql.join(this.config.orderBy, sql`, `)}`;
}
// Add LIMIT clause
if (this.config.limit !== undefined) {
query = sql`${query} LIMIT ${sql.raw(String(this.config.limit))}`;
}
// Add OFFSET clause
if (this.config.offset !== undefined) {
query = sql`${query} OFFSET ${sql.raw(String(this.config.offset))}`;
}
// Wrap in CTE definition
this.sqlCache = sql`${sql.identifier(this.name)} AS (${query})`;
return this.sqlCache;
}
/**
* Build SQL for a nested field in 'with' clause
*/
private buildNestedField({
fieldName,
nestedConfig,
parentTable,
}: {
fieldName: string;
nestedConfig: CTEConfig | CTEBuilder;
parentTable: PgTable;
}): SQL {
// If nestedConfig is already a CTEBuilder, extract its config
const config =
nestedConfig instanceof CTEBuilder ? nestedConfig.config : nestedConfig;
// Determine if this should be array or row
const mode = inferMode({
fieldName,
limit: config.limit,
orderBy: config.orderBy,
through: config.through,
mode: config.mode,
});
const targetTable = this.getSourceTable(config.from);
// Handle many-to-many through junction table
if (config.through) {
return this.buildManyToManyField({
nestedConfig: config,
parentTable,
targetTable,
mode,
});
}
// Try to find relation path using schema relations
let relationPath: RelationPath | undefined;
let joinCondition: SQL | undefined;
try {
relationPath = findRelationPath({
from: parentTable,
to: targetTable,
relations,
});
// Extract join condition from relation
joinCondition = this.extractJoinCondition({
parentTable,
targetTable,
fieldName,
});
} catch (error) {
// If no relation found, use the WHERE clause as-is
console.warn(
`Warning: ${error instanceof Error ? error.message : String(error)}`,
);
joinCondition = config.where;
}
// Use relation path if found, otherwise use explicit config
if (relationPath?.junction) {
// Many-to-many detected
const junctionFields = getJunctionFields({
junction: relationPath.junction,
from: parentTable,
to: targetTable,
relations,
});
return this.buildManyToManyField({
nestedConfig: config,
parentTable,
targetTable,
mode,
junctionConfig: junctionFields,
});
}
// Direct relationship (one-to-one or one-to-many)
if (mode === "array") {
return this.buildArrayField({
nestedConfig: config,
parentTable,
targetTable,
joinCondition,
});
}
return this.buildRowField({
nestedConfig: config,
parentTable,
targetTable,
joinCondition,
});
}
/**
* Extract join condition from Drizzle relations
*/
private extractJoinCondition({
parentTable,
targetTable,
fieldName,
}: {
parentTable: PgTable;
targetTable: PgTable;
fieldName: string;
}): SQL | undefined {
const parentTableName = getTableName(parentTable);
const targetTableName = getTableName(targetTable);
// Look up relations for parent table
const parentRelations = relations[parentTableName];
if (!parentRelations) {
return undefined;
}
// Find the specific relation by field name
const relation = parentRelations[fieldName];
if (!relation) {
return undefined;
}
// Extract field mappings from relation
const fields = (relation as any).fields || [];
const references = (relation as any).references || [];
if (fields.length === 0 || references.length === 0) {
// Check reverse relation (from target to parent)
const targetRelations = relations[targetTableName];
if (!targetRelations) {
return undefined;
}
// Look for a relation back to the parent
const reverseRelation = Object.values(targetRelations).find((rel) => {
let relTable = (rel as any).referencedTable || (rel as any).table;
if (typeof relTable === "function") {
relTable = relTable();
}
const relTableName = getTableName(relTable);
return relTableName === parentTableName;
});
if (!reverseRelation) {
return undefined;
}
const reverseFields = (reverseRelation as any).fields || [];
const reverseReferences = (reverseRelation as any).references || [];
if (reverseFields.length === 0 || reverseReferences.length === 0)
return undefined;
// Build condition: targetTable.field = parentTable.reference
const targetField = reverseFields[0];
const parentReference = reverseReferences[0];
return sql`${sql.identifier(targetTableName)}.${sql.identifier(targetField.name)} = ${sql.identifier(parentTableName)}.${sql.identifier(parentReference.name)}`;
}
// Build condition: targetTable.reference = parentTable.field
const parentField = fields[0];
const targetReference = references[0];
return sql`${sql.identifier(targetTableName)}.${sql.identifier(targetReference.name)} = ${sql.identifier(parentTableName)}.${sql.identifier(parentField.name)}`;
}
/**
* Build array field SQL (one-to-many)
*/
private buildArrayField({
nestedConfig,
targetTable,
joinCondition,
}: {
nestedConfig: CTEConfig;
parentTable: PgTable;
targetTable: PgTable;
joinCondition?: SQL;
}): SQL {
const targetTableName = getTableName(targetTable);
// Build SELECT fields - start with all columns
const selectFields: SQL[] = [sql`${sql.identifier(targetTableName)}.*`];
// Add nested WITH fields if they exist
if (nestedConfig.with) {
for (const [fieldName, nestedFieldConfig] of Object.entries(
nestedConfig.with,
)) {
const nestedSQL = this.buildNestedField({
fieldName,
nestedConfig: nestedFieldConfig,
parentTable: targetTable,
});
selectFields.push(sql`${nestedSQL} AS ${sql.identifier(fieldName)}`);
}
}
// Build the inner SELECT
let innerSelect = sql`SELECT ${sql.join(selectFields, sql`, `)} FROM ${sql.identifier(targetTableName)}`;
// Add WHERE clause (join condition + additional filters)
if (joinCondition) {
innerSelect = sql`${innerSelect} WHERE ${joinCondition}`;
// Add additional filters
if (nestedConfig.where) {
innerSelect = sql`${innerSelect} AND ${nestedConfig.where}`;
}
} else if (nestedConfig.where) {
innerSelect = sql`${innerSelect} WHERE ${nestedConfig.where}`;
}
// Add ORDER BY
if (nestedConfig.orderBy && nestedConfig.orderBy.length > 0) {
innerSelect = sql`${innerSelect} ORDER BY ${sql.join(nestedConfig.orderBy, sql`, `)}`;
}
// Add LIMIT
if (nestedConfig.limit !== undefined) {
innerSelect = sql`${innerSelect} LIMIT ${sql.raw(String(nestedConfig.limit))}`;
}
// Wrap in json_agg with subquery alias
const subquery = sql`SELECT json_agg(row_to_json(sub)) FROM (${innerSelect}) sub`;
// Wrap with COALESCE
return sql`COALESCE((${subquery}), '[]'::json)`;
}
/**
* Build row field SQL (one-to-one)
*/
private buildRowField({
nestedConfig,
targetTable,
joinCondition,
}: {
nestedConfig: CTEConfig;
parentTable: PgTable;
targetTable: PgTable;
joinCondition?: SQL;
}): SQL {
const targetTableName = getTableName(targetTable);
// Build SELECT fields - start with all columns
const selectFields: SQL[] = [sql`${sql.identifier(targetTableName)}.*`];
// Add nested WITH fields if they exist
if (nestedConfig.with) {
for (const [fieldName, nestedFieldConfig] of Object.entries(
nestedConfig.with,
)) {
const nestedSQL = this.buildNestedField({
fieldName,
nestedConfig: nestedFieldConfig,
parentTable: targetTable,
});
selectFields.push(sql`${nestedSQL} AS ${sql.identifier(fieldName)}`);
}
}
// Build the inner SELECT
let innerSelect = sql`SELECT ${sql.join(selectFields, sql`, `)} FROM ${sql.identifier(targetTableName)}`;
// Add WHERE clause (join condition + additional filters)
if (joinCondition) {
innerSelect = sql`${innerSelect} WHERE ${joinCondition}`;
// Add additional filters
if (nestedConfig.where) {
innerSelect = sql`${innerSelect} AND ${nestedConfig.where}`;
}
} else if (nestedConfig.where) {
innerSelect = sql`${innerSelect} WHERE ${nestedConfig.where}`;
}
// Wrap in row_to_json with subquery
const subquery = sql`SELECT row_to_json(sub) FROM (${innerSelect}) sub`;
return sql`(${subquery})`;
}
/**
* Build many-to-many field SQL (through junction table)
*/
private buildManyToManyField({
nestedConfig,
targetTable,
junctionConfig,
}: {
nestedConfig: CTEConfig;
parentTable: PgTable;
targetTable: PgTable;
mode: CTEMode;
junctionConfig?: {
table: PgTable;
fromField: string;
toField: string;
};
}): SQL {
const through = nestedConfig.through!;
const junctionTable = junctionConfig?.table || through.table;
const junctionTableName = getTableName(junctionTable);
const targetTableName = getTableName(targetTable);
// Note: We don't use aliases in subqueries to avoid table reference issues in WHERE clauses
let subquery = sql`SELECT json_agg(row_to_json(${sql.identifier(targetTableName)})`;
// Add ORDER BY
if (nestedConfig.orderBy && nestedConfig.orderBy.length > 0) {
subquery = sql`${subquery} ORDER BY ${sql.join(nestedConfig.orderBy, sql`, `)}`;
}
subquery = sql`${subquery}) FROM ${sql.identifier(junctionTableName)}`;
subquery = sql`${subquery} INNER JOIN ${sql.identifier(targetTableName)} ON ${through.to}`;
subquery = sql`${subquery} WHERE ${through.from}`;
// Add additional WHERE filters
if (nestedConfig.where) {
subquery = sql`${subquery} AND ${nestedConfig.where}`;
}
// Add LIMIT
if (nestedConfig.limit !== undefined) {
subquery = sql`${subquery} LIMIT ${sql.raw(String(nestedConfig.limit))}`;
}
// Wrap with COALESCE
return sql`COALESCE((${subquery}), '[]'::json)`;
}
/**
* Get the source table (unwrap if it's a CTE)
*/
private getSourceTable(from?: PgTable | CTEBuilder): PgTable {
const source = from || this.config.from;
if (source instanceof CTEBuilder) {
return this.getSourceTable(source.config.from);
}
return source;
}
/**
* Collect all CTE dependencies in correct order
*/
collectDependencies(): CTEBuilder[] {
const deps = new Set<CTEBuilder>();
const visited = new Set<CTEBuilder>();
const visit = (cte: CTEBuilder) => {
if (visited.has(cte)) return;
visited.add(cte);
for (const dep of cte.dependencies) {
visit(dep);
}
deps.add(cte);
};
visit(this);
return Array.from(deps);
}
/**
* Execute the CTE query and return data with count
*/
async execute({
db,
}: CTEExecuteOptions): Promise<{ data: any[]; count: number }> {
const allCTEs = this.collectDependencies();
const cteDefinitions = allCTEs.map((cte) => cte.toSQL());
const query = sql`WITH ${sql.join(cteDefinitions, sql`, `)} SELECT * FROM ${sql.identifier(this.name)}`;
// Execute and extract rows and count - let errors propagate
const result = await db.execute(query);
const data = Array.isArray(result) ? result : result.rows || [];
const count = result.count ?? data.length;
return { data, count };
}
/**
* Generate a unique name for this CTE
*/
private generateName(): string {
const table = this.getSourceTable();
const tableName = getTableName(table);
return `${tableName}_cte`;
}
}
/**
* Main CTE builder function
* Usage:
* ```typescript
* const usersCTE = cte({
* from: userTable,
* with: {
* organizations: cte({ from: organizations, ... })
* }
* });
* ```
*/
export function cte(config: CTEConfig): CTEBuilder {
return new CTEBuilder(config);
}

View File

@@ -0,0 +1,217 @@
import type { Many, One, Relations } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
export interface RelationPath {
type: "row" | "array";
path: PgTable[];
junction?: PgTable;
}
export interface JunctionConfig {
table: PgTable;
fromField: string;
toField: string;
}
/**
* Find the relationship path between two tables using Drizzle relations
* Supports:
* - Direct one() relationships → row
* - Direct many() relationships → array
* - Many-to-many through junction table → array
*/
export function findRelationPath({
from,
to,
relations,
}: {
from: PgTable;
to: PgTable;
relations: Record<string, Relations>;
}): RelationPath {
const fromTableName = (from as any)[Symbol.for("drizzle:Name")] || from;
const toTableName = (to as any)[Symbol.for("drizzle:Name")] || to;
// 1. Check for direct relationship
const fromRelations = relations[fromTableName];
if (fromRelations) {
const directRel = Object.entries(fromRelations).find(([_, rel]) => {
let relTable = (rel as any).referencedTable || (rel as any).table;
// If it's a function, call it to get the actual table
if (typeof relTable === "function") {
relTable = relTable();
}
const relTableName = relTable?.[Symbol.for("drizzle:Name")] || relTable;
return relTableName === toTableName;
});
if (directRel) {
const [_, rel] = directRel;
// Check if it's a one() or many() relationship
const isOne = (rel as any).isOne !== undefined;
const isMany = (rel as any).isMany !== undefined;
if (isOne) {
return { type: "row", path: [from, to] };
}
if (isMany) {
return { type: "array", path: [from, to] };
}
}
}
// 2. Check for many-to-many (through junction table)
const junctionPaths = findJunctionPaths({ from, to, relations });
if (junctionPaths.length === 1) {
const path = junctionPaths[0];
return {
type: "array",
path: path.path,
junction: path.junction,
};
}
if (junctionPaths.length > 1) {
const pathStrings = junctionPaths
.map((p) => p.path.map((t) => getTableName(t)).join(" → "))
.join(", ");
throw new Error(
`Ambiguous relationship between ${getTableName(from)} and ${getTableName(to)}. Found multiple paths: [${pathStrings}]. Please specify 'through' explicitly.`,
);
}
// 3. No path found
throw new Error(
`No relationship found between ${getTableName(from)} and ${getTableName(to)}. Please define the relationship in Drizzle relations or specify 'through' explicitly.`,
);
}
/**
* Find many-to-many paths: from -> junction (many from 'from', one from junction) -> to (one from junction)
*/
function findJunctionPaths({
from,
to,
relations,
}: {
from: PgTable;
to: PgTable;
relations: Record<string, Relations>;
}): Array<{ path: [PgTable, PgTable, PgTable]; junction: PgTable }> {
const paths: Array<{
path: [PgTable, PgTable, PgTable];
junction: PgTable;
}> = [];
const fromTableName = getTableName(from);
const toTableName = getTableName(to);
// Get all many() relationships from 'from' table
const fromRelations = relations[fromTableName];
if (!fromRelations) return paths;
for (const [_, rel] of Object.entries(fromRelations)) {
const isMany = (rel as any).isMany !== undefined;
if (!isMany) continue;
const junctionTable = (rel as any).referencedTable || (rel as any).table;
const junctionTableName = getTableName(junctionTable);
// Check if junction table has a one() relationship to 'to' table
const junctionRelations = relations[junctionTableName];
if (!junctionRelations) continue;
// Look for a one() relationship from junction to 'to'
const toRel = Object.entries(junctionRelations).find(([_, jRel]) => {
const isOne = (jRel as any).isOne !== undefined;
if (!isOne) return false;
const targetTable = (jRel as any).referencedTable || (jRel as any).table;
const targetTableName = getTableName(targetTable);
return targetTableName === toTableName;
});
if (toRel) {
// Also verify there's a one() relationship from junction back to 'from'
const reverseRel = Object.entries(junctionRelations).find(([_, jRel]) => {
const isOne = (jRel as any).isOne !== undefined;
if (!isOne) return false;
const targetTable =
(jRel as any).referencedTable || (jRel as any).table;
const targetTableName = getTableName(targetTable);
return targetTableName === fromTableName;
});
if (reverseRel) {
paths.push({
path: [from, junctionTable, to],
junction: junctionTable,
});
}
}
}
return paths;
}
/**
* Extract table name from Drizzle table object
*/
function getTableName(table: PgTable): string {
return (table as any)[Symbol.for("drizzle:Name")] || String(table);
}
/**
* Extract join field information from a junction table relationship
*/
export function getJunctionFields({
junction,
from,
to,
relations,
}: {
junction: PgTable;
from: PgTable;
to: PgTable;
relations: Record<string, Relations>;
}): JunctionConfig {
const junctionTableName = getTableName(junction);
const fromTableName = getTableName(from);
const toTableName = getTableName(to);
const junctionRelations = relations[junctionTableName];
if (!junctionRelations) {
throw new Error(
`No relations defined for junction table ${junctionTableName}`,
);
}
let fromField = "";
let toField = "";
for (const [_, rel] of Object.entries(junctionRelations)) {
const isOne = (rel as any).isOne !== undefined;
if (!isOne) continue;
const targetTable = (rel as any).referencedTable || (rel as any).table;
const targetTableName = getTableName(targetTable);
const fields = (rel as any).fields || [];
if (targetTableName === fromTableName && fields.length > 0) {
fromField = fields[0].name;
}
if (targetTableName === toTableName && fields.length > 0) {
toField = fields[0].name;
}
}
if (!fromField || !toField) {
throw new Error(
`Could not determine junction fields for ${junctionTableName} between ${fromTableName} and ${toTableName}`,
);
}
return { table: junction, fromField, toField };
}

View File

@@ -0,0 +1,148 @@
import { type SQL, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
export interface ArrayAggregationConfig {
table: PgTable;
alias?: string;
filter?: SQL;
orderBy?: SQL[];
limit?: number;
distinct?: boolean;
}
export interface RowSubqueryConfig {
table: PgTable;
alias?: string;
where?: SQL;
}
export interface JunctionJoinConfig {
junctionTable: PgTable;
fromField: string;
toField: string;
fromTable: PgTable;
toTable: PgTable;
fromId: SQL;
}
/**
* Generate json_agg SQL for array aggregation with COALESCE to empty array
* Example: COALESCE(json_agg(row_to_json(e) ORDER BY e.id) FILTER (WHERE e.id IS NOT NULL), '[]'::json)
*/
export function generateArrayAggSQL({
table,
alias,
filter,
orderBy,
limit,
distinct = false,
}: ArrayAggregationConfig): SQL {
const tableAlias = alias || getTableAlias(table);
const distinctKeyword = distinct ? sql`DISTINCT ` : sql``;
let aggExpression = sql`json_agg(${distinctKeyword}row_to_json(${sql.identifier(tableAlias)})`;
// Add ORDER BY if provided
if (orderBy && orderBy.length > 0) {
aggExpression = sql`${aggExpression} ORDER BY ${sql.join(orderBy, sql`, `)}`;
}
aggExpression = sql`${aggExpression})`;
// Add FILTER clause if provided
if (filter) {
aggExpression = sql`${aggExpression} FILTER (WHERE ${filter})`;
}
// Wrap with COALESCE to handle NULL → empty array
return sql`COALESCE(${aggExpression}, '[]'::json)`;
}
/**
* Generate row_to_json SQL for single row subquery
* Example: (SELECT row_to_json(p) FROM products p WHERE p.id = ${parentId})
*/
export function generateRowSubquerySQL({
table,
alias,
where,
}: RowSubqueryConfig): SQL {
const tableAlias = alias || getTableAlias(table);
const tableName = getTableName(table);
let query = sql`(SELECT row_to_json(${sql.identifier(tableAlias)}) FROM ${sql.identifier(tableName)} ${sql.identifier(tableAlias)}`;
if (where) {
query = sql`${query} WHERE ${where}`;
}
query = sql`${query})`;
return query;
}
/**
* Generate SQL for many-to-many join through junction table
* Example:
* SELECT json_agg(o)
* FROM member m
* INNER JOIN organizations o ON o.id = m.organization_id
* WHERE m.user_id = ${userId}
*/
export function generateJunctionJoinSQL({
junctionTable,
fromField,
toField,
fromTable,
toTable,
fromId,
}: JunctionJoinConfig): SQL {
const junctionAlias = getTableAlias(junctionTable);
const toAlias = getTableAlias(toTable);
const junctionTableName = getTableName(junctionTable);
const toTableName = getTableName(toTable);
return sql`
FROM ${sql.identifier(junctionTableName)} ${sql.identifier(junctionAlias)}
INNER JOIN ${sql.identifier(toTableName)} ${sql.identifier(toAlias)}
ON ${sql.identifier(toAlias)}.${sql.identifier("id")} = ${sql.identifier(junctionAlias)}.${sql.identifier(toField)}
WHERE ${sql.identifier(junctionAlias)}.${sql.identifier(fromField)} = ${fromId}
`;
}
/**
* Generate SQL for limiting results per parent using window functions
* Example: row_number() OVER (PARTITION BY user_id ORDER BY created_at)
*/
export function generateRowNumberSQL({
partitionBy,
orderBy,
}: {
partitionBy: SQL;
orderBy?: SQL[];
}): SQL {
let windowSQL = sql`row_number() OVER (PARTITION BY ${partitionBy}`;
if (orderBy && orderBy.length > 0) {
windowSQL = sql`${windowSQL} ORDER BY ${sql.join(orderBy, sql`, `)}`;
}
windowSQL = sql`${windowSQL})`;
return windowSQL;
}
/**
* Extract table name from Drizzle table object
*/
function getTableName(table: PgTable): string {
return (table as any)[Symbol.for("drizzle:Name")] || String(table);
}
/**
* Generate a short alias for a table (first letter of table name)
*/
function getTableAlias(table: PgTable): string {
const tableName = getTableName(table);
return tableName.charAt(0);
}

View File

@@ -0,0 +1,313 @@
import { type SQL, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import type { CTEConfig } from "../buildCte.js";
import {
buildRelationGraph,
getTableName,
parseJoinCondition,
type RelationNode,
} from "./relationGraph.js";
/**
* Build the optimized query using JOIN + GROUP BY strategy
* This replaces correlated subqueries with flat JOINs for better performance
*/
export function buildJoinGroupByQuery({
config,
relations,
extractJoinCondition,
}: {
config: CTEConfig;
relations: Record<string, any>;
extractJoinCondition: (params: {
parentTable: PgTable;
targetTable: PgTable;
fieldName: string;
}) => SQL | undefined;
}): SQL {
// Build relation graph
const rootTable = getSourceTable(config.from);
const graph = buildRelationGraph({
config,
relations,
extractJoinCondition,
});
// Step 1: Build aggregation CTEs for array (one-to-many) relations
const aggregationCTEs = buildAggregationCTEs({ graph, rootTable });
// Step 2: Build main query with row (one-to-one) relations as direct JOINs
const mainQuery = buildMainQuery({ graph, config });
// Step 3: Assemble final query with CTEs if needed
if (aggregationCTEs.length > 0) {
const cteDefinitions = aggregationCTEs.map((c) => c.definition);
return sql`WITH ${sql.join(cteDefinitions, sql`, `)} ${mainQuery}`;
}
return mainQuery;
}
/**
* Build the main SELECT query with direct JOINs for row relations
*/
function buildMainQuery({
graph,
config,
}: {
graph: RelationNode;
config: CTEConfig;
}): SQL {
const selectFields: SQL[] = [];
const joins: SQL[] = [];
const groupByFields: SQL[] = [];
// Add root table columns
selectFields.push(sql`${sql.identifier(graph.tableName)}.*`);
groupByFields.push(sql`${sql.identifier(graph.tableName)}.id`);
// Recursively add JOINs and SELECT fields for nested relations
addNestedJoins({
node: graph,
parentTableName: graph.tableName,
path: [],
selectFields,
joins,
groupByFields,
});
// Build the base query
let query = sql`SELECT ${sql.join(selectFields, sql`, `)} FROM ${sql.identifier(graph.tableName)}`;
// Add JOINs
if (joins.length > 0) {
query = sql`${query} ${sql.join(joins, sql` `)}`;
}
// Add WHERE clause
if (config.where) {
query = sql`${query} WHERE ${config.where}`;
}
// Add GROUP BY (needed when we have array aggregations)
if (groupByFields.length > 1) {
query = sql`${query} GROUP BY ${sql.join(groupByFields, sql`, `)}`;
}
// Add ORDER BY
if (config.orderBy && config.orderBy.length > 0) {
query = sql`${query} ORDER BY ${sql.join(config.orderBy, sql`, `)}`;
}
// Add LIMIT
if (config.limit !== undefined) {
query = sql`${query} LIMIT ${sql.raw(String(config.limit))}`;
}
// Add OFFSET
if (config.offset !== undefined) {
query = sql`${query} OFFSET ${sql.raw(String(config.offset))}`;
}
return query;
}
/**
* Recursively add JOINs for nested relations
*/
function addNestedJoins({
node,
parentTableName,
path,
selectFields,
joins,
groupByFields,
}: {
node: RelationNode;
parentTableName: string;
path: string[];
selectFields: SQL[];
joins: SQL[];
groupByFields: SQL[];
}) {
for (const [fieldName, childNode] of Object.entries(node.nestedFields)) {
const childAlias = [...path, fieldName].join("_");
if (childNode.mode === "row") {
// For row relations (one-to-one), add direct JOIN
joins.push(sql`
LEFT JOIN ${sql.identifier(childNode.tableName)} AS ${sql.identifier(childAlias)}
ON ${sql.identifier(childAlias)}.${sql.identifier(childNode.childKey || "id")} = ${sql.identifier(parentTableName)}.${sql.identifier(childNode.parentKey || "id")}
`);
// Add to GROUP BY
groupByFields.push(
sql`${sql.identifier(childAlias)}.${sql.identifier(childNode.childKey || "id")}`,
);
// Add to SELECT as row_to_json
selectFields.push(
sql`row_to_json(${sql.identifier(childAlias)}) AS ${sql.identifier(fieldName)}`,
);
// Recurse for nested row relations
addNestedJoins({
node: childNode,
parentTableName: childAlias,
path: [...path, fieldName],
selectFields,
joins,
groupByFields,
});
} else {
// For array relations, we'll use aggregation CTEs
// Add the aggregated field from the CTE
const aggCTEName = [...path, fieldName, "agg"].join("_");
selectFields.push(
sql`COALESCE(${sql.identifier(aggCTEName)}.${sql.identifier(fieldName)}, '[]'::json) AS ${sql.identifier(fieldName)}`,
);
// The CTE will be built separately and joined
joins.push(sql`
LEFT JOIN ${sql.identifier(aggCTEName)}
ON ${sql.identifier(aggCTEName)}.group_key = ${sql.identifier(parentTableName)}.id
`);
}
}
}
/**
* Build aggregation CTEs for array (one-to-many) relations
*/
function buildAggregationCTEs({
graph,
rootTable,
}: {
graph: RelationNode;
rootTable: PgTable;
}): Array<{ name: string; definition: SQL }> {
const ctes: Array<{ name: string; definition: SQL }> = [];
function processNode({
node,
parentTableName,
path,
}: {
node: RelationNode;
parentTableName: string;
path: string[];
}) {
for (const [fieldName, childNode] of Object.entries(node.nestedFields)) {
if (childNode.mode === "array") {
const cteName = [...path, fieldName, "agg"].join("_");
// Build SELECT fields for the aggregation
const selectFields: SQL[] = [
sql`${sql.identifier(childNode.tableName)}.*`,
];
// Add nested row relations as inline row_to_json
for (const [nestedFieldName, nestedNode] of Object.entries(
childNode.nestedFields,
)) {
if (nestedNode.mode === "row") {
selectFields.push(
sql`row_to_json(${sql.identifier(nestedFieldName)}) AS ${sql.identifier(nestedFieldName)}`,
);
}
}
// Build JOINs for nested row relations
const nestedJoins: SQL[] = [];
for (const [nestedFieldName, nestedNode] of Object.entries(
childNode.nestedFields,
)) {
if (nestedNode.mode === "row") {
nestedJoins.push(sql`
LEFT JOIN ${sql.identifier(nestedNode.tableName)} AS ${sql.identifier(nestedFieldName)}
ON ${sql.identifier(nestedFieldName)}.${sql.identifier(nestedNode.childKey || "id")} = ${sql.identifier(childNode.tableName)}.${sql.identifier(nestedNode.parentKey || "id")}
`);
}
}
// Build the aggregation query
let innerQuery = sql`
SELECT
${sql.identifier(childNode.tableName)}.${sql.identifier(childNode.parentKey || "id")} AS group_key,
COALESCE(
json_agg(
row_to_json(agg_sub)
ORDER BY ${sql.identifier(childNode.tableName)}.created_at DESC
),
'[]'::json
) AS ${sql.identifier(fieldName)}
FROM ${sql.identifier(childNode.tableName)}
`;
// Add nested JOINs
if (nestedJoins.length > 0) {
innerQuery = sql`${innerQuery} ${sql.join(nestedJoins, sql` `)}`;
}
// Add WHERE to filter by parent IDs
innerQuery = sql`${innerQuery}
WHERE ${sql.identifier(childNode.tableName)}.${sql.identifier(childNode.parentKey || "id")} IN (
SELECT id FROM ${sql.identifier(parentTableName)}
)
`;
// Add additional filters from config
if (childNode.config.where) {
innerQuery = sql`${innerQuery} AND ${childNode.config.where}`;
}
// Add GROUP BY
innerQuery = sql`${innerQuery}
GROUP BY ${sql.identifier(childNode.tableName)}.${sql.identifier(childNode.parentKey || "id")}
`;
// Wrap in CTE
const cteDefinition = sql`${sql.identifier(cteName)} AS (
${innerQuery}
)`;
ctes.push({
name: cteName,
definition: cteDefinition,
});
// Recurse for nested array relations
processNode({
node: childNode,
parentTableName: childNode.tableName,
path: [...path, fieldName],
});
} else {
// For row relations, continue recursion
processNode({
node: childNode,
parentTableName: childNode.tableName,
path: [...path, fieldName],
});
}
}
}
processNode({
node: graph,
parentTableName: graph.tableName,
path: [],
});
return ctes;
}
/**
* Get source table from config (unwrap CTEBuilder if needed)
*/
function getSourceTable(from: any): PgTable {
if (from?.config?.from) {
return getSourceTable(from.config.from);
}
return from;
}

View File

@@ -0,0 +1,159 @@
import { type SQL, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import type { CTEConfig } from "../buildCte.js";
import { CTEBuilder } from "../buildCte.js";
import { type CTEMode, inferMode } from "../typeDetection.js";
/**
* Get table name from Drizzle table object
*/
export function getTableName(table: PgTable): string {
return (table as any)[Symbol.for("drizzle:Name")] || String(table);
}
/**
* Represents a node in the relation graph
*/
export interface RelationNode {
table: PgTable;
tableName: string;
parentKey?: string; // Foreign key column on child table
childKey?: string; // Primary key column on parent table
mode: CTEMode;
nestedFields: Record<string, RelationNode>;
config: CTEConfig; // Store original config for filters/ordering
}
/**
* Parse join condition SQL to extract column names
*/
export function parseJoinCondition({
joinCondition,
parentTableName,
targetTableName,
}: {
joinCondition?: SQL;
parentTableName: string;
targetTableName: string;
}): { parentKey: string; childKey: string } {
if (!joinCondition) {
// Default fallback - assume standard foreign key pattern
return {
parentKey: "id",
childKey: `internal_${parentTableName.replace(/s$/, "")}_id`,
};
}
// Extract column names from SQL
// Expected format: "target_table.child_key = parent_table.parent_key"
try {
const sqlString = joinCondition.getSQL().sql;
// Try to parse pattern: table.column = table.column
const match = sqlString.match(/(\w+)\.(\w+)\s*=\s*(\w+)\.(\w+)/);
if (match) {
const [_, table1, col1, _table2, col2] = match;
// Determine which is parent and which is child
if (table1 === targetTableName) {
return { parentKey: col2, childKey: col1 };
}
return { parentKey: col1, childKey: col2 };
}
} catch (error) {
// If parsing fails, use fallback
console.warn("Failed to parse join condition:", error);
}
// Fallback
return {
parentKey: "id",
childKey: `internal_${parentTableName.replace(/s$/, "")}_id`,
};
}
/**
* Build relation graph from CTE config
* This maps out the entire relationship tree
*/
export function buildRelationGraph({
config,
parentTable,
relations,
extractJoinCondition,
}: {
config: CTEConfig;
parentTable?: PgTable;
relations: Record<string, any>;
extractJoinCondition: (params: {
parentTable: PgTable;
targetTable: PgTable;
fieldName: string;
}) => SQL | undefined;
}): RelationNode {
const table = getSourceTable(config.from);
const tableName = getTableName(table);
const node: RelationNode = {
table,
tableName,
mode: "row",
nestedFields: {},
config,
};
if (!config.with) return node;
for (const [fieldName, nestedConfig] of Object.entries(config.with)) {
const nested =
nestedConfig instanceof CTEBuilder ? nestedConfig.config : nestedConfig;
const targetTable = getSourceTable(nested.from);
const targetTableName = getTableName(targetTable);
// Extract join keys from relations
const joinCondition = extractJoinCondition({
parentTable: table,
targetTable,
fieldName,
});
const { parentKey, childKey } = parseJoinCondition({
joinCondition,
parentTableName: tableName,
targetTableName,
});
// Recursively build nested nodes
const nestedNode = buildRelationGraph({
config: nested,
parentTable: table,
relations,
extractJoinCondition,
});
nestedNode.parentKey = parentKey;
nestedNode.childKey = childKey;
nestedNode.mode = inferMode({
fieldName,
limit: nested.limit,
orderBy: nested.orderBy,
through: nested.through,
mode: nested.mode,
});
node.nestedFields[fieldName] = nestedNode;
}
return node;
}
/**
* Get source table from config (unwrap CTEBuilder if needed)
*/
function getSourceTable(from: PgTable | CTEBuilder): PgTable {
if (from instanceof CTEBuilder) {
return getSourceTable(from.config.from);
}
return from;
}

View File

@@ -0,0 +1,79 @@
import type { CTEConfig } from "../buildCte.js";
import { CTEBuilder } from "../buildCte.js";
import { inferMode } from "../typeDetection.js";
export type QueryStrategy = "correlated" | "join_group_by";
/**
* Calculate the maximum nesting depth of a CTE configuration
*/
export function calculateNestingDepth({
config,
current = 0,
}: {
config: CTEConfig;
current?: number;
}): number {
if (!config.with) return current;
let maxDepth = current;
for (const nested of Object.values(config.with)) {
const nestedConfig = nested instanceof CTEBuilder ? nested.config : nested;
const depth = calculateNestingDepth({
config: nestedConfig,
current: current + 1,
});
maxDepth = Math.max(maxDepth, depth);
}
return maxDepth;
}
/**
* Detect if any nested relations will return arrays (one-to-many)
*/
export function detectArrayRelations({
config,
}: {
config: CTEConfig;
}): boolean {
if (!config.with) return false;
for (const [fieldName, nested] of Object.entries(config.with)) {
const nestedConfig = nested instanceof CTEBuilder ? nested.config : nested;
const mode = inferMode({
fieldName,
limit: nestedConfig.limit,
orderBy: nestedConfig.orderBy,
through: nestedConfig.through,
mode: nestedConfig.mode,
});
if (mode === "array") return true;
if (detectArrayRelations({ config: nestedConfig })) return true;
}
return false;
}
/**
* Determine which query strategy to use based on config complexity
*/
export function shouldUseJoinStrategy({
config,
}: {
config: CTEConfig;
}): boolean {
// 1. Explicit strategy override
if ((config as any).strategy === "join_group_by") return true;
if ((config as any).strategy === "correlated") return false;
// 2. Auto detection - TEMPORARILY DISABLED while we fix join condition parsing
// const depth = calculateNestingDepth({ config });
// const hasArrayRelations = detectArrayRelations({ config });
// const isLargeResultSet = config.limit === undefined || config.limit > 50;
// Use JOIN strategy when:
// - Deep nesting (2+ levels) with array relations and large result sets
// - Or very deep nesting (3+ levels) regardless of result set size
// return (depth >= 2 && hasArrayRelations && isLargeResultSet) || depth >= 3;
return false; // Temporarily disabled
}

View File

@@ -0,0 +1,49 @@
import type { SQL } from "drizzle-orm";
export type CTEMode = "array" | "row";
export interface ModeDetectionConfig {
fieldName?: string;
limit?: number;
orderBy?: SQL[];
through?: unknown;
mode?: CTEMode;
}
/**
* Infer whether a CTE should return an array or single row based on config and heuristics
*/
export function inferMode(config: ModeDetectionConfig): CTEMode {
// 1. Explicit mode always wins
if (config.mode) {
return config.mode;
}
// 2. Has `through`? → array (many-to-many relationship)
if (config.through) {
return "array";
}
// 3. Has limit > 1? → array
if (config.limit !== undefined && config.limit !== 1) {
return "array";
}
// 4. Has orderBy? → probably array (ordering implies multiple results)
if (config.orderBy && config.orderBy.length > 0) {
return "array";
}
// 5. Plural field name? → array (entities, organizations, products)
// Exclude words ending in 'ss' (address, process, etc.)
if (
config.fieldName &&
config.fieldName.endsWith("s") &&
!config.fieldName.endsWith("ss")
) {
return "array";
}
// 6. Default: row (safer default for 1:1 relationships)
return "row";
}

View File

@@ -81,6 +81,8 @@ const createDevLogStream = () => {
"context",
"req",
"data",
"body",
"query",
];
const additionalFields = Object.keys(log)
.filter((key) => !excludeFields.includes(key))
@@ -90,7 +92,7 @@ const createDevLogStream = () => {
}, {} as any);
if (Object.keys(additionalFields).length > 0) {
message += " " + JSON.stringify(additionalFields, null, 2);
message += ` ${JSON.stringify(additionalFields, null, 2)}`;
}
// Format the final log line
@@ -98,7 +100,7 @@ const createDevLogStream = () => {
process.stdout.write(formattedLog);
callback();
} catch (error) {
} catch (_error) {
// Fallback for malformed JSON
process.stdout.write(chunk);
callback();

View File

@@ -1,39 +1,34 @@
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
AppEnv,
type AppEnv,
BillingType,
CusProductStatus,
Customer,
FullCusProduct,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
type Customer,
type FullCusProduct,
type FullCustomerEntitlement,
type FullCustomerPrice,
type Organization,
} from "@autumn/shared";
import Stripe from "stripe";
import { createStripeCli } from "../../utils.js";
import { getStripeSubs } from "../../stripeSubUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.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";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { getFeatureName } from "@/internal/features/utils/displayUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
getFullStripeInvoice,
invoiceToSubId,
} from "../../stripeInvoiceUtils.js";
import { handleUsagePrices } from "./handleUsagePrices.js";
import { handleContUsePrices } from "./handleContUsePrices.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { handlePrepaidPrices } from "./handlePrepaidPrices.js";
import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.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";
const handleInArrearProrated = async ({
db,
@@ -82,19 +77,19 @@ const handleInArrearProrated = async ({
return;
}
let feature = cusEnt.entitlement.feature;
const feature = cusEnt.entitlement.feature;
logger.info(
`Handling invoice.created for in arrear prorated, feature: ${feature.id}`,
);
let deletedEntities = await EntityService.list({
const deletedEntities = await EntityService.list({
db,
internalCustomerId: customer.internal_id!,
inFeatureIds: [feature.internal_id!],
isDeleted: true,
});
if (deletedEntities.length == 0) {
if (deletedEntities.length === 0) {
logger.info("No deleted entities found");
return;
}
@@ -112,7 +107,7 @@ const handleInArrearProrated = async ({
for (const linkedCusEnt of cusEnts) {
// isLinked
let isLinked = linkedCusEnt.entitlement.entity_feature_id == feature.id;
const isLinked = linkedCusEnt.entitlement.entity_feature_id === feature.id;
if (!isLinked) {
continue;
@@ -123,14 +118,14 @@ const handleInArrearProrated = async ({
);
// Delete cus ent ids
let newEntities = structuredClone(linkedCusEnt.entities!);
const newEntities = structuredClone(linkedCusEnt.entities!);
for (const entityId in newEntities) {
if (deletedEntities.some((e) => e.id == entityId)) {
if (deletedEntities.some((e) => e.id === entityId)) {
delete newEntities[entityId];
}
}
let updated = await CusEntService.update({
const updated = await CusEntService.update({
db,
id: linkedCusEnt.id,
updates: {
@@ -198,13 +193,13 @@ export const sendUsageAndReset = async ({
for (const cusPrice of cusPrices) {
const price = cusPrice.price;
let billingType = getBillingType(price.config);
const billingType = getBillingType(price.config);
if (isFixedPrice({ price })) {
continue;
}
let relatedCusEnt = getRelatedCusEnt({
const relatedCusEnt = getRelatedCusEnt({
cusPrice,
cusEnts,
});
@@ -227,19 +222,19 @@ export const sendUsageAndReset = async ({
const subId = invoiceToSubId({ invoice });
if (!usageBasedSub || usageBasedSub.id != subId) {
if (!usageBasedSub || usageBasedSub.id !== subId) {
continue;
}
// If trial just ended, skip
const { start, end } = subToPeriodStartEnd({ sub: usageBasedSub });
if (usageBasedSub.trial_end == start) {
if (usageBasedSub.trial_end === start) {
logger.info(`Trial just ended, skipping usage invoice.created`);
continue;
}
if (billingType == BillingType.UsageInArrear) {
if (billingType === BillingType.UsageInArrear) {
await handleUsagePrices({
db,
org,
@@ -254,7 +249,7 @@ export const sendUsageAndReset = async ({
});
}
if (billingType == BillingType.InArrearProrated) {
if (billingType === BillingType.InArrearProrated) {
await handleContUsePrices({
db,
stripeCli,
@@ -266,7 +261,7 @@ export const sendUsageAndReset = async ({
});
}
if (billingType == BillingType.UsageInAdvance) {
if (billingType === BillingType.UsageInAdvance) {
await handlePrepaidPrices({
db,
stripeCli,
@@ -324,11 +319,11 @@ export const handleInvoiceCreated = async ({
return;
}
let internalEntityId = activeProducts.find(
const internalEntityId = activeProducts.find(
(p) => p.internal_entity_id,
)?.internal_entity_id;
let features = await FeatureService.list({
const features = await FeatureService.list({
db,
orgId: org.id,
env,
@@ -372,7 +367,7 @@ export const handleInvoiceCreated = async ({
const stripeSubs = await getStripeSubs({
stripeCli: createStripeCli({ org, env }),
subIds: activeProducts.map((p) => p.subscription_ids || []).flat(),
subIds: activeProducts.flatMap((p) => p.subscription_ids || []),
});
for (const activeProduct of activeProducts) {

View File

@@ -1,5 +1,6 @@
import {
ApiVersion,
AppEnv,
type Customer,
EntInterval,
type FullCusProduct,
@@ -20,6 +21,7 @@ import { submitUsageToStripe } from "../../stripeMeterUtils.js";
import { getInvoiceItemForUsage } from "../../stripePriceUtils.js";
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js";
import { getAllFullCustomers } from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js";
export const handleUsagePrices = async ({
db,
@@ -134,6 +136,14 @@ export const handleUsagePrices = async ({
return;
}
const allFullCustomers = await getAllFullCustomers({
db,
orgId: org.id,
env: AppEnv.Live,
});
console.log(`All full customers: ${allFullCustomers.length}`);
const ent = relatedCusEnt.entitlement;
const resetBalancesUpdate = getResetBalancesUpdate({

View File

@@ -1,4 +1,10 @@
import { type AppEnv, InvoiceStatus } from "@autumn/shared";
import {
type AppEnv,
type Invoice,
InvoiceStatus,
stripeToAtmnAmount,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
@@ -32,7 +38,12 @@ const handleInvoiceCheckoutVoided = async ({
id: metadataId,
});
const { anchorToUnix, config, ...rest } = metadata?.data;
const {
anchorToUnix: _anchorToUnix,
config: _config,
...rest
} = metadata?.data || {};
const attachParams = rest as AttachParams;
if (!attachParams) return;
@@ -81,47 +92,46 @@ export const handleInvoiceUpdated = async ({
req: any;
}) => {
const invoiceObject = event.data.object as Stripe.Invoice;
const currentInvoice = await InvoiceService.getByStripeId({
db: req.db,
stripeId: invoiceObject.id!,
});
// const invoice = await getFullStripeInvoice({
// stripeCli,
// stripeId: invoiceObject.id!,
// });
const prevAttributes = event.data.previous_attributes as any;
const invoiceVoided =
prevAttributes?.status !== "void" && invoiceObject.status === "void";
const { logger } = req;
const updates: Partial<Invoice> = {};
if (invoiceVoided) {
logger.info(`Invoice has been voided!`);
await handleInvoiceCheckoutVoided({
db: req.db,
stripeCli,
invoiceObject,
logger,
});
await InvoiceService.updateByStripeId({
db: req.db,
stripeId: invoiceObject.id!,
updates: {
status: InvoiceStatus.Void,
},
});
if (invoiceObject.status === "void") {
updates.status = InvoiceStatus.Void;
}
const invoiceOpen =
prevAttributes?.status !== "open" && invoiceObject.status === "open";
if (invoiceObject.status === "open") {
updates.status = InvoiceStatus.Open;
}
if (invoiceOpen) {
// logger.info(`Invoice has been opened!`);
if (currentInvoice) {
const newAtmnTotal = stripeToAtmnAmount({
amount: invoiceObject.total,
currency: invoiceObject.currency,
});
const totalEquals = new Decimal(newAtmnTotal).eq(currentInvoice.total);
if (!totalEquals) {
updates.total = newAtmnTotal;
}
}
if (Object.keys(updates).length > 0 && invoiceObject.id) {
await InvoiceService.updateByStripeId({
db: req.db,
stripeId: invoiceObject.id!,
updates: {
status: InvoiceStatus.Open,
},
stripeId: invoiceObject.id,
updates,
});
}
};

View File

@@ -0,0 +1,126 @@
import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
const parseCustomerIdFromUrl = ({
url,
}: {
url: string;
}): string | undefined => {
if (!url.startsWith("/v1")) {
return undefined;
}
const cleanUrl = url.split("?")[0].replace(/^\/+|\/+$/g, "");
const segments = cleanUrl.split("/");
const customersIndex = segments.indexOf("customers");
if (customersIndex !== -1 && segments[customersIndex + 1]) {
return segments[customersIndex + 1];
}
return undefined;
};
/**
* Logs response details asynchronously without blocking
*/
const logResponse = async ({
ctx,
c,
method,
skipUrls,
}: {
ctx: any;
c: Context<HonoEnv>;
method: string;
skipUrls: string[];
}) => {
try {
// Skip logging for certain URLs
if (skipUrls.includes(c.req.path)) {
return;
}
// Try to extract response body if it's JSON
let responseBody: any = null;
const contentType = c.res.headers.get("content-type");
if (contentType?.includes("application/json")) {
try {
// Clone response to read body without consuming it
const clonedResponse = c.res.clone();
responseBody = await clonedResponse.json();
} catch (_error) {
// Response might not be JSON or already consumed
}
}
// Log response in non-development environments
if (process.env.NODE_ENV !== "development") {
ctx.logger.info(
`[${c.res.status}] ${method} ${c.req.path} (${ctx.org?.slug})`,
{
statusCode: c.res.status,
res: responseBody,
},
);
}
} catch (error) {
console.error("Failed to log response to logtail");
console.error(error);
}
};
/**
* Analytics middleware for Hono
* Enriches logger context and logs responses
*/
export const analyticsMiddleware = async (c: Context<HonoEnv>, next: Next) => {
const ctx = c.get("ctx");
const skipUrls = ["/v1/customers/all/search"];
// Parse request body for customer_id
let requestBody: any = null;
const method = c.req.method;
if (method === "POST" || method === "PUT" || method === "PATCH") {
try {
// Clone the request to read body without consuming it
requestBody = await c.req.json();
} catch (_error) {
// Body might not be JSON, that's okay
}
}
const customerId =
requestBody?.customer_id || parseCustomerIdFromUrl({ url: c.req.path });
// Enrich logger context
const reqContext = {
org_id: ctx.org?.id,
org_slug: ctx.org?.slug,
env: ctx.env,
authType: ctx.authType,
body: requestBody,
customer_id: customerId,
user_id: ctx.userId || null,
};
// Update logger with enriched context
ctx.logger = ctx.logger.child({
context: {
context: reqContext,
},
});
ctx.logger.info(`${method} ${c.req.path} (${ctx.org?.slug})`);
// Execute the request
await next();
// Log response asynchronously without blocking (runs after response is sent)
Promise.resolve()
.then(() => logResponse({ ctx, c, method, skipUrls }))
.catch((error) => {
console.error("Failed to log response to logtail");
console.error(error);
});
};

View File

@@ -61,7 +61,6 @@ export const apiVersionMiddleware = async (c: Context<HonoEnv>, next: Next) => {
// Store in context - now you can do ctx.apiVersion.gte(ApiVersion.V1_1)
ctx.apiVersion = finalVersion;
console.log(`Autumn Version: ${finalVersion.semver}`);
await next();
};

View File

@@ -30,10 +30,22 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
timestamp,
};
const method = c.req.method;
const path = c.req.path;
let body = null;
if (method === "POST" || method === "PUT" || method === "PATCH") {
try {
body = await c.req.json();
} catch (_error) {}
}
// Create child logger
const childLogger = logger.child({
context: {
req: reqContext,
body,
query: c.req.query(),
},
});
@@ -58,21 +70,7 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
env: AppEnv.Sandbox, // maybe use app_env headers
});
const method = c.req.method;
const path = c.req.path;
let body = null;
if (method === "POST" || method === "PUT" || method === "PATCH") {
try {
body = await c.req.json();
} catch (_error) {}
}
logger.info(`[HONO] ${method} ${path}`, {
context: {
body,
},
});
childLogger.info(`${method} ${path}`);
await next();
};

View File

@@ -1,6 +1,7 @@
import { getRequestListener } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { analyticsMiddleware } from "./honoMiddlewares/analyticsMiddleware.js";
import { apiVersionMiddleware } from "./honoMiddlewares/apiVersionMiddleware.js";
import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js";
import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
@@ -11,6 +12,7 @@ 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 { honoPlatformRouter } from "./internal/platform/honoPlatformRouter.js";
import { honoProductRouter } from "./internal/products/productRouter.js";
import { auth } from "./utils/auth.js";
@@ -82,14 +84,15 @@ export const createHonoApp = () => {
// Step 6: Refresh cache middleware - clears customer cache after successful mutations
app.use("/v1/*", refreshCacheMiddleware);
// Step 7: Query middleware
// 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());
// Additional middleware can be added here as needed
// Routes
app.route("v1/customers", cusRouter);
app.route("v1/products", honoProductRouter);
app.route("v1/platform", honoPlatformRouter);
// Error handler - must be defined after all routes and middleware
app.onError(errorMiddleware);
@@ -132,7 +135,7 @@ export const redirectToHono = () => {
// Check for dynamic routes (e.g., /v1/products/:id)
if (routePath.includes(":")) {
const routeRegex = new RegExp(
"^" + routePath.replace(/:[^/]+/g, "([^/]+)") + "$",
`^${routePath.replace(/:[^/]+/g, "([^/]+)")}$`,
);
return routeRegex.test(path);
}

View File

@@ -3,11 +3,11 @@ import {
type AttachConfig,
AttachScenario,
ErrCode,
InternalError,
SuccessCode,
} 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,6 +29,7 @@ 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,
@@ -79,6 +80,14 @@ export const handleScheduleFunction2 = async ({
const subItems = curSub?.items.data.filter((item) =>
subItemInCusProduct({ cusProduct: curCusProduct, subItem: item }),
);
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}`,
});
}
const expectedEnd = getLatestPeriodEnd({ subItems });
if (schedule) {

View File

@@ -1,44 +1,36 @@
import {
type AttachConfig,
calculateProrationAmount,
Feature,
FeatureOptions,
FullCusProduct,
cusProductToProduct,
type Feature,
type FeatureOptions,
type FullCusProduct,
getFeatureInvoiceDescription,
OnDecrease,
priceToInvoiceAmount,
UsagePriceConfig,
type UsagePriceConfig,
} from "@autumn/shared";
import { Stripe } from "stripe";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { Decimal } from "decimal.js";
import type { Stripe } from "stripe";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import {
shouldBillNow,
shouldProrate,
} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
import { Decimal } from "decimal.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { cusProductToProduct } from "@autumn/shared";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { notNullish } from "@/utils/genUtils.js";
const onDecreaseToStripeProration: Record<OnDecrease, string> = {
[OnDecrease.ProrateImmediately]: "always_invoice",
[OnDecrease.ProrateNextCycle]: "create_prorations",
[OnDecrease.Prorate]: "create_prorations",
[OnDecrease.None]: "none",
[OnDecrease.NoProrations]: "none",
};
export const handleQuantityDowngrade = async ({
req,
attachParams,
attachConfig,
cusProduct,
stripeSub,
oldOptions,
@@ -47,13 +39,14 @@ export const handleQuantityDowngrade = async ({
}: {
req: any;
attachParams: AttachParams;
attachConfig: AttachConfig;
cusProduct: FullCusProduct;
stripeSub: Stripe.Subscription;
oldOptions: FeatureOptions;
newOptions: FeatureOptions;
subItem: Stripe.SubscriptionItem;
}) => {
const { db, logger, org, features } = req;
const { db, logger, org } = req;
const { stripeCli, paymentMethod } = attachParams;
const cusPrice = featureToCusPrice({
@@ -65,10 +58,6 @@ export const handleQuantityDowngrade = async ({
cusPrice.price.proration_config?.on_decrease ||
OnDecrease.ProrateImmediately;
const difference = new Decimal(newOptions.quantity)
.minus(oldOptions.quantity)
.toNumber();
const subItemDifference = new Decimal(newOptions.quantity)
.minus(
notNullish(oldOptions.upcoming_quantity)
@@ -80,18 +69,11 @@ export const handleQuantityDowngrade = async ({
const billingUnits =
(cusPrice.price.config as UsagePriceConfig).billing_units || 1;
// const diffWithBillingUnits = new Decimal(difference)
// .mul((cusPrice.price.config as UsagePriceConfig).billing_units || 1)
// .toNumber();
const newSubItemQuantity = new Decimal(subItem.quantity || 0)
.plus(subItemDifference)
.toNumber();
const stripeProration = onDecreaseToStripeProration[
onDecrease
] as Stripe.SubscriptionItemUpdateParams.ProrationBehavior;
let invoice = null;
const createDowngradeInvoice = async () => {
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
@@ -117,7 +99,7 @@ export const handleQuantityDowngrade = async ({
const product = cusProductToProduct({ cusProduct });
const feature = req.features.find(
(f: Feature) => f.internal_id == newOptions.internal_feature_id,
(f: Feature) => f.internal_id === newOptions.internal_feature_id,
)!;
const invoiceItem = constructStripeInvoiceItem({
product,
@@ -152,9 +134,12 @@ export const handleQuantityDowngrade = async ({
stripeCusId: stripeSub.customer as string,
stripeSubId: stripeSub.id,
paymentMethod: paymentMethod || null,
chargeAutomatically: !attachConfig.invoiceOnly,
logger,
});
invoice = finalInvoice;
try {
const invoiceItems = await getInvoiceItems({
stripeInvoice: finalInvoice,
@@ -199,8 +184,7 @@ export const handleQuantityDowngrade = async ({
});
if (cusEnt) {
const config = cusPrice.price.config as UsagePriceConfig;
let decrementBy = new Decimal(oldOptions.quantity)
const decrementBy = new Decimal(oldOptions.quantity)
.minus(new Decimal(newOptions.quantity))
.mul(billingUnits)
.toNumber();

View File

@@ -1,32 +1,30 @@
import {
type AttachConfig,
calculateProrationAmount,
Feature,
FeatureOptions,
FullCusProduct,
FullCustomerPrice,
getAmountForQuantity,
cusProductToProduct,
type Feature,
type FeatureOptions,
type FullCusProduct,
type FullCustomerPrice,
getFeatureInvoiceDescription,
OnIncrease,
UsagePriceConfig,
type UsagePriceConfig,
} from "@autumn/shared";
import { Stripe } from "stripe";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { Decimal } from "decimal.js";
import type { Stripe } from "stripe";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import {
shouldBillNow,
shouldProrate,
} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { cusProductToProduct } from "@autumn/shared";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { Decimal } from "decimal.js";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
export const handleQuantityUpgrade = async ({
@@ -34,6 +32,7 @@ export const handleQuantityUpgrade = async ({
attachParams,
cusProduct,
stripeSubs,
attachConfig,
oldOptions,
newOptions,
cusPrice,
@@ -43,6 +42,7 @@ export const handleQuantityUpgrade = async ({
req: any;
attachParams: AttachParams;
cusProduct: FullCusProduct;
attachConfig: AttachConfig;
stripeSubs: Stripe.Subscription[];
oldOptions: FeatureOptions;
newOptions: FeatureOptions;
@@ -74,10 +74,7 @@ export const handleQuantityUpgrade = async ({
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
const diffWithBillingUnits = new Decimal(difference)
.mul((cusPrice.price.config as UsagePriceConfig).billing_units || 1)
.toNumber();
let invoice = null;
if (prorate && stripeSub?.status !== "trialing") {
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
@@ -101,20 +98,8 @@ export const handleQuantityUpgrade = async ({
});
}
// const amount = priceToInvoiceAmount({
// price: cusPrice.price,
// quantity: diffWithBillingUnits,
// proration: prorate
// ? {
// start: start * 1000,
// end: end * 1000,
// }
// : undefined,
// now,
// });
const feature = features.find(
(f: Feature) => f.internal_id == newOptions.internal_feature_id,
(f: Feature) => f.internal_id === newOptions.internal_feature_id,
)!;
const product = cusProductToProduct({ cusProduct });
@@ -149,6 +134,7 @@ export const handleQuantityUpgrade = async ({
stripeCusId: stripeSub.customer as string,
stripeSubId: stripeSub.id,
paymentMethod: paymentMethod || null,
chargeAutomatically: !attachConfig.invoiceOnly,
logger,
});
@@ -173,6 +159,7 @@ export const handleQuantityUpgrade = async ({
} catch (error) {
logger.error(`Failed to create invoice from stripe: ${error}`);
}
invoice = finalInvoice;
}
}
@@ -184,7 +171,7 @@ export const handleQuantityUpgrade = async ({
// Update cus ent
let cusEnt = getRelatedCusEnt({
const cusEnt = getRelatedCusEnt({
cusPrice,
cusEnts: cusProduct.customer_entitlements,
});
@@ -200,4 +187,5 @@ export const handleQuantityUpgrade = async ({
amount: incrementBy,
});
}
return { invoice };
};

View File

@@ -1,23 +1,21 @@
import { getUsageBasedSub } from "@/external/stripe/stripeSubUtils.js";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
type AttachConfig,
ErrCode,
Feature,
FeatureOptions,
FullCusProduct,
type FeatureOptions,
type FullCusProduct,
} from "@autumn/shared";
import { Stripe } from "stripe";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import type { Stripe } from "stripe";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js";
import RecaseError from "@/utils/errorUtils.js";
import { handleQuantityDowngrade } from "./handleQuantityDowngrade.js";
import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js";
export const handleUpdateFeatureQuantity = async ({
req,
attachParams,
attachConfig,
cusProduct,
stripeSubs,
oldOptions,
@@ -25,6 +23,7 @@ export const handleUpdateFeatureQuantity = async ({
}: {
req: any;
attachParams: AttachParams;
attachConfig: AttachConfig;
cusProduct: FullCusProduct;
stripeSubs: Stripe.Subscription[];
oldOptions: FeatureOptions;
@@ -62,7 +61,7 @@ export const handleUpdateFeatureQuantity = async ({
});
}
let subItem = findStripeItemForPrice({
const subItem = findStripeItemForPrice({
price: price!,
stripeItems: subToUpdate.items.data,
}) as Stripe.SubscriptionItem;
@@ -71,6 +70,7 @@ export const handleUpdateFeatureQuantity = async ({
return await handleQuantityDowngrade({
req,
attachParams,
attachConfig,
cusProduct,
stripeSub: subToUpdate,
oldOptions,
@@ -81,6 +81,7 @@ export const handleUpdateFeatureQuantity = async ({
return await handleQuantityUpgrade({
req,
attachParams,
attachConfig,
cusProduct,
stripeSubs,
oldOptions,
@@ -90,34 +91,4 @@ export const handleUpdateFeatureQuantity = async ({
subItem,
});
}
// if (!price) {
// throw new RecaseError({
// message: `updateFeatureQuantity: No price found for feature ${newOptions.feature_id}`,
// code: ErrCode.PriceNotFound,
// });
// }
// if (!subItem) {
// subItem = await stripeCli.subscriptionItems.create({
// subscription: subToUpdate.id,
// price: price.config.stripe_price_id as string,
// quantity: newOptions.quantity,
// proration_behavior: prorationBehavior,
// payment_behavior: "error_if_incomplete",
// });
// logger.info(
// `updateFeatureQuantity: Successfully created sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
// );
// } else {
// await stripeCli.subscriptionItems.update(subItem.id, {
// quantity: newOptions.quantity,
// proration_behavior: prorationBehavior,
// payment_behavior: "error_if_incomplete",
// });
// logger.info(
// `updateFeatureQuantity: Successfully updated sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
// );
// }
};

View File

@@ -1,13 +1,13 @@
import { SuccessCode } from "@autumn/shared";
import { type AttachConfig, SuccessCode } from "@autumn/shared";
import type Stripe from "stripe";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
AttachParams,
type AttachParams,
AttachResultSchema,
} from "../../../cusProducts/AttachParams.js";
import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js";
import { handleUpdateFeatureQuantity } from "./updateFeatureQuantity.js";
import { AttachConfig } from "@autumn/shared";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
export const handleUpdateQuantityFunction = async ({
req,
@@ -33,15 +33,21 @@ export const handleUpdateQuantityFunction = async ({
subIds: cusProduct.subscription_ids || [],
});
const invoices: Stripe.Invoice[] = [];
for (const options of optionsToUpdate) {
await handleUpdateFeatureQuantity({
const result = await handleUpdateFeatureQuantity({
req,
attachParams,
attachConfig: config,
cusProduct,
stripeSubs,
oldOptions: options.old,
newOptions: options.new,
});
if (result?.invoice) {
invoices.push(result.invoice);
}
}
await CusProductService.update({
@@ -54,6 +60,8 @@ export const handleUpdateQuantityFunction = async ({
AttachResultSchema.parse({
customer_id: customer.id || customer.internal_id,
product_ids: attachParams.products.map((p) => p.id),
invoice:
config.invoiceOnly && invoices.length > 0 ? invoices[0] : undefined,
code: SuccessCode.FeaturesUpdated,
message: `Successfully updated quantity for features: ${optionsToUpdate.map((o) => o.new.feature_id).join(", ")}`,
}),

View File

@@ -229,6 +229,8 @@ export const handleUpgradeFlow = async ({
}
if (res) {
if (req.apiVersion.gte(ApiVersion.V1_1)) {
res.status(200).json(
AttachResultSchema.parse({

View File

@@ -276,8 +276,11 @@ const getChangeProductBranch = async ({
prices2: newPrices,
});
// Check if it's a trial first
if (isUpgrade) {
if (isMainTrialBranch({ attachParams })) {
const isTrial = isMainTrialBranch({ attachParams });
if (isTrial) {
return AttachBranch.MainIsTrial;
}

View File

@@ -3,6 +3,7 @@ import {
AttachBranch,
type AttachConfig,
cusProductToPrices,
cusProductToProduct,
intervalToValue,
ProrationBehavior,
} from "@autumn/shared";
@@ -15,6 +16,7 @@ import {
attachParamsToProduct,
attachParamToCusProducts,
} from "./convertAttachParams.js";
import { isDefaultTrialFullProduct } from "@/internal/products/productUtils/classifyProduct.js";
export const intervalsAreSame = ({
attachParams,
@@ -111,6 +113,17 @@ export const getAttachConfig = async ({
const invoiceCheckout =
attachParams.invoiceOnly === true && !attachBody.enable_product_immediately;
// Check if upgrading from a default trial
const { curMainProduct } = attachParamToCusProducts({ attachParams });
let isUpgradingFromDefaultTrial = false;
if (curMainProduct && branch === AttachBranch.Upgrade) {
const product = cusProductToProduct({ cusProduct: curMainProduct });
isUpgradingFromDefaultTrial = isDefaultTrialFullProduct({
product,
skipDefault: true,
}) || false;
}
const checkoutFlow =
isPublic ||
forceCheckout ||
@@ -159,6 +172,7 @@ export const getAttachConfig = async ({
? attachBody.finalize_invoice!
: true,
requirePaymentMethod: paymentMethodRequired,
};
return { flags, config };

View File

@@ -53,6 +53,9 @@ export const getAttachFunction = async ({
AttachBranch.MainIsTrial,
].includes(branch);
// Check for upgrade/downgrade from default trial (should also use checkout)
if (newScenario && onlyCheckout) {
return AttachFunction.CreateCheckout;
} else if (branch === AttachBranch.OneOff) {

View File

@@ -5,11 +5,10 @@ import {
AttachErrCode,
BillingType,
cusProductsToCusEnts,
cusProductToPrices,
ErrCode,
cusProductToPrices, ErrCode,
type FullCusProduct,
getStartingBalance,
type UsagePriceConfig,
type UsagePriceConfig
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
@@ -230,14 +229,20 @@ export const handleAttachErrors = async ({
}
// Invoice no payment enabled: onlyCheckout
// Note: Upgrade from trial should proceed to checkout, so only block if NOT from trial
if (onlyCheckout || flags.isPublic) {
const upgradeDowngradeFlows = [
AttachBranch.Upgrade,
AttachBranch.Downgrade,
AttachBranch.MainIsTrial,
];
if (upgradeDowngradeFlows.includes(branch)) {
if (
upgradeDowngradeFlows.includes(branch)
) {
handleNonCheckoutErrors({
flags,
config,

View File

@@ -61,7 +61,7 @@ export const attachParamsToPreview = async ({
let preview: any = null;
if (
branch == AttachBranch.MultiAttach ||
branch === AttachBranch.MultiAttach ||
notNullish(attachParams.productsList)
) {
preview = await getMultiAttachPreview({
@@ -73,9 +73,9 @@ export const attachParamsToPreview = async ({
branch,
});
} else if (
func == AttachFunction.AddProduct ||
func == AttachFunction.CreateCheckout ||
func == AttachFunction.OneOff
func === AttachFunction.AddProduct ||
func === AttachFunction.CreateCheckout ||
func === AttachFunction.OneOff
) {
preview = await getNewProductPreview({
branch,
@@ -86,7 +86,7 @@ export const attachParamsToPreview = async ({
});
}
if (func == AttachFunction.ScheduleProduct) {
if (func === AttachFunction.ScheduleProduct) {
preview = await getDowngradeProductPreview({
attachParams,
now,
@@ -98,9 +98,9 @@ export const attachParamsToPreview = async ({
}
if (
func == AttachFunction.UpgradeDiffInterval ||
func == AttachFunction.UpgradeSameInterval ||
func == AttachFunction.UpdatePrepaidQuantity
func === AttachFunction.UpgradeDiffInterval ||
func === AttachFunction.UpgradeSameInterval ||
func === AttachFunction.UpdatePrepaidQuantity
) {
preview = await getUpgradeProductPreview({
req,

View File

@@ -6,8 +6,7 @@ import {
CusExpand,
CusProductStatus,
ErrCode,
FullCusProduct,
productToCusProduct,
FullCusProduct, productToCusProduct
} from "@autumn/shared";
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
@@ -297,32 +296,12 @@ cusRouter.get(
});
let productV2 = mapToProductV2({ product: product!, features });
// let numVersions = await ProductService.getProductVersionCount({
// db,
// orgId: org.id,
// env,
// productId: product_id,
// });
res.status(200).json({
cusProduct,
product: productV2,
// customer,
// product: cusProduct
// ? {
// ...productV2,
// options: cusProduct.options,
// isActive: cusProduct.status === CusProductStatus.Active,
// isCustom: cusProduct.is_custom,
// isCanceled:
// cusProduct.canceled_at !== null || cusProduct.canceled,
// cusProductId: cusProduct.id,
// }
// : productV2,
// features,
// numVersions,
// entities: customer.entities,
// org: createOrgResponse({ org, env }),
});
} catch (error) {
handleFrontendReqError({

View File

@@ -1,5 +1,5 @@
import type Stripe from "stripe";
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
import Stripe from "stripe";
export const createAndFinalizeInvoice = async ({
stripeCli,
@@ -9,6 +9,7 @@ export const createAndFinalizeInvoice = async ({
invoiceItems,
errorOnPaymentFail = true,
voidIfFailed = true,
chargeAutomatically = true,
logger,
}: {
stripeCli: Stripe;
@@ -18,12 +19,18 @@ export const createAndFinalizeInvoice = async ({
invoiceItems?: Stripe.InvoiceItemCreateParams[];
errorOnPaymentFail?: boolean;
voidIfFailed?: boolean;
chargeAutomatically?: boolean;
logger?: any;
}) => {
const invoice = await stripeCli.invoices.create({
customer: stripeCusId,
auto_advance: false,
subscription: stripeSubId,
collection_method: chargeAutomatically
? "charge_automatically"
: "send_invoice",
days_until_due: chargeAutomatically ? undefined : 30,
});
if (invoiceItems) {
@@ -36,11 +43,19 @@ export const createAndFinalizeInvoice = async ({
}
}
if (!chargeAutomatically) {
let finalInvoice = await stripeCli.invoices.retrieve(invoice.id!);
if (finalInvoice.total <= 0) {
finalInvoice = await stripeCli.invoices.finalizeInvoice(invoice.id!);
}
return { invoice: finalInvoice };
}
let finalInvoice = await stripeCli.invoices.finalizeInvoice(invoice.id!, {
auto_advance: false,
});
if (finalInvoice.status == "open") {
if (finalInvoice.status === "open") {
const {
paid,
error,

View File

@@ -2,13 +2,13 @@ import { AppEnv, ErrCode } from "@autumn/shared";
import Stripe from "stripe";
import { z } from "zod";
import { ensureStripeProductsWithEnv } from "@/external/stripe/stripeEnsureUtils.js";
import {
checkKeyValid,
createWebhookEndpoint,
} from "@/external/stripe/stripeOnboardingUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { encryptData } from "@/utils/encryptUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { OrgService } from "../OrgService.js";
@@ -287,3 +287,22 @@ export const handleConnectStripe = async (req: any, res: any) =>
});
},
});
export const handleGetStripe = async (req: any, res: any) => {
try {
const org = await OrgService.getFromReq(req);
if (!isStripeConnected({ org, env: req.env })) {
res.status(200).json({});
return;
}
const stripeCli = createStripeCli({ org, env: req.env });
const account_details = await stripeCli.accounts.retrieve();
res.status(200).json(account_details);
} catch (error) {
handleRequestError({ req, error, res, action: "Get invoice" });
}
};

View File

@@ -1,18 +1,17 @@
import express, { Router } from "express";
import express, { type Router } from "express";
import {
handleConnectStripe,
handleGetStripe,
} from "./handlers/handleConnectStripe.js";
import { handleDeleteOrg } from "./handlers/handleDeleteOrg.js";
import { handleDeleteStripe } from "./handlers/handleDeleteStripe.js";
import { handleGetInvites } from "./handlers/handleGetInvites.js";
import { handleGetOrg } from "./handlers/handleGetOrg.js";
import {
handleGetOrgMembers,
handleRemoveMember,
} from "./handlers/handleGetOrgMembers.js";
import { OrgService } from "./OrgService.js";
import { handleRequestError } from "@/utils/errorUtils.js";
import { createOrgResponse } from "./orgUtils.js";
import { handleGetUploadUrl } from "./handlers/handleGetUploadUrl.js";
import { handleDeleteOrg } from "./handlers/handleDeleteOrg.js";
import { handleGetInvites } from "./handlers/handleGetInvites.js";
import { handleConnectStripe } from "./handlers/handleConnectStripe.js";
import { handleDeleteStripe } from "./handlers/handleDeleteStripe.js";
import { handleGetOrg } from "./handlers/handleGetOrg.js";
export const orgRouter: Router = express.Router();
orgRouter.get("/members", handleGetOrgMembers);
@@ -29,6 +28,8 @@ orgRouter.delete("/delete-user", async (req: any, res) => {
orgRouter.get("", handleGetOrg);
orgRouter.get("/stripe", handleGetStripe);
orgRouter.post("/stripe", handleConnectStripe);
orgRouter.delete("/stripe", handleDeleteStripe);

View File

@@ -0,0 +1,89 @@
import {
type ApiPlatformUser,
type ListPlatformUsersQuery,
ListPlatformUsersQuerySchema,
member,
organizations,
user as userTable,
} from "@autumn/shared";
import { eq } from "drizzle-orm";
import { cte } from "@/db/cteUtils/buildCte.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
/**
* Route: GET /platform/users - List users created by master org
*/
export const listPlatformUsers = createRoute({
query: ListPlatformUsersQuerySchema,
handler: async (c) => {
const query = c.req.valid("query") as ListPlatformUsersQuery;
const ctx = c.get("ctx");
const { db, org, logger } = ctx;
const shouldExpandOrgs = query.expand?.includes("organizations");
// Build CTE for users with optional organizations
const usersCTE = cte({
name: "platform_users",
from: userTable,
where: eq(userTable.createdBy, org.id),
limit: query.limit,
offset: query.offset,
with: {
organizations: cte({
from: organizations,
through: {
table: member,
from: eq(member.userId, userTable.id),
to: eq(organizations.id, member.organizationId),
},
where: eq(organizations.created_by, org.id),
limit: 100,
}),
},
});
// Execute the CTE
const { data: results, count } = await usersCTE.execute({ db });
// Map results to API format
const users: ApiPlatformUser[] = results.map((user) => ({
// name: user.name,
email: user.email,
created_at: new Date(user.created_at).getTime(),
...(shouldExpandOrgs &&
user.organizations && {
organizations: user.organizations.map((org: any) => ({
slug: cleanOrgSlug(org.slug, org.id),
name: org.name,
created_at: new Date(org.createdAt).getTime(),
})),
}),
}));
return c.json({
list: users,
total: count,
limit: query.limit,
offset: query.offset,
});
},
});
/**
* Remove master org ID prefix from organization slug
*/
function cleanOrgSlug(slug: string, orgId: string): string {
let cleanedSlug = slug;
const prefix = `${orgId}_`;
if (cleanedSlug.startsWith(prefix)) {
cleanedSlug = cleanedSlug.slice(prefix.length);
}
// Handle the case where slug is prepended with "slug_orgId"
const altPrefix = `_${orgId}`;
if (cleanedSlug.endsWith(altPrefix)) {
cleanedSlug = cleanedSlug.slice(0, -altPrefix.length);
}
return cleanedSlug;
}

View File

@@ -0,0 +1,11 @@
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { listPlatformUsers } from "./handlers/handleListPlatformUsers.js";
/**
* Hono router for platform API endpoints
*/
export const honoPlatformRouter = new Hono<HonoEnv>();
// GET /platform/users - List users created by master org
honoPlatformRouter.get("/users", ...listPlatformUsers);

View File

@@ -1,5 +1,5 @@
import type { Stripe } from "stripe";
import { timeout } from "@/utils/genUtils.js";
import { Stripe } from "stripe";
export const getAllStripeSubscriptions = async ({
numPages,
@@ -24,6 +24,10 @@ export const getAllStripeSubscriptions = async ({
expand: ["data.discounts.coupon"],
});
if (response.data.length === 0) {
break;
}
allSubscriptions.push(...response.data);
hasMore = response.has_more;

View File

@@ -1,6 +1,6 @@
import "dotenv/config";
import fs from "node:fs";
import type { AppEnv } from "@autumn/shared";
import { ApiVersion, ApiVersionClass, type AppEnv } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { subHours } from "date-fns";
import type { Stripe } from "stripe";
@@ -180,6 +180,7 @@ export const initScript = async ({
features,
logger,
logtail: logger,
apiVersion: new ApiVersionClass(ApiVersion.V1_2),
} as unknown as ExtendedRequest;
return { stripeCli, autumnProducts, req };

View File

@@ -0,0 +1,74 @@
import { queryStringArray } from "@api/common/queryHelpers.js";
import { z } from "zod/v4";
/**
* Query params for GET /platform/users endpoint
*/
export const ListPlatformUsersQuerySchema = z.object({
limit: z
.number()
.int({ error: "limit must be an integer" })
.min(1, { error: "limit must be at least 1" })
.max(100, { error: "limit must be at most 100" })
.default(10),
offset: z
.number({ error: "offset must be a number" })
.int({ error: "offset must be an integer" })
.min(0, { error: "offset must be at least 0" })
.default(0),
expand: queryStringArray(z.enum(["organizations"]))
.optional()
.describe(
"Comma-separated list of fields to expand. Currently supports: organizations",
),
});
export type ListPlatformUsersQuery = z.infer<
typeof ListPlatformUsersQuerySchema
>;
/**
* Platform organization schema
*/
export const ApiPlatformOrgSchema = z.object({
slug: z.string().describe("Organization slug without the master org prefix"),
name: z.string().describe("Organization name"),
created_at: z
.number()
.describe("Timestamp of when org was created in milliseconds since epoch"),
});
export type ApiPlatformOrg = z.infer<typeof ApiPlatformOrgSchema>;
/**
* Platform user schema
*/
export const ApiPlatformUserSchema = z.object({
name: z.string().describe("User name"),
email: z.string().describe("User email"),
created_at: z
.number()
.describe("Timestamp of when user was created in milliseconds since epoch"),
organizations: z
.array(ApiPlatformOrgSchema)
.optional()
.describe("List of organizations created by the master org for this user"),
});
export type ApiPlatformUser = z.infer<typeof ApiPlatformUserSchema>;
/**
* Response schema for GET /platform/users
*/
export const ListPlatformUsersResponseSchema = z.object({
list: z.array(ApiPlatformUserSchema),
total: z.number().describe("Total number of users returned"),
limit: z.number().describe("Limit used in the query"),
offset: z.number().describe("Offset used in the query"),
});
export type ListPlatformUsersResponse = z.infer<
typeof ListPlatformUsersResponseSchema
>;

View File

@@ -1,6 +1,6 @@
import { relations } from "drizzle-orm";
import { invitation, member, user } from "./auth-schema.js";
import { organizations } from "../models/orgModels/orgTable.js";
import { invitation, member, user } from "./auth-schema.js";
export const userRelations = relations(user, ({ many }) => ({
memberships: many(member),

View File

@@ -5,6 +5,7 @@ export { schemas };
// API MODELS
export * from "./api/models.js";
export * from "./api/operations.js";
export * from "./api/platform/platformModels.js";
// API VERSIONING SYSTEM
export * from "./api/versionUtils/versionUtils.js";

View File

@@ -1,9 +1,8 @@
import { relations } from "drizzle-orm";
import { member } from "../../db/auth-schema.js";
import { apiKeys } from "../devModels/apiKeyTable.js";
import { features } from "../featureModels/featureTable.js";
import { organizations } from "./orgTable.js";
import { user } from "../../db/auth-schema.js";
import { member } from "../../db/auth-schema.js";
export const organizationsRelations = relations(organizations, ({ many }) => ({
api_keys: many(apiKeys),

View File

@@ -17,6 +17,9 @@
"linter": {
"enabled": true,
"rules": {
"correctness": {
"useExhaustiveDependencies": "off"
},
"recommended": true,
"complexity": {
"noStaticOnlyClass": "off"

View File

@@ -18,7 +18,6 @@
"dependencies": {
"@autumn/shared": "workspace:*",
"@better-auth/stripe": "^1.2.12",
"@clerk/clerk-react": "^5.24.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@heroicons/react": "^2.2.0",

View File

@@ -8,7 +8,6 @@ import { AdminView } from "./views/admin/AdminView";
import { AcceptInvitation } from "./views/auth/AcceptInvitation";
import { PasswordSignIn } from "./views/auth/components/PasswordSignIn";
import { SignIn } from "./views/auth/SignIn";
import CliAuth from "./views/CliAuth";
import { Otp } from "./views/cli/Otp";
import CustomersPage from "./views/customers/CustomersPage";
import { AnalyticsView } from "./views/customers/customer/analytics/AnalyticsView";
@@ -47,8 +46,6 @@ export default function App() {
<Route path="/onboarding" element={<OnboardingView2 />} />
<Route path="/sandbox/onboarding" element={<OnboardingView2 />} />
<Route path="/cli-auth" element={<CliAuth />} />
<Route
path="/products"
element={<ProductsView env={AppEnv.Live} />}

View File

@@ -1,8 +1,8 @@
import { authClient, useListOrganizations } from "@/lib/auth-client";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { FrontendOrg } from "@autumn/shared";
import type { FrontendOrg } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { authClient, useListOrganizations } from "@/lib/auth-client";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useOrg = () => {
const axiosInstance = useAxiosInstance();

View File

@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import type Stripe from "stripe";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useOrg } from "../common/useOrg";
/** Fetches organization Stripe account information */
export const useOrgStripeQuery = () => {
const axiosInstance = useAxiosInstance();
// const orgId = authClient.getSession()?.session.activeOrganizationId;
const { org } = useOrg();
const fetchStripeAccount = async () => {
const { data } = await axiosInstance.get<Stripe.Account>(
"/organization/stripe",
);
return data;
};
const { data, isLoading, error, refetch } = useQuery<Stripe.Account | null>({
queryKey: ["org", org?.id, "stripe"],
queryFn: fetchStripeAccount,
retry: false,
enabled: !!org?.id,
});
return {
stripeAccount: data || null,
isLoading,
error,
refetch,
};
};

View File

@@ -1,24 +1,63 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: can use any*/
import { AppEnv } from "@autumn/shared";
export const getStripeCusLink = (customerId: string, env: AppEnv) => {
return `https://dashboard.stripe.com${
env == AppEnv.Live ? "" : "/test"
}/customers/${customerId}`;
export const getStripeCusLink = ({
customerId,
env,
accountId,
}: {
customerId: string;
env: AppEnv;
accountId?: string;
}) => {
const baseUrl = `https://dashboard.stripe.com`;
const accountPath = accountId ? `/${accountId}` : "";
const withTest = env === AppEnv.Live ? "" : "/test";
return `${baseUrl}${accountPath}${withTest}/customers/${customerId}`;
};
export const getStripeSubLink = (subscriptionId: string, env: AppEnv) => {
return `https://dashboard.stripe.com${
env == AppEnv.Live ? "" : "/test"
}/subscriptions/${subscriptionId}`;
};
export const getStripeSubScheduleLink = (scheduledId: string, env: AppEnv) => {
return `https://dashboard.stripe.com${
env == AppEnv.Live ? "" : "/test"
}/subscription_schedules/${scheduledId}`;
export const getStripeSubLink = ({
subscriptionId,
env,
accountId,
}: {
subscriptionId: string;
env: AppEnv;
accountId?: string;
}) => {
const baseUrl = `https://dashboard.stripe.com`;
const accountPath = accountId ? `/${accountId}` : "";
const withTest = env === AppEnv.Live ? "" : "/test";
return `${baseUrl}${accountPath}${withTest}/subscriptions/${subscriptionId}`;
};
export const getStripeInvoiceLink = (stripeInvoice: any) => {
return `https://dashboard.stripe.com${
stripeInvoice.livemode ? "" : "/test"
}/invoices/${stripeInvoice.id || stripeInvoice.stripe_id}`;
export const getStripeSubScheduleLink = ({
scheduledId,
env,
accountId,
}: {
scheduledId: string;
env: AppEnv;
accountId?: string;
}) => {
const baseUrl = `https://dashboard.stripe.com`;
const accountPath = accountId ? `/${accountId}` : "";
const withTest = env === AppEnv.Live ? "" : "/test";
return `${baseUrl}${accountPath}${withTest}/subscription_schedules/${scheduledId}`;
};
export const getStripeInvoiceLink = ({
stripeInvoice,
env,
accountId,
}: {
stripeInvoice: any;
env: AppEnv;
accountId?: string;
}) => {
const baseUrl = `https://dashboard.stripe.com`;
const accountPath = accountId ? `/${accountId}` : "";
const withTest = env === AppEnv.Live ? "" : "/test";
return `${baseUrl}${accountPath}${withTest}/invoices/${stripeInvoice.id || stripeInvoice.stripe_id}`;
};

View File

@@ -1,59 +0,0 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import axios from "axios";
import { useSession } from "@clerk/clerk-react";
import LoadingScreen from "./general/LoadingScreen";
import ErrorScreen from "./general/ErrorScreen";
export default function CliAuth() {
let [searchParams] = useSearchParams();
let { session } = useSession();
let [savedToken, setSavedToken] = useState<boolean>(false);
let [error, setError] = useState<boolean>(false);
const handleCallback = async () => {
let code = searchParams.get("code");
let redirectUrl = searchParams.get("redirect");
if (!redirectUrl) {
return;
}
let token = await session?.getToken({
template: "cli_template",
});
try {
await axios.get(redirectUrl, {
params: {
token: token,
},
});
setSavedToken(true);
} catch (error) {
setError(true);
}
};
useEffect(() => {
if (!session) {
return;
}
handleCallback();
}, [searchParams, session]);
if (savedToken) {
return (
<div className="h-full w-full flex justify-center items-center">
<div className=""> Successfully authenticated CLI</div>
</div>
);
}
if (error) {
return <ErrorScreen>Something went wrong, please try again</ErrorScreen>;
}
return <LoadingScreen />;
}

View File

@@ -1,25 +1,34 @@
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { useCustomerContext } from "./CustomerContext";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Invoice, Product } from "@autumn/shared";
import {
type Entity,
type Invoice,
type InvoiceDiscount,
Product,
} from "@autumn/shared";
import { toast } from "sonner";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { Row, Item } from "@/components/general/TableGrid";
import { AdminHover } from "@/components/general/AdminHover";
import { Item, Row } from "@/components/general/TableGrid";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { cn } from "@/lib/utils";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { useCustomerContext } from "./CustomerContext";
import { CusProductEntityItem } from "./components/CusProductEntityItem";
import { useCusQuery } from "./hooks/useCusQuery";
export const InvoicesTable = () => {
// const { env, invoices, products, entityId, entities, showEntityView } =
// useCustomerContext();
const env = useEnv();
const { entityId, showEntityView } = useCustomerContext();
const { customer, products, entities } = useCusQuery();
const { stripeAccount } = useOrgStripeQuery();
const axiosInstance = useAxiosInstance();
const invoices = customer.invoices;
const entity = entities.find(
(e: any) => e.id === entityId || e.internal_id === entityId,
(e: Entity) => e.id === entityId || e.internal_id === entityId,
);
const getStripeInvoice = async (stripeInvoiceId: string) => {
@@ -28,16 +37,19 @@ export const InvoicesTable = () => {
`/v1/invoices/${stripeInvoiceId}/stripe`,
);
return data;
} catch (error) {
} catch {
toast.error("Failed to get invoice URL");
return null;
}
};
const getTotalDiscountAmount = (invoice: Invoice) => {
return invoice.discounts.reduce((acc: number, discount: any) => {
return acc + discount.amount_used;
}, 0);
return invoice.discounts.reduce(
(acc: number, discount: InvoiceDiscount) => {
return acc + discount.amount_used;
},
0,
);
};
const invoicesFiltered = invoices.filter((invoice: Invoice) => {
@@ -60,22 +72,17 @@ export const InvoicesTable = () => {
<p className="text-t3">No invoice history found</p>
</div>
) : (
<>
<Row
type="header"
className={cn(
"grid-cols-12 pr-0",
showEntityView && "grid-cols-15",
)}
>
<Item className="col-span-3">Products</Item>
{showEntityView && <Item className="col-span-3">Entity</Item>}
<Item className="col-span-3">Total</Item>
<Item className="col-span-3">Status</Item>
<Item className="col-span-2">Created At</Item>
<Item className="col-span-1" />
</Row>
</>
<Row
type="header"
className={cn("grid-cols-12 pr-0", showEntityView && "grid-cols-15")}
>
<Item className="col-span-3">Products</Item>
{showEntityView && <Item className="col-span-3">Entity</Item>}
<Item className="col-span-3">Total</Item>
<Item className="col-span-3">Status</Item>
<Item className="col-span-2">Created At</Item>
<Item className="col-span-1" />
</Row>
)}
{invoicesFiltered.map((invoice: Invoice) => (
@@ -85,12 +92,18 @@ export const InvoicesTable = () => {
onClick={async () => {
const stripeInvoice = await getStripeInvoice(invoice.stripe_id);
if (!stripeInvoice.hosted_invoice_url) {
const livemode = stripeInvoice.livemode;
window.open(getStripeInvoiceLink(stripeInvoice), "_blank");
window.open(
getStripeInvoiceLink({
stripeInvoice,
env,
accountId: stripeAccount?.id,
}),
"_blank",
);
return;
}
if (stripeInvoice && stripeInvoice.hosted_invoice_url) {
if (stripeInvoice?.hosted_invoice_url) {
window.open(stripeInvoice.hosted_invoice_url, "_blank");
}
}}

View File

@@ -1,14 +1,16 @@
import CopyButton from "@/components/general/CopyButton";
import { SideAccordion } from "@/components/general/SideAccordion";
import { getStripeCusLink } from "@/utils/linkUtils";
import { faStripe } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ArrowUpRightFromSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Link } from "react-router";
import { useEnv } from "@/utils/envUtils";
import Stripe from "stripe";
import CopyButton from "@/components/general/CopyButton";
import { SideAccordion } from "@/components/general/SideAccordion";
import { SidebarLabel } from "@/components/general/sidebar/sidebar-label";
import { Button } from "@/components/ui/button";
import { useOrg } from "@/hooks/common/useOrg";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useEnv } from "@/utils/envUtils";
import { getStripeCusLink } from "@/utils/linkUtils";
import { useCusQuery } from "../../hooks/useCusQuery";
export const CustomerDetails = ({
@@ -20,6 +22,8 @@ export const CustomerDetails = ({
}) => {
const { customer } = useCusQuery();
const env = useEnv();
const { org } = useOrg();
const { stripeAccount } = useOrgStripeQuery();
return (
<div className="flex w-full border-b mt-[2.5px] p-4">
@@ -100,15 +104,28 @@ export const CustomerDetails = ({
Stripe
</span>
<div className="col-span-6">
<Link
className="!cursor-pointer hover:underline"
to={getStripeCusLink(customer.processor?.id, env)}
target="_blank"
>
<div className="!cursor-pointer hover:underline">
<div className="flex items-center gap-2 justify-end">
<Button
variant="sidebarItem"
// className="bg-white border shadow-sm rounded-md gap-2 h-6 max-h-6 !py-0"
className="!cursor-pointer hover:underline"
onClick={() => {
if (stripeAccount) {
window.open(
getStripeCusLink({
customerId: customer.processor?.id,
env,
accountId: stripeAccount.id,
}),
"_blank",
);
} else {
window.location.href = getStripeCusLink({
customerId: customer.processor?.id,
env,
});
}
}}
>
<FontAwesomeIcon
icon={faStripe}
@@ -117,7 +134,7 @@ export const CustomerDetails = ({
<ArrowUpRightFromSquare size={12} className="text-t2" />
</Button>
</div>
</Link>
</div>
</div>
</>
)}

View File

@@ -1,9 +1,11 @@
import { useEnv } from "@/utils/envUtils";
import { getStripeSubLink, getStripeSubScheduleLink } from "@/utils/linkUtils";
import { CusProductStatus, FullCusProduct } from "@autumn/shared";
import { CusProductStatus, type FullCusProduct } from "@autumn/shared";
import { ArrowUpRightFromSquare } from "lucide-react";
import React from "react";
import { Link } from "react-router";
import { useOrg } from "@/hooks/common/useOrg";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useEnv } from "@/utils/envUtils";
import { getStripeSubLink, getStripeSubScheduleLink } from "@/utils/linkUtils";
export const CusProductStripeLink = ({
cusProduct,
@@ -11,6 +13,8 @@ export const CusProductStripeLink = ({
cusProduct: FullCusProduct;
}) => {
const env = useEnv();
const { org } = useOrg();
const { stripeAccount } = useOrgStripeQuery();
return (
<>
{cusProduct.subscription_ids &&
@@ -18,12 +22,24 @@ export const CusProductStripeLink = ({
<React.Fragment>
{cusProduct.subscription_ids.map((subId: string) => {
return (
<Link
<div
key={subId}
to={getStripeSubLink(subId, env)}
target="_blank"
onClick={(e) => {
e.stopPropagation();
if (stripeAccount) {
window.open(
getStripeSubLink({
subscriptionId: subId,
env,
accountId: stripeAccount.id,
}),
"_blank",
);
} else {
window.open(
getStripeSubLink({ subscriptionId: subId, env }),
);
}
}}
>
<div className="flex justify-center items-center w-fit px-2 gap-2 h-6">
@@ -32,7 +48,7 @@ export const CusProductStripeLink = ({
className="text-purple-stripe"
/>
</div>
</Link>
</div>
);
})}
</React.Fragment>
@@ -43,12 +59,24 @@ export const CusProductStripeLink = ({
<React.Fragment>
{cusProduct.scheduled_ids.map((subId: string) => {
return (
<Link
<div
key={subId}
to={getStripeSubScheduleLink(subId, env)}
target="_blank"
onClick={(e) => {
e.stopPropagation();
if (stripeAccount) {
window.open(
getStripeSubScheduleLink({
scheduledId: subId,
env,
accountId: stripeAccount.id,
}),
"_blank",
);
} else {
window.open(
getStripeSubScheduleLink({ scheduledId: subId, env }),
);
}
}}
>
<div className="flex justify-center items-center w-fit px-2 gap-2 h-6">
@@ -57,7 +85,7 @@ export const CusProductStripeLink = ({
className="text-purple-stripe"
/>
</div>
</Link>
</div>
);
})}
</React.Fragment>

View File

@@ -1,12 +1,11 @@
"use client";
import { type ProductItem, type ProductV2 } from "@autumn/shared";
import type { ProductItem, ProductV2 } from "@autumn/shared";
import { useEffect, useRef, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router";
import { CustomToaster } from "@/components/general/CustomToaster";
import { useOrg } from "@/hooks/common/useOrg";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useEnv } from "@/utils/envUtils";
import { notNullish } from "@/utils/genUtils";
import { sortProductItems } from "@/utils/productUtils";
import ErrorScreen from "@/views/general/ErrorScreen";
@@ -52,7 +51,6 @@ export default function CustomerProductView() {
const { isLoading: orgLoading } = useOrg();
const { isLoading: featuresLoading } = useFeaturesQuery();
const env = useEnv();
const initialProductRef = useRef<ProductV2 | null>(null);
const [options, setOptions] = useState<OptionValue[]>([]);
@@ -60,7 +58,6 @@ export default function CustomerProductView() {
const [entityFeatureIds, setEntityFeatureIds] = useState<string[]>([]);
const version = searchParams.get("version");
const customer_product_id = searchParams.get("id");
const {
product: originalProduct,
@@ -94,12 +91,17 @@ export default function CustomerProductView() {
const product = originalProduct;
setProduct(product);
console.log('[CPV] effect', { prodId: originalProduct.id, v: originalProduct.version, cusId: cusProduct?.id });
// Update initialProductRef BEFORE setProduct to ensure useAttachState
// effect has the correct baseline when it runs
initialProductRef.current = structuredClone({
...product,
items: sortProductItems(product.items),
});
setProduct(product);
setEntityFeatureIds(
Array.from(
new Set(

View File

@@ -17,6 +17,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { cn } from "@/lib/utils";
import { CusService } from "@/services/customers/CusService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
@@ -45,6 +46,7 @@ export const AttachModal = ({
}) => {
const { customer, entities } = useCusQuery();
const { product, entityId, attachState, version } = useProductContext();
const { stripeAccount } = useOrgStripeQuery();
const navigation = useNavigate();
const env = useEnv();
@@ -169,7 +171,14 @@ export const AttachModal = ({
if (data.checkout_url) {
window.open(data.checkout_url, "_blank");
} else if (data.invoice) {
window.open(getStripeInvoiceLink(data.invoice), "_blank");
window.open(
getStripeInvoiceLink({
stripeInvoice: data.invoice,
env,
accountId: stripeAccount?.id,
}),
"_blank",
);
}
navigateTo(`/customers/${cusId}`, navigation, env);

View File

@@ -119,6 +119,8 @@ export const useAttachState = ({
free_trial: initialProductRef.current?.free_trial || null,
});
console.log('[UAS] effect', { changed: hasItemsChanged, refLen: initialProductRef.current?.items?.length, hasPrepaid: productHasPrepaid(product.items) });
setItemsChanged(hasItemsChanged);
}, [product]);
@@ -163,21 +165,26 @@ export const useAttachState = ({
};
const getButtonText = () => {
if (cusProduct && !itemsChanged) {
if (flags.isOneOff) {
return "Attach Product";
const result = (() => {
if (cusProduct && !itemsChanged) {
if (flags.isOneOff) {
return "Attach Product";
}
if (flags.hasPrepaid) {
return "Update prepaid quantity";
}
}
if (flags.hasPrepaid) {
return "Update prepaid quantity";
if (flags.isCanceled) {
return "Renew Product";
}
}
if (flags.isCanceled) {
return "Renew Product";
}
return "Attach Product";
})();
return "Attach Product";
console.log('[BTN]', { text: result, cusId: cusProduct?.id, changed: itemsChanged, prepaid: flags.hasPrepaid });
return result;
};
return {

View File

@@ -1,14 +1,14 @@
import { useQueryClient } from "@tanstack/react-query";
import {
ACTIVE_STATUSES,
cusProductToProduct,
FullCusProduct,
FullCustomer,
type FullCusProduct,
type FullCustomer,
mapToProductV2,
notNullish,
type ProductV2,
productToCusProduct,
ProductV2,
} from "@autumn/shared";
import { useQueryClient } from "@tanstack/react-query";
export const useCusProductCache = ({
customerId,
@@ -81,15 +81,12 @@ export const useCusProductCache = ({
: cachedData.customer.entity?.internal_id;
const cusProduct = productToCusProduct({
productId: productId!,
productId,
cusProducts,
internalEntityId,
version: queryStates.version,
cusProductId: queryStates.customerProductId,
inStatuses: ACTIVE_STATUSES,
// version: undefined,
// cusProductId: undefined,
// inStatuses: ACTIVE_STATUSES,
});
if (cusProduct) {

View File

@@ -1,11 +1,11 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { FullCusProduct, ProductV2 } from "@autumn/shared";
import type { FullCusProduct, ProductV2 } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useParams } from "react-router";
import { useCusProductCache } from "./useCusProductCache";
import { useEffect, useMemo, useState } from "react";
import { debounce } from "lodash";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useCusProductCache } from "./useCusProductCache";
export const useCusProductQuery = () => {
const axiosInstance = useAxiosInstance();
@@ -28,7 +28,7 @@ export const useCusProductQuery = () => {
},
});
const cachedCusProduct = useMemo(getCachedCusProduct, [getCachedCusProduct]);
const cachedCusProduct = useMemo(getCachedCusProduct, []);
const fetcher = async () => {
const queryParams = {

View File

@@ -1,14 +1,17 @@
import type { ProductV2 } from "@autumn/shared";
import type { CheckoutResult } from "autumn-js";
import { ArrowUpRightFromSquare, Loader2, Plus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
CustomDialogBody,
CustomDialogContent,
CustomDialogFooter,
} from "@/components/general/modal-components/DialogContentWrapper";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Button } from "@/components/ui/button";
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InvoiceCustomerButton } from "../components/InvoiceCustomerButton";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { ArrowUpRightFromSquare, Loader2, Plus, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -16,22 +19,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import { ProductV2 } from "@autumn/shared";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { useOrg } from "@/hooks/common/useOrg";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { useEnv } from "@/utils/envUtils";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { getBackendErr } from "@/utils/genUtils";
import { MultiAtttachLines } from "./MultiAttachLines";
import { CheckoutResult } from "autumn-js";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { formatAmount } from "@/utils/product/productItemUtils";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { AddRewardButton, MultiAttachRewards } from "./MultiAttachRewards";
import { useAxiosSWR } from "@/services/useAxiosSwr";
import { useCusQuery } from "../../hooks/useCusQuery";
import { useOrg } from "@/hooks/common/useOrg";
import { InvoiceCustomerButton } from "../components/InvoiceCustomerButton";
import { getCusProductMinQuantity } from "../utils/getCusProductMinQuantity";
import { MultiAtttachLines } from "./MultiAttachLines";
import { AddRewardButton, MultiAttachRewards } from "./MultiAttachRewards";
export const MultiAttachDialog = ({
open,
@@ -43,6 +44,8 @@ export const MultiAttachDialog = ({
// const { customer, cusMutate, products, org } = useCustomerContext();
const { org } = useOrg();
const { customer, products, refetch } = useCusQuery();
const { stripeAccount } = useOrgStripeQuery();
const env = useEnv();
const axiosInstance = useAxiosInstance();
@@ -157,7 +160,14 @@ export const MultiAttachDialog = ({
});
if (data.invoice) {
window.open(getStripeInvoiceLink(data.invoice), "_blank");
window.open(
getStripeInvoiceLink({
stripeInvoice: data.invoice,
env,
accountId: stripeAccount?.id,
}),
"_blank",
);
}
await refetch();

View File

@@ -28,10 +28,19 @@ export default defineConfig({
},
optimizeDeps: {
// Exclude workspace dependencies from pre-bundling to avoid cache issues
exclude: ["@autumn/shared"],
exclude: [
"@autumn/shared",
"better-auth",
"better-auth/react",
"@better-auth/stripe",
],
// Force re-optimization on server start to catch workspace changes
force: process.env.FORCE_OPTIMIZE === "true",
// Include specific dependencies that might cause issues
include: [],
},
// Clear cache on config change
cacheDir: "node_modules/.vite",
server: {
host: "0.0.0.0", // Required for Docker
port: process.env.VITE_PORT