diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 0141ef708..056dd9c68 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -25,4 +25,4 @@ jobs: run: bun install - name: Run Knip - run: bun knip --no-exit-code + run: bun knip diff --git a/.husky/pre-commit b/.husky/pre-commit index 6dbfe7a0a..da223edad 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,5 @@ bun knip -cd server && bun ts +(cd server && bun ts) changed_files="$(git diff --cached --name-only)" diff --git a/apps/checkout/src/components/checkout/layout/CardBackground.tsx b/apps/checkout/src/components/checkout/layout/CardBackground.tsx deleted file mode 100644 index 578a3c50b..000000000 --- a/apps/checkout/src/components/checkout/layout/CardBackground.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { ReactNode } from "react"; -import { motion } from "motion/react"; - -/** - * Full-screen background wrapper with subtle diagonal gradients from primary color. - * Includes entrance animation for the content container. - */ -export function CardBackground({ children }: { children: ReactNode }) { - return ( -
- {/* Top-right diagonal gradient */} -
- ); -} diff --git a/apps/checkout/src/components/ui/card.tsx b/apps/checkout/src/components/ui/card.tsx deleted file mode 100644 index 5b4c79868..000000000 --- a/apps/checkout/src/components/ui/card.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const cardVariants = { - default: "ring-foreground/10 bg-card ring-1 shadow-[0_0_10px_2px_rgba(0,0,0,0.02)]", - muted: "bg-muted/50", -} - -function Card({ - className, - size = "default", - variant = "default", - ...props -}: React.ComponentProps<"div"> & { - size?: "default" | "sm" - variant?: "default" | "muted" -}) { - return ( -
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", - cardVariants[variant], - className - )} - {...props} - /> - ) -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, -} diff --git a/knip.json b/knip.json index 918e2fb0b..b2efc285b 100644 --- a/knip.json +++ b/knip.json @@ -16,18 +16,25 @@ "entry": ["apps/scope-picker/**/*"] }, "server": { - "entry": ["src/**/*.ts", "tests/**/*.ts"], + "entry": [ + "src/internal/customers/cusUtils/createNewCustomer.ts", + "src/utils/importUtils/addProductFromSubs.ts", + "src/utils/scriptUtils/readOnlyStripe.ts", + "src/utils/scriptUtils/scriptUtils.ts", + "src/utils/scriptUtils/getAll/getAllAutumnCustomers.ts", + "src/utils/scriptUtils/getAll/getAllOrgs.ts", + "tests/**/*.ts" + ], "project": ["src/**/*.ts", "tests/**/*.ts"], "ignoreDependencies": ["@react-email/components", "@axiomhq/pino"] }, "shared": { - "entry": ["**/*.ts", "!utils/**"], "project": ["**/*.ts", "!utils/**"], "includeEntryExports": false }, "vite": { - "entry": ["src/**/*.{ts,tsx}"], - "project": ["src/**/*.{ts,tsx}"], + "entry": ["tests/**/*.{ts,tsx}"], + "project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"], "ignore": ["src/components/ai-elements/**"], "ignoreDependencies": [ "tailwindcss", @@ -53,32 +60,30 @@ "project": ["**/*.{ts,tsx,mdx,mjs}"] }, "apps/checkout": { - "entry": ["src/**/*.{ts,tsx}"], "project": ["src/**/*.{ts,tsx}"], "ignoreDependencies": ["shadcn", "tailwindcss", "tw-animate-css"] }, "packages/atmn": { - "entry": ["src/**/*.{ts,tsx}"], + "entry": ["src/cli.tsx", "src/compose/index.ts"], "project": ["src/**/*.{ts,tsx}", "*.ts"] }, "packages/autumn-js": { - "entry": ["src/**/*.ts", "*.ts"], + "entry": ["src/{sdk,react,backend,better-auth}/index.ts", "src/backend/adapters/*.ts"], "project": ["src/**/*.ts", "*.ts"] }, "packages/ksuid": { - "entry": ["src/**/*.ts"], "project": ["src/**/*.ts"] }, "packages/openapi": { - "entry": ["**/*.ts"], + "entry": ["scripts/**/*.ts", "v2.1/contracts/index.ts"], "project": ["**/*.ts"] }, "packages/sdk": { - "entry": ["src/**/*.ts", "examples/**/*.ts"], + "entry": ["src/*.ts", "examples/**/*.ts"], "project": ["src/**/*.ts", "examples/**/*.ts"] }, "packages/stripe-sync": { - "entry": ["src/**/*.ts", "scripts/**/*.ts"], + "entry": ["scripts/**/*.ts"], "project": ["src/**/*.ts", "scripts/**/*.ts"] } }, diff --git a/packages/atmn/src/commands/events-aggregate-test/command.ts b/packages/atmn/src/commands/events-aggregate-test/command.ts deleted file mode 100644 index 049815b9a..000000000 --- a/packages/atmn/src/commands/events-aggregate-test/command.ts +++ /dev/null @@ -1,150 +0,0 @@ -import chalk from "chalk"; -// @ts-expect-error - ervy doesn't have types -import ervy from "ervy"; -import { fetchEvents } from "../../lib/api/endpoints/events.js"; -import { AppEnv } from "../../lib/env/detect.js"; -import { getKey } from "../../lib/env/keys.js"; - -const { bar, bg } = ervy; - -interface AggregateOptions { - prod: boolean; - limit: number; -} - -/** - * Headless test command to inspect aggregate data and test ervy charts - */ -export async function eventsAggregateTestCommand(options: AggregateOptions) { - const environment = options.prod ? AppEnv.Live : AppEnv.Sandbox; - const secretKey = getKey(environment); - - console.log(chalk.cyan(`\n=== Events Aggregate Test ===`)); - console.log(chalk.gray(`Environment: ${environment}`)); - console.log(chalk.gray(`Limit: ${options.limit}`)); - - // Fetch events - console.log(chalk.yellow(`\nFetching events...`)); - const response = await fetchEvents({ - secretKey, - limit: options.limit, - }); - - const events = response.list; - console.log(chalk.green(`Fetched ${events.length} events`)); - - // === RAW DATA === - console.log(chalk.cyan(`\n=== Raw Events Sample (first 5) ===`)); - for (const event of events.slice(0, 5)) { - console.log( - JSON.stringify( - { - id: event.id.slice(0, 12) + "...", - feature_id: event.feature_id, - customer_id: event.customer_id.slice(0, 12) + "...", - value: event.value, - timestamp: new Date(event.timestamp).toISOString(), - }, - null, - 2, - ), - ); - } - - // === AGGREGATE BY FEATURE === - console.log(chalk.cyan(`\n=== Aggregate by Feature ===`)); - const featureMap = new Map(); - for (const event of events) { - const existing = featureMap.get(event.feature_id) ?? { - count: 0, - totalValue: 0, - }; - featureMap.set(event.feature_id, { - count: existing.count + 1, - totalValue: existing.totalValue + event.value, - }); - } - - const byFeature = Array.from(featureMap.entries()) - .map(([featureId, data]) => ({ - featureId, - count: data.count, - totalValue: data.totalValue, - })) - .sort((a, b) => b.count - a.count); - - console.log(chalk.gray("Feature aggregates:")); - for (const f of byFeature) { - console.log( - ` ${f.featureId.padEnd(30)} count=${f.count} total=${f.totalValue}`, - ); - } - - // === AGGREGATE BY TIME (day) === - console.log(chalk.cyan(`\n=== Aggregate by Day ===`)); - const dayMap = new Map(); - for (const event of events) { - const date = new Date(event.timestamp); - const dayKey = `${date.getMonth() + 1}/${date.getDate()}`; - dayMap.set(dayKey, (dayMap.get(dayKey) ?? 0) + 1); - } - - const byDay = Array.from(dayMap.entries()) - .map(([day, count]) => ({ day, count })) - .sort((a, b) => a.day.localeCompare(b.day)); - - console.log(chalk.gray("Daily counts:")); - for (const d of byDay) { - console.log(` ${d.day.padEnd(10)} count=${d.count}`); - } - - // === ERVY BAR CHART DATA FORMAT === - console.log(chalk.cyan(`\n=== ervy Bar Chart Data Format ===`)); - - // Prepare data for ervy - CORRECT format with bg() function - const colors = ["cyan", "green", "yellow", "magenta", "blue", "red", "white"]; - const chartData = byFeature.slice(0, 6).map((f, idx) => ({ - key: f.featureId.length > 10 ? f.featureId.slice(0, 8) + ".." : f.featureId, - value: f.count, - style: bg(colors[idx % colors.length], 1), - })); - - console.log(chalk.gray("Chart data:")); - console.log(JSON.stringify(chartData, null, 2)); - - // === RENDER ERVY BAR CHART === - console.log(chalk.cyan(`\n=== ervy Bar Chart Output ===`)); - try { - const chartOutput = bar(chartData, { - barWidth: 3, - height: 6, - padding: 2, - }); - console.log(chartOutput); - } catch (err) { - console.log(chalk.red(`Bar chart error: ${err}`)); - } - - // === SIMPLE ASCII BAR (fallback) === - console.log(chalk.cyan(`\n=== Simple ASCII Bar Chart ===`)); - const maxCount = Math.max(...byFeature.map((f) => f.count)); - const barWidth = 40; - - for (const f of byFeature.slice(0, 8)) { - const width = Math.round((f.count / maxCount) * barWidth); - const barStr = "█".repeat(width); - const label = f.featureId.length > 20 ? f.featureId.slice(0, 18) + ".." : f.featureId.padEnd(20); - console.log(`${label} ${chalk.cyan(barStr)} ${f.count}`); - } - - // === SUMMARY === - console.log(chalk.cyan(`\n=== Summary ===`)); - console.log(`Total events: ${events.length}`); - console.log(`Total value: ${events.reduce((sum, e) => sum + e.value, 0)}`); - console.log(`Unique features: ${featureMap.size}`); - console.log( - `Unique customers: ${new Set(events.map((e) => e.customer_id)).size}`, - ); - - console.log(chalk.green(`\n=== Done ===\n`)); -} diff --git a/packages/atmn/src/commands/events-aggregate-test/index.ts b/packages/atmn/src/commands/events-aggregate-test/index.ts deleted file mode 100644 index f5348a1be..000000000 --- a/packages/atmn/src/commands/events-aggregate-test/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { eventsAggregateTestCommand } from "./command.js"; diff --git a/packages/atmn/src/commands/pull/index.ts b/packages/atmn/src/commands/pull/index.ts deleted file mode 100644 index 1d30ee85f..000000000 --- a/packages/atmn/src/commands/pull/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { pull } from "./pull.js"; -export type { EnvironmentData, PullOptions, PullResult } from "./types.js"; diff --git a/packages/atmn/src/commands/test-template/command.tsx b/packages/atmn/src/commands/test-template/command.tsx deleted file mode 100644 index 504c3427b..000000000 --- a/packages/atmn/src/commands/test-template/command.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { render } from "ink"; -import { TemplateSelector } from "../../views/react/template/TemplateSelector.js"; - -export function testTemplateCommand() { - render(); -} diff --git a/packages/atmn/src/lib/constants/templates.ts b/packages/atmn/src/lib/constants/templates.ts deleted file mode 100644 index 199161bd1..000000000 --- a/packages/atmn/src/lib/constants/templates.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Template data for the template selector - * Each template has 3 pricing plans with features - */ - -export interface PlanData { - name: string; - features: string[]; - price: string; - badge?: string; -} - -export const templateData: Record = { - Railway: [ - { - name: "Free", - features: [ - "500 credits one-time", - "Memory: 0.039cr/GB-hr", - "CPU: 0.078cr/vCPU-hr", - "Egress: 5cr/GB", - ], - price: "$0", - }, - { - name: "Hobby", - badge: "most popular", - features: [ - "500 credits/month", - "Pay-per-use overage", - "Memory: 0.039cr/GB-hr", - "CPU: 0.078cr/vCPU-hr", - "Egress: 5cr/GB", - "Storage: 1.5cr/GB-mo", - ], - price: "$5/month", - }, - { - name: "Pro", - features: [ - "2000 credits/month", - "Pay-per-use overage", - "All resource types", - "Team features", - "Priority support", - ], - price: "$20/month", - }, - ], - Linear: [ - { - name: "Free", - features: [ - "2 teams", - "250 issues limit", - "Basic integrations", - "Community support", - ], - price: "$0", - }, - { - name: "Basic", - badge: "most popular", - features: [ - "5 teams", - "Unlimited issues", - "All integrations", - "Cycles & roadmaps", - "Email support", - "Guest access", - ], - price: "$12/user/mo", - }, - { - name: "Business", - features: [ - "Unlimited teams", - "Unlimited issues", - "SAML SSO", - "Audit logs", - "Priority support", - ], - price: "$18/user/mo", - }, - ], - "T3 Chat": [ - { - name: "Free", - features: ["100 messages", "/month", "Basic models", "Web access"], - price: "$0", - }, - { - name: "Pro", - badge: "most popular", - features: [ - "1500 messages/month", - "100 premium/month", - "All models", - "Priority access", - "Faster responses", - "File uploads", - ], - price: "$8/month", - }, - { - name: "Credits", - features: [ - "100 premium", - "messages", - "One-time purchase", - "Never expires", - ], - price: "$8 add-on", - }, - ], -}; - -export const templates = ["Railway", "Linear", "T3 Chat"] as const; - -export type TemplateName = (typeof templates)[number]; diff --git a/packages/atmn/src/lib/utils/index.ts b/packages/atmn/src/lib/utils/index.ts deleted file mode 100644 index ac75f0d22..000000000 --- a/packages/atmn/src/lib/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./monorepo.js"; diff --git a/packages/atmn/src/views/App.tsx b/packages/atmn/src/views/App.tsx deleted file mode 100644 index 060eae060..000000000 --- a/packages/atmn/src/views/App.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Box, Text } from "ink"; - -export default function App() { - return ( - - - atmn - - Autumn CLI - Interactive Mode - - ); -} diff --git a/packages/atmn/src/views/react/components/providers/index.ts b/packages/atmn/src/views/react/components/providers/index.ts deleted file mode 100644 index e105c7df6..000000000 --- a/packages/atmn/src/views/react/components/providers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { QueryProvider } from "./QueryProvider.js"; diff --git a/packages/atmn/src/views/react/customers/components/CustomerRow.tsx b/packages/atmn/src/views/react/customers/components/CustomerRow.tsx deleted file mode 100644 index d2e2339e9..000000000 --- a/packages/atmn/src/views/react/customers/components/CustomerRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Box, Text } from "ink"; -import type { ColumnWidths, CustomerRowProps } from "../types.js"; -import { formatDate, truncate } from "../types.js"; - -/** - * Single customer row in the table - */ -export function CustomerRow({ - customer, - isSelected, - isFocused, - columnWidths, -}: CustomerRowProps) { - const marker = isSelected ? "▸ " : " "; - const markerColor = isSelected && isFocused ? "magenta" : "gray"; - - const { colId, colName, colEmail, colCreated, shouldTruncate } = columnWidths; - - return ( - - {marker} - - - {shouldTruncate - ? truncate(customer.id, colId - 1) - : customer.id || "-"} - - - - - {shouldTruncate - ? truncate(customer.name, colName - 1) - : customer.name || "-"} - - - - - {shouldTruncate - ? truncate(customer.email, colEmail - 1) - : customer.email || "-"} - - - - - {formatDate(customer.created_at)} - - - - ); -} - -export interface CustomerTableHeaderProps { - columnWidths: ColumnWidths; -} - -/** - * Table header row - */ -export function CustomerTableHeader({ - columnWidths, -}: CustomerTableHeaderProps) { - const { colId, colName, colEmail, colCreated } = columnWidths; - - return ( - - {" "} - - - ID - - - - - Name - - - - - Email - - - - - Created - - - - ); -} diff --git a/packages/atmn/src/views/react/customers/components/CustomersTable.tsx b/packages/atmn/src/views/react/customers/components/CustomersTable.tsx deleted file mode 100644 index 743a8ba83..000000000 --- a/packages/atmn/src/views/react/customers/components/CustomersTable.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Box } from "ink"; -import { ScrollList, type ScrollListRef } from "ink-scroll-list"; -import { useEffect, useRef } from "react"; -import type { ColumnWidths, CustomersTableProps } from "../types.js"; -import { CustomerRow, CustomerTableHeader } from "./CustomerRow.js"; - -export interface CustomersTableComponentProps extends CustomersTableProps { - columnWidths: ColumnWidths; -} - -/** - * Scrollable customer table using ink-scroll-list - */ -export function CustomersTable({ - customers, - selectedIndex, - onSelect, - isFocused, - columnWidths, -}: CustomersTableComponentProps) { - const listRef = useRef(null); - - // Handle terminal resize - useEffect(() => { - const handleResize = () => { - listRef.current?.remeasure(); - }; - - process.stdout.on("resize", handleResize); - return () => { - process.stdout.off("resize", handleResize); - }; - }, []); - - // Update selected customer when index changes - useEffect(() => { - if (customers[selectedIndex]) { - onSelect(customers[selectedIndex], selectedIndex); - } - }, [selectedIndex, customers, onSelect]); - - return ( - - - - - {customers.map((customer, index) => ( - - ))} - - - - ); -} diff --git a/packages/atmn/src/views/react/customers/components/EmptyState.tsx b/packages/atmn/src/views/react/customers/components/EmptyState.tsx deleted file mode 100644 index a521f52ea..000000000 --- a/packages/atmn/src/views/react/customers/components/EmptyState.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Box, Text } from "ink"; -import { AppEnv } from "../../../../lib/env/detect.js"; -import type { EmptyStateProps } from "../types.js"; - -/** - * Empty state when no customers exist - */ -export function EmptyState({ environment, searchQuery }: EmptyStateProps) { - const envLabel = environment === AppEnv.Sandbox ? "sandbox" : "live"; - - // Different message when search has no results - if (searchQuery) { - return ( - - 🔍 - - No results for "{searchQuery}" - - - - Try a different search term or press x{" "} - to clear the search. - - - - ); - } - - return ( - - 📭 - - No customers found - - - - There are no customers in your {envLabel} environment yet. - - - - - Create customers via the API or dashboard to see them here. - - - - ); -} diff --git a/packages/atmn/src/views/react/customers/components/ErrorState.tsx b/packages/atmn/src/views/react/customers/components/ErrorState.tsx deleted file mode 100644 index 9d198ef82..000000000 --- a/packages/atmn/src/views/react/customers/components/ErrorState.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Box, Text } from "ink"; -import type { ErrorStateProps } from "../types.js"; - -/** - * Error state with retry option - */ -export function ErrorState({ error, onRetry: _onRetry }: ErrorStateProps) { - return ( - - - - ✗ Error loading customers - - - - {error.message} - - - - Press r to retry or{" "} - q to quit - - - - ); -} diff --git a/packages/atmn/src/views/react/customers/components/KeybindHints.tsx b/packages/atmn/src/views/react/customers/components/KeybindHints.tsx deleted file mode 100644 index 96fde0be3..000000000 --- a/packages/atmn/src/views/react/customers/components/KeybindHints.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Box, Text } from "ink"; -import type { KeybindHintsProps } from "../types.js"; - -/** - * Context-aware keyboard shortcut hints - */ -export function KeybindHints({ - focusTarget, - sheetOpen, - canGoPrev, - canGoNext, -}: KeybindHintsProps) { - if (focusTarget === "sheet" && sheetOpen) { - return ( - - - Tab - focus table - - - Esc - close - - - c - copy ID - - - o - open - - - q - quit - - - ); - } - - // Table focused hints - return ( - - - ↑↓ - navigate - - {canGoPrev && ( - - - prev page - - )} - {canGoNext && ( - - - next page - - )} - - Enter - inspect - - - / - search - - - r - refresh - - - q - quit - - - ); -} diff --git a/packages/atmn/src/views/react/customers/components/LoadingState.tsx b/packages/atmn/src/views/react/customers/components/LoadingState.tsx deleted file mode 100644 index 312b51c82..000000000 --- a/packages/atmn/src/views/react/customers/components/LoadingState.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Box, Text } from "ink"; -import Spinner from "ink-spinner"; -import { AppEnv } from "../../../../lib/env/detect.js"; -import type { LoadingStateProps } from "../types.js"; - -/** - * Loading state with spinner - */ -export function LoadingState({ environment }: LoadingStateProps) { - const envLabel = environment === AppEnv.Sandbox ? "sandbox" : "live"; - - return ( - - - - - - Loading customers from {envLabel}... - - - ); -} diff --git a/packages/atmn/src/views/react/customers/components/SearchInput.tsx b/packages/atmn/src/views/react/customers/components/SearchInput.tsx deleted file mode 100644 index 9ffe51ddf..000000000 --- a/packages/atmn/src/views/react/customers/components/SearchInput.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Box, Text, useInput } from "ink"; -import TextInput from "ink-text-input"; -import { useState } from "react"; - -export interface SearchInputProps { - /** Current search value */ - initialValue: string; - /** Called when search is submitted */ - onSubmit: (query: string) => void; - /** Called when search is cancelled */ - onCancel: () => void; -} - -/** - * Inline search input that appears below the title bar - */ -export function SearchInput({ - initialValue, - onSubmit, - onCancel, -}: SearchInputProps) { - const [value, setValue] = useState(initialValue); - - useInput((_input, key) => { - if (key.escape) { - onCancel(); - return; - } - if (key.return) { - onSubmit(value); - return; - } - }); - - return ( - - Search: - - (Enter to search, Esc to cancel) - - ); -} diff --git a/packages/atmn/src/views/react/customers/components/TitleBar.tsx b/packages/atmn/src/views/react/customers/components/TitleBar.tsx deleted file mode 100644 index bdc93518b..000000000 --- a/packages/atmn/src/views/react/customers/components/TitleBar.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Box, Text } from "ink"; -import { APP_VERSION } from "../../../../lib/version.js"; -import type { TitleBarProps } from "../types.js"; - -/** - * Title bar showing version, command name, pagination info, and search query - */ -export function TitleBar({ - environment, - pagination, - searchQuery, -}: TitleBarProps) { - return ( - - {APP_VERSION} - - - atmn customers - - - {pagination.display} - {searchQuery && ( - <> - - search: - {searchQuery} - (x to clear) - - )} - - ); -} diff --git a/packages/atmn/src/views/react/customers/components/index.ts b/packages/atmn/src/views/react/customers/components/index.ts deleted file mode 100644 index 239bcc697..000000000 --- a/packages/atmn/src/views/react/customers/components/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { CustomerRow, CustomerTableHeader } from "./CustomerRow.js"; -export { CustomerSheet } from "./CustomerSheet.js"; -export { CustomersTable } from "./CustomersTable.js"; -export { EmptyState } from "./EmptyState.js"; -export { ErrorState } from "./ErrorState.js"; -export { KeybindHints } from "./KeybindHints.js"; -export { LoadingState } from "./LoadingState.js"; -export { SearchInput } from "./SearchInput.js"; -export { TitleBar } from "./TitleBar.js"; diff --git a/packages/atmn/src/views/react/features/components/index.ts b/packages/atmn/src/views/react/features/components/index.ts deleted file mode 100644 index 682d0d550..000000000 --- a/packages/atmn/src/views/react/features/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { FeatureSheet } from "./FeatureSheet.js"; -export type { FeatureSheetProps } from "./FeatureSheet.js"; diff --git a/packages/atmn/src/views/react/features/index.ts b/packages/atmn/src/views/react/features/index.ts deleted file mode 100644 index a883750be..000000000 --- a/packages/atmn/src/views/react/features/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { FeaturesView } from "./FeaturesView.js"; -export type { FeaturesViewProps } from "./FeaturesView.js"; -export { FeatureSheet } from "./components/index.js"; -export type { FeatureSheetProps } from "./components/index.js"; diff --git a/packages/atmn/src/views/react/init/steps/AgentStep.tsx b/packages/atmn/src/views/react/init/steps/AgentStep.tsx deleted file mode 100644 index 26e230e72..000000000 --- a/packages/atmn/src/views/react/init/steps/AgentStep.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { Box, Text } from "ink"; -import { useEffect, useState } from "react"; -import { - type AgentIdentifier, - type FileOption, - useAgentSetup, -} from "../../../../lib/hooks/index.js"; -import { - MultiSelect, - StatusLine, - StepHeader, -} from "../../components/index.js"; - -interface AgentStepProps { - step: number; - totalSteps: number; - onComplete: () => void; -} - -type AgentState = - | "selecting" - | "mcp-agents" - | "installing" - | "creating" - | "complete" - | "error"; - -/** - * Agent setup step - allows user to configure agent files - */ -export function AgentStep({ step, totalSteps, onComplete }: AgentStepProps) { - const [state, setState] = useState("selecting"); - const [selectedOptions, setSelectedOptions] = useState([]); - const [selectedAgents, setSelectedAgents] = useState([]); - const [error, setError] = useState(null); - - const { installMcp, createAgentFiles } = useAgentSetup(); - - const options = [ - { - label: "MCP Server Config (for Claude Code, OpenCode, etc.)", - value: "mcp", - }, - { - label: "CLAUDE.md", - value: "claude-md", - }, - { - label: "AGENTS.md", - value: "agents-md", - }, - { - label: ".cursorrules", - value: "cursor-rules", - }, - ]; - - const agentOptions = [ - { - label: "Claude Code (auto-install via command)", - value: "claude-code", - }, - { - label: "OpenCode, Codex and others (copy URL to clipboard)", - value: "other", - }, - ]; - - const handleSubmit = (values: string[]) => { - setSelectedOptions(values); - - // If MCP is selected, go to agent selection - if (values.includes("mcp")) { - setState("mcp-agents"); - } else { - // Otherwise, create the other files - setState("creating"); - const fileOptions = values.filter((v) => v !== "mcp") as FileOption[]; - createAgentFiles.mutate(fileOptions); - } - }; - - const handleAgentSubmit = (agents: string[]) => { - setSelectedAgents(agents); - setState("installing"); - - // Install MCP for selected agents - installMcp.mutate(agents as AgentIdentifier[]); - }; - - // Handle MCP installation completion - useEffect(() => { - if (installMcp.isSuccess && state === "installing") { - // Now create the other selected files - const nonMcpOptions = selectedOptions.filter( - (opt) => opt !== "mcp", - ) as FileOption[]; - if (nonMcpOptions.length > 0) { - setState("creating"); - createAgentFiles.mutate(nonMcpOptions); - } else { - setState("complete"); - setTimeout(() => { - onComplete(); - }, 1000); - } - } - }, [ - installMcp.isSuccess, - state, - selectedOptions, - onComplete, - createAgentFiles, - ]); - - // Handle MCP installation error - useEffect(() => { - if (installMcp.isError) { - setError( - installMcp.error instanceof Error - ? installMcp.error.message - : "Failed to install MCP", - ); - setState("error"); - } - }, [installMcp.isError, installMcp.error]); - - // Handle file creation completion - useEffect(() => { - if (createAgentFiles.isSuccess && state === "creating") { - setState("complete"); - setTimeout(() => { - onComplete(); - }, 1000); - } - }, [createAgentFiles.isSuccess, state, onComplete]); - - // Handle file creation error - useEffect(() => { - if (createAgentFiles.isError) { - setError( - createAgentFiles.error instanceof Error - ? createAgentFiles.error.message - : "Failed to create files", - ); - setState("error"); - } - }, [createAgentFiles.isError, createAgentFiles.error]); - - const handleChange = (values: string[]) => { - setSelectedOptions(values); - }; - - const handleAgentChange = (values: string[]) => { - setSelectedAgents(values); - }; - - if (state === "selecting") { - return ( - - - - Select agent configuration files to create/update: - (Space to select, Enter to confirm) - - - - - - ); - } - - if (state === "mcp-agents") { - return ( - - - - Which agent(s) are you using? - (Space to select, Enter to confirm) - - - - - - ); - } - - if (state === "installing") { - const installMessages: string[] = []; - - if (selectedAgents.includes("claude-code")) { - installMessages.push("Installing MCP for Claude Code..."); - } - - if ( - selectedAgents.includes("opencode") || - selectedAgents.includes("other") - ) { - installMessages.push("Copied MCP URL to clipboard!"); - } - - return ( - - - - {installMessages.map((msg) => ( - - ))} - - - ); - } - - if (state === "creating") { - return ( - - - o !== "mcp").length} file${selectedOptions.filter((o) => o !== "mcp").length !== 1 ? "s" : ""}...`} - /> - - ); - } - - if (state === "complete") { - const createdFiles: string[] = []; - - if (selectedAgents.length > 0) { - createdFiles.push("MCP server config"); - } - if (selectedOptions.includes("claude-md")) { - createdFiles.push("CLAUDE.md"); - } - if (selectedOptions.includes("agents-md")) { - createdFiles.push("AGENTS.md"); - } - if (selectedOptions.includes("cursor-rules")) { - createdFiles.push(".cursorrules"); - } - - return ( - - - 0 - ? `Created ${createdFiles.join(", ")}` - : "Setup complete" - } - /> - - ); - } - - if (state === "error") { - return ( - - - - - ); - } - - return null; -} diff --git a/packages/atmn/src/views/react/init/steps/StripeStep.tsx b/packages/atmn/src/views/react/init/steps/StripeStep.tsx deleted file mode 100644 index aa0badf6b..000000000 --- a/packages/atmn/src/views/react/init/steps/StripeStep.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { Box, Text } from "ink"; -import open from "open"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { FRONTEND_URL } from "../../../../constants.js"; -import { fetchOrganization } from "../../../../lib/api/endpoints/organization.js"; -import { readFromEnv } from "../../../../lib/utils.js"; -import { StatusLine, StepHeader } from "../../components/index.js"; - -type StripeState = - | "pending" - | "checking" - | "not_connected" - | "connecting" - | "connected" - | "error"; - -interface StripeStepProps { - step: number; - totalSteps: number; - onComplete: () => void; -} - -export function StripeStep({ step, totalSteps, onComplete }: StripeStepProps) { - const [stripeState, setStripeState] = useState("pending"); - const [stripeError, setStripeError] = useState(null); - const pollIntervalRef = useRef(null); - - const connectStripe = useCallback(async () => { - setStripeState("connecting"); - - // Open dashboard to Stripe connect page - const stripeConnectUrl = `${FRONTEND_URL}/dev?tab=stripe`; - await open(stripeConnectUrl); - - // Poll for Stripe connection - const maxAttempts = 60; // 5 minutes with 5 second intervals - let attempts = 0; - - const pollInterval = setInterval(async () => { - attempts++; - - try { - const secretKey = readFromEnv({ bypass: true }); - if (!secretKey) { - return; // Continue polling, key may not be ready yet - } - - const orgDetails = await fetchOrganization({ secretKey }); - - if ( - orgDetails.stripe_connection && - orgDetails.stripe_connection !== "none" - ) { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } - setStripeState("connected"); - onComplete(); - } else if (attempts >= maxAttempts) { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } - setStripeError("Timed out waiting for Stripe connection"); - setStripeState("error"); - } - } catch { - // Continue polling on error - } - }, 5000); - - pollIntervalRef.current = pollInterval; - }, [onComplete]); - - const checkStripe = useCallback(async () => { - setStripeState("checking"); - - try { - const secretKey = readFromEnv({ bypass: true }); - if (!secretKey) { - setStripeError("No API key found. Please run 'atmn login' first."); - setStripeState("error"); - return; - } - - // Fetch org details to check Stripe connection - const orgDetails = await fetchOrganization({ secretKey }); - - if ( - orgDetails.stripe_connection && - orgDetails.stripe_connection !== "none" - ) { - setStripeState("connected"); - onComplete(); - } else { - setStripeState("not_connected"); - // Auto-open Stripe connect page - await connectStripe(); - } - } catch (error) { - setStripeError( - error instanceof Error - ? error.message - : "Failed to check Stripe status", - ); - setStripeState("error"); - } - }, [connectStripe, onComplete]); - - useEffect(() => { - // Only start checking when this step is active - if (stripeState === "pending") { - checkStripe(); - } - }, [checkStripe, stripeState]); - - // Cleanup: clear poll interval on unmount - useEffect(() => { - return () => { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } - }; - }, []); - - return ( - - - {stripeState === "checking" && ( - - )} - {stripeState === "not_connected" && ( - - )} - {stripeState === "connecting" && ( - - - - {" "} - Complete the setup in your browser, then return here. - - - )} - - {stripeState === "connected" && ( - - )} - - {stripeState === "error" && ( - - )} - - ); -} diff --git a/packages/atmn/src/views/react/products/components/index.ts b/packages/atmn/src/views/react/products/components/index.ts deleted file mode 100644 index dfee379b7..000000000 --- a/packages/atmn/src/views/react/products/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ProductSheet } from "./ProductSheet.js"; -export type { ProductSheetProps } from "./ProductSheet.js"; diff --git a/packages/atmn/src/views/react/products/index.ts b/packages/atmn/src/views/react/products/index.ts deleted file mode 100644 index ae29158dc..000000000 --- a/packages/atmn/src/views/react/products/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { ProductsView } from "./ProductsView.js"; -export type { ProductsViewProps } from "./ProductsView.js"; -export { ProductSheet } from "./components/index.js"; -export type { ProductSheetProps } from "./components/index.js"; diff --git a/packages/atmn/src/views/react/push/index.ts b/packages/atmn/src/views/react/push/index.ts deleted file mode 100644 index d8147a42f..000000000 --- a/packages/atmn/src/views/react/push/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PushView } from "./Push.js"; diff --git a/packages/atmn/src/views/react/template/TemplateSelector.tsx b/packages/atmn/src/views/react/template/TemplateSelector.tsx deleted file mode 100644 index a42e02bf9..000000000 --- a/packages/atmn/src/views/react/template/TemplateSelector.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { Box, Text, useInput } from "ink"; -import React, { useState } from "react"; -import { templateData, templates } from "../../../lib/constants/templates.js"; - -interface TemplateSelectorProps { - onSelect?: (template: string) => void; - onCancel?: () => void; -} - -// Total width of all 3 cards + gaps -const CARDS_TOTAL_WIDTH = 84; - -export const TemplateSelector: React.FC = ({ - onSelect, - onCancel, -}) => { - const [activeIndex, setActiveIndex] = useState(1); // Start on RatGPT (middle) - - const activeTemplate = templates[activeIndex]; - - useInput((_input, key) => { - if (key.tab || key.rightArrow) { - // Next template - setActiveIndex((prev) => (prev + 1) % templates.length); - } else if (key.leftArrow) { - // Previous template - setActiveIndex( - (prev) => (prev - 1 + templates.length) % templates.length, - ); - } else if (key.return) { - // Confirm selection - if (activeTemplate) { - onSelect?.(activeTemplate); - } - } else if (key.escape) { - // Cancel - onCancel?.(); - } - }); - - if (!activeTemplate) { - return No template selected; - } - - const plans = templateData[activeTemplate]; - if (!plans) { - return Invalid template data; - } - - const [plan0, plan1, plan2] = plans; - if (!plan0 || !plan1 || !plan2) { - return Incomplete plan data; - } - - return ( - - {/* Template Tabs - single box spanning all cards */} - - - {templates.map((template: string, idx: number) => ( - - {idx > 0 && } - - {" "} - {template}{" "} - - - ))} - - - - {/* Plan Cards - 3 columns, centered within fixed width */} - - - {/* Left Card */} - - - {plan0.name} - - - {plan0.features.map((feature: string) => ( - - • {feature} - - ))} - - - {plan0.price} - - - - - {/* Center Card */} - - - {plan1.name} - - {plan1.badge && ( - - {plan1.badge} - - )} - - {plan1.features.map((feature: string) => ( - - • {feature} - - ))} - - - {plan1.price} - - - - - {/* Right Card */} - - - {plan2.name} - - - {plan2.features.map((feature: string) => ( - - • {feature} - - ))} - - - {plan2.price} - - - - - - - {/* Hint for controls */} - - - ← → switch templates • Enter to confirm • Esc to cancel - - - - ); -}; diff --git a/packages/atmn/src/views/react/template2/index.ts b/packages/atmn/src/views/react/template2/index.ts deleted file mode 100644 index 34f3d1340..000000000 --- a/packages/atmn/src/views/react/template2/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { Badge } from "./Badge.js"; -export { - type Template, - type TemplateBadge, - type TemplateCreditCost, - type TemplateTier, - templates, -} from "./data.js"; -export { PlanCard } from "./PlanCard.js"; -export { TemplateRow } from "./TemplateRow.js"; -export { TemplateSelector2 } from "./TemplateSelector2.js"; diff --git a/packages/atmn/src/views/react/test-agent.tsx b/packages/atmn/src/views/react/test-agent.tsx deleted file mode 100644 index 1b47d135f..000000000 --- a/packages/atmn/src/views/react/test-agent.tsx +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -import { render } from "ink"; -import { AgentStep } from "./init/steps/AgentStep.js"; - -// Test the AgentStep component standalone -function TestAgentStep() { - return ( - { - console.log("Agent setup complete!"); - process.exit(0); - }} - /> - ); -} - -render(); diff --git a/packages/autumn-js/src/utils/ErrorResponse.ts b/packages/autumn-js/src/utils/ErrorResponse.ts deleted file mode 100644 index 63cf61ff8..000000000 --- a/packages/autumn-js/src/utils/ErrorResponse.ts +++ /dev/null @@ -1,33 +0,0 @@ -// export interface ErrorResponse { -// message: string; -// code: string; -// } - -export class ErrorResponse extends Error { - public readonly message: string; - public readonly code: string; - - constructor(response: { message: string; code: string }) { - super(response.message); - this.message = response.message; - this.code = response.code; - } - - static fromError(error: any) { - return new ErrorResponse({ - message: error.message || "Unknown error", - code: error.code || "unknown_error", - }); - } - - toString() { - return `${this.message} (code: ${this.code})`; - } - - toJSON() { - return { - message: this.message, - code: this.code, - }; - } -} diff --git a/packages/autumn-js/src/utils/handleFetchResult.ts b/packages/autumn-js/src/utils/handleFetchResult.ts deleted file mode 100644 index cc493b24b..000000000 --- a/packages/autumn-js/src/utils/handleFetchResult.ts +++ /dev/null @@ -1,50 +0,0 @@ -export const handleFetchResult = async ({ - response, - logger, - logError = true, -}: { - response: Response; - logger: Console; - logError?: boolean; -}): Promise => { - if (response.status === 204) { - return null; - } - - if (response.status < 200 || response.status >= 300) { - let error: any; - try { - error = await response.json(); - if (logError) { - logger.error(`[Autumn] ${error.message}`); - } - - throw error; - } catch (error) { - // biome-ignore lint/complexity/noUselessCatch: idk - throw error; - } - - // return { - // data: null, - // error: new AutumnError({ - // message: error.message, - // code: error.code, - // }), - // statusCode: response.status, - // }; - } - - try { - const data = await response.json(); - - return data; - // return { - // data: data, - // error: null, - // statusCode: response?.status, - // }; - } catch (error) { - return null; - } -}; diff --git a/packages/autumn-js/src/utils/logger.ts b/packages/autumn-js/src/utils/logger.ts deleted file mode 100644 index 5098bb194..000000000 --- a/packages/autumn-js/src/utils/logger.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Alternative: Use pino without worker thread transport -const getTime = () => { - const timeString = new Date().toISOString(); - return `[${timeString.split("T")[1].split(".")[0]}]`; -}; - -const greaterThanLevel = (level: string) => { - return levels.indexOf(level) >= levels.indexOf(logger.level); -}; - -const levels = ["debug", "info", "warn", "error", "fatal"]; - -export const logger = { - ...console, - level: "info", - debug: (...args: any[]) => { - if (greaterThanLevel("debug")) { - console.log(getTime(), "DEBUG", ...args); - } - }, - log: (...args: any[]) => { - console.log(getTime(), "INFO", ...args); - }, - info: (...args: any[]) => { - if (greaterThanLevel("info")) { - console.log(getTime(), "INFO", ...args); - } - }, - warn: (...args: any[]) => { - if (greaterThanLevel("warn")) { - console.log(getTime(), "WARN", ...args); - } - }, - error: (...args: any[]) => { - if (greaterThanLevel("error")) { - console.log(getTime(), "ERROR", ...args); - } - }, -}; -// export const logger = -// typeof window !== "undefined" -// ? console -// : pino( -// { -// level: process.env.LOG_LEVEL || "info", -// formatters: { -// level: (label: string) => { -// return { level: label }; -// }, -// }, -// }, -// pretty({ -// customColors: { -// default: "white", -// 60: "bgRed", -// 50: "red", -// 40: "yellow", -// 30: "green", -// 20: "blue", -// 10: "gray", -// message: "reset", -// greyMessage: "gray", -// time: "darkGray", -// }, -// ignore: "pid,hostname", -// }) -// ); diff --git a/packages/autumn-js/src/utils/toSnakeCase.ts b/packages/autumn-js/src/utils/toSnakeCase.ts deleted file mode 100644 index 25c3a6d9c..000000000 --- a/packages/autumn-js/src/utils/toSnakeCase.ts +++ /dev/null @@ -1,53 +0,0 @@ -function stringToSnakeCase(str: string): string { - return str - .replace(/([a-z])([A-Z])/g, "$1_$2") - .replace(/[-\s]+/g, "_") - .toLowerCase(); -} - -export const toSnakeCase = ({ - obj, - excludeKeys, - excludeChildrenOf, -}: { - obj: T; - excludeKeys?: string[]; - excludeChildrenOf?: string[]; -}): T => { - if (Array.isArray(obj)) { - return obj.map((item) => - toSnakeCase({ - obj: item as unknown as T, - excludeKeys, - excludeChildrenOf, - }), - ) as T; - } else if (obj !== null && typeof obj === "object") { - return Object.fromEntries( - Object.entries(obj).map(([key, value]) => { - const snakeKey = stringToSnakeCase(key); - - // If this key is in excludeKeys, leave key and value untouched - if (excludeKeys?.includes(key)) { - return [key, value]; - } - - // If this key is in excludeChildrenOf, convert key but do not recurse into value - if (excludeChildrenOf?.includes(key)) { - return [snakeKey, value]; - } - - // Otherwise, convert key and recursively process value - return [ - snakeKey, - toSnakeCase({ - obj: value as unknown as T, - excludeKeys, - excludeChildrenOf, - }), - ]; - }), - ) as T; - } - return obj as T; -}; diff --git a/packages/autumn-js/tsup.dev.config.ts b/packages/autumn-js/tsup.dev.config.ts deleted file mode 100644 index bcb60aa15..000000000 --- a/packages/autumn-js/tsup.dev.config.ts +++ /dev/null @@ -1,83 +0,0 @@ -import alias from "esbuild-plugin-path-alias"; -import * as path from "path"; -import { defineConfig, type Options } from "tsup"; - -// Path aliases that match tsconfig.json -const pathAliases = { - "@": path.resolve("./src/libraries/react"), - "@sdk": path.resolve("./src/sdk"), -}; - -const reactConfigs: Options[] = [ - // Backend - { - entry: ["src/libraries/backend/**/*.{ts,tsx}"], - format: ["cjs", "esm"], - dts: true, - clean: false, // Don't clean on subsequent builds - outDir: "./dist/libraries/backend", - external: ["react", "react/jsx-runtime", "react-dom"], - bundle: true, - esbuildOptions(options) { - options.plugins = options.plugins || []; - options.plugins.push(alias(pathAliases)); - }, - }, - - // React - { - entry: ["src/libraries/react/**/*.{ts,tsx}"], - format: ["cjs", "esm"], - dts: false, - clean: false, - outDir: "./dist/libraries/react", - external: ["react", "react/jsx-runtime", "react-dom"], - bundle: true, - banner: { - js: '"use client";', - }, - esbuildOptions(options) { - options.plugins = options.plugins || []; - options.plugins.push(alias(pathAliases)); - }, - }, -]; - -export default defineConfig([ - { - format: ["cjs", "esm"], - entry: ["./src/sdk/index.ts"], - skipNodeModulesBundle: true, - dts: false, - shims: true, - clean: false, - outDir: "./dist/sdk", - splitting: false, - - treeshake: true, - target: "es2020", - - esbuildOptions(options) { - options.plugins = options.plugins || []; - options.plugins.push(alias(pathAliases)); - options.mainFields = ["module", "main"]; - }, - }, - - // GLOBAL - { - entry: ["src/utils/*.{ts,tsx}"], - format: ["cjs", "esm"], - dts: false, - clean: true, - bundle: true, - outDir: "./dist/utils", // Fixed wildcard path to specific directory - external: ["react", "react/jsx-runtime", "react-dom"], - esbuildOptions(options) { - options.plugins = options.plugins || []; - options.plugins.push(alias(pathAliases)); - }, - }, - - ...reactConfigs, -]); diff --git a/packages/openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts b/packages/openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts deleted file mode 100644 index 973eae779..000000000 --- a/packages/openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - CreateBalanceParamsV0Schema, - SuccessResponseSchema, - xCodeSamplesLegacy, -} from "@autumn/shared"; -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -export const balancesOpenApi: ZodOpenApiPathsObject = { - "/balances/create": { - post: { - summary: "Create Balance", - description: - "Create a new balance for a specific feature for a customer.", - tags: ["balances"], - requestBody: { - content: { - "application/json": { - schema: CreateBalanceParamsV0Schema, - }, - }, - }, - responses: { - "200": { - description: "Balance created successfully", - content: { - "application/json": { schema: SuccessResponseSchema }, - }, - }, - }, - - "x-codeSamples": xCodeSamplesLegacy({ - methodPath: "balances.create", - example: { - customer_id: "cus_123", - feature_id: "api_tokens", - granted_balance: 100, - reset: { - interval: "month", - }, - }, - }), - }, - }, -}; diff --git a/packages/openapi/prevVersions/openapi1.2/coreOpenApi.ts b/packages/openapi/prevVersions/openapi1.2/coreOpenApi.ts deleted file mode 100644 index 49606a7a9..000000000 --- a/packages/openapi/prevVersions/openapi1.2/coreOpenApi.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - CheckResponseV1Schema, - ExtCheckParamsSchema, - setUsageJsDoc, - SetUsageParamsSchema, - SuccessResponseSchema, - TrackParamsSchema, - TrackResponseV1Schema, -} from "@autumn/shared"; -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -export const coreOpenApi: ZodOpenApiPathsObject = { - "/track": { - post: { - summary: "Track Event", - // description: trackJsDoc, - tags: ["core"], - requestBody: { - content: { - "application/json": { schema: TrackParamsSchema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: TrackResponseV1Schema } }, - }, - }, - }, - }, - "/check": { - post: { - summary: "Check Feature Access", - tags: ["core"], - requestBody: { - content: { - "application/json": { schema: ExtCheckParamsSchema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: CheckResponseV1Schema } }, - }, - }, - }, - }, - "/usage": { - post: { - summary: "Set Usage", - description: setUsageJsDoc, - tags: ["core"], - requestBody: { - content: { - "application/json": { schema: SetUsageParamsSchema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: SuccessResponseSchema } }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/prevVersions/openapi1.2/customersOpenApi.ts b/packages/openapi/prevVersions/openapi1.2/customersOpenApi.ts deleted file mode 100644 index 8ba6acf83..000000000 --- a/packages/openapi/prevVersions/openapi1.2/customersOpenApi.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { UpdateBalancesParamsSchema } from "@api/balances/prevVersions/legacyUpdateBalanceModels.js"; -import { SuccessResponseSchema } from "@api/common/commonResponses.js"; -import { queryStringArray } from "@api/common/queryHelpers.js"; -import { - BillingPortalParamsSchema, - BillingPortalResultSchema, -} from "@api/core/coreOpModels.js"; -import { CustomerExpandEnum } from "@api/customers/components/customerExpand/customerExpand.js"; -import { CreateCustomerParamsV0Schema } from "@api/customers/crud/createCustomerParams.js"; -import { UpdateCustomerParamsV0Schema } from "@api/customers/crud/updateCustomerParams.js"; -import { - ListCustomersQuerySchema, - ListCustomersResponseSchema, -} from "@api/customers/customerOpModels.js"; -import { - API_CUSTOMER_V3_EXAMPLE, - ApiCustomerV3Schema, -} from "@api/customers/previousVersions/apiCustomerV3.js"; -import { z } from "zod/v4"; - -export const ApiCustomerWithMeta = ApiCustomerV3Schema.meta({ - id: "Customer", - example: API_CUSTOMER_V3_EXAMPLE, -}); - -export const customersOpenApi = { - "/customers": { - get: { - summary: "List Customers", - tags: ["customers"], - requestParams: { - query: ListCustomersQuerySchema, - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: ListCustomersResponseSchema.extend({ - list: z.array( - ApiCustomerWithMeta.omit({ - entities: true, - invoices: true, - trials_used: true, - referrals: true, - payment_method: true, - }), - ), - }).meta({ - examples: [ - { - list: [API_CUSTOMER_V3_EXAMPLE], - total: 1, - total_count: 100, - total_filtered_count: 100, - limit: 10, - offset: 0, - }, - ], - }), - }, - }, - }, - }, - }, - post: { - summary: "Create Customer", - tags: ["customers"], - requestParams: { - query: z.object({ - expand: queryStringArray(CustomerExpandEnum).optional(), - }), - }, - requestBody: { - content: { - "application/json": { schema: CreateCustomerParamsV0Schema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiCustomerWithMeta } }, - }, - }, - }, - }, - - "/customers/{customer_id}": { - get: { - summary: "Get Customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string().meta({ - description: "The ID of the customer.", - }), - }), - query: z.object({ - expand: queryStringArray(CustomerExpandEnum).optional(), - }), - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiCustomerWithMeta } }, - }, - }, - }, - post: { - summary: "Update Customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - }, - requestBody: { - content: { - "application/json": { schema: UpdateCustomerParamsV0Schema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiCustomerWithMeta } }, - }, - }, - }, - delete: { - summary: "Delete Customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - query: z.object({ - delete_in_stripe: z.boolean().default(false).meta({ - description: - "Whether to delete the customer and cancel all existing subscriptions in Stripe.", - }), - }), - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: SuccessResponseSchema, - }, - }, - }, - }, - }, - }, - "/customers/{customer_id}/billing_portal": { - post: { - summary: "Get Billing Portal URL", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - }, - requestBody: { - content: { - "application/json": { schema: BillingPortalParamsSchema }, - }, - }, - responses: { - "200": { - description: "", - content: { - "application/json": { schema: BillingPortalResultSchema }, - }, - }, - }, - }, - }, - "/customers/{customer_id}/balances": { - post: { - summary: "Set Feature Balances", - description: "Set the balance of a feature for a specific customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - }, - requestBody: { - content: { - "application/json": { schema: UpdateBalancesParamsSchema }, - }, - }, - responses: { - "200": { - description: "", - content: { - "application/json": { schema: SuccessResponseSchema }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/prevVersions/openapi1.2/entitiesOpenApi.ts b/packages/openapi/prevVersions/openapi1.2/entitiesOpenApi.ts deleted file mode 100644 index e0826f935..000000000 --- a/packages/openapi/prevVersions/openapi1.2/entitiesOpenApi.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { SuccessResponseSchema } from "@api/common"; -import { CreateEntityParamsV0Schema } from "@api/entities/crud/createEntityParams"; -import { - API_ENTITY_V0_EXAMPLE, - ApiEntityV0Schema, - queryStringArray, -} from "@autumn/shared"; -import { EntityExpandV0 } from "@models/cusModels/entityModels/entityExpand.js"; -import { z } from "zod/v4"; - -// Note: The meta with id is added in openapi.ts to avoid duplicate registration -// This schema is exported through the main index and should not have an id here -export const ApiEntityWithMeta = ApiEntityV0Schema.meta({ - id: "Entity", - example: API_ENTITY_V0_EXAMPLE, -}); - -export const entitiesOpenApi = { - "/customers/{customer_id}/entities": { - post: { - summary: "Create Entity", - tags: ["entities"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - }, - requestBody: { - content: { - "application/json": { schema: CreateEntityParamsV0Schema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiEntityWithMeta } }, - }, - }, - }, - }, - "/customers/{customer_id}/entities/{entity_id}": { - get: { - summary: "Get Entity", - tags: ["entities"], - requestParams: { - path: z.object({ - customer_id: z.string(), - entity_id: z.string(), - }), - query: z.object({ - expand: queryStringArray(z.enum(EntityExpandV0)).optional(), - }), - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiEntityWithMeta } }, - }, - }, - }, - delete: { - summary: "Delete Entity", - tags: ["entities"], - requestParams: { - path: z.object({ - customer_id: z.string(), - entity_id: z.string(), - }), - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: SuccessResponseSchema, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/prevVersions/openapi1.2/eventsOpenApi.ts b/packages/openapi/prevVersions/openapi1.2/eventsOpenApi.ts deleted file mode 100644 index e5985d652..000000000 --- a/packages/openapi/prevVersions/openapi1.2/eventsOpenApi.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - ApiEventsListParamsSchema, - ApiEventsListResponseSchema, - EVENTS_AGGREGATE_EXAMPLE_V0, - EVENTS_LIST_EXAMPLE, - EventsAggregateResponseV0Schema, - ExtEventsAggregateParamsSchema, -} from "@autumn/shared"; -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -export const eventsOpenApi: ZodOpenApiPathsObject = { - "/events/list": { - post: { - summary: "List Events", - tags: ["events"], - requestBody: { - content: { - "application/json": { - schema: ApiEventsListParamsSchema, - }, - }, - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: ApiEventsListResponseSchema.meta({ - example: EVENTS_LIST_EXAMPLE, - }), - }, - }, - }, - }, - }, - }, - "/events/aggregate": { - post: { - summary: "Aggregate Events", - tags: ["events"], - requestBody: { - content: { - "application/json": { - schema: ExtEventsAggregateParamsSchema, - }, - }, - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: EventsAggregateResponseV0Schema.meta({ - example: EVENTS_AGGREGATE_EXAMPLE_V0, - }), - }, - }, - }, - }, - }, - }, - - // LEGACY - "/query": { - post: { - summary: "Query Analytics Aggregation", - tags: ["analytics"], - requestBody: { - content: { - "application/json": { - schema: ExtEventsAggregateParamsSchema, - }, - }, - }, - responses: { - "200": { - description: "Analytics aggregation results", - content: { - "application/json": { - schema: EventsAggregateResponseV0Schema, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/prevVersions/openapi1.2/featuresOpenApi.ts b/packages/openapi/prevVersions/openapi1.2/featuresOpenApi.ts deleted file mode 100644 index ef59fffb9..000000000 --- a/packages/openapi/prevVersions/openapi1.2/featuresOpenApi.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - ApiFeatureV0Schema, - CreateFeatureV0ParamsSchema, - FEATURE_EXAMPLE, - getListResponseSchema, - SuccessResponseSchema, - UpdateFeatureV0ParamsSchema, -} from "@autumn/shared"; -import { z } from "zod/v4"; - -export const ApiFeatureWithMeta = ApiFeatureV0Schema.extend({ - type: z.enum(["boolean", "single_use", "continuous_use", "credit_system"]), -}).meta({ - id: "Feature", - examples: [FEATURE_EXAMPLE], -}); - -export const featuresOpenApi = { - "/features": { - get: { - summary: "List Features", - tags: ["features"], - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: getListResponseSchema({ schema: ApiFeatureWithMeta }), - }, - }, - }, - }, - }, - post: { - summary: "Create Feature", - tags: ["features"], - requestBody: { - content: { - "application/json": { schema: CreateFeatureV0ParamsSchema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiFeatureWithMeta } }, - }, - }, - }, - }, - "/features/{feature_id}": { - get: { - summary: "Get Feature", - tags: ["features"], - requestParams: { - path: z.object({ - feature_id: z.string(), - }), - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiFeatureWithMeta } }, - }, - }, - }, - post: { - summary: "Update Feature", - tags: ["features"], - requestParams: { - path: z.object({ - feature_id: z.string(), - }), - }, - requestBody: { - content: { - "application/json": { schema: UpdateFeatureV0ParamsSchema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiFeatureWithMeta } }, - }, - }, - }, - delete: { - summary: "Delete Feature", - tags: ["features"], - requestParams: { - path: z.object({ - feature_id: z.string(), - }), - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: SuccessResponseSchema, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/prevVersions/openapi1.2/openapi1.2.0.ts b/packages/openapi/prevVersions/openapi1.2/openapi1.2.0.ts deleted file mode 100644 index f2e981d66..000000000 --- a/packages/openapi/prevVersions/openapi1.2/openapi1.2.0.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { - ApiCusFeatureV3Schema, - ApiCusProductV3Schema, - ApiProductItemV0Schema, - CustomerDataSchema, - EntityDataSchema, -} from "@autumn/shared"; -import yaml from "yaml"; -import { createDocument } from "zod-openapi"; -import { balancesOpenApi } from "./balancesOpenApi1.2.0.js"; -import { coreOpenApi } from "./coreOpenApi.js"; -import { ApiCustomerWithMeta, customersOpenApi } from "./customersOpenApi.js"; -import { ApiEntityWithMeta, entitiesOpenApi } from "./entitiesOpenApi.js"; -import { eventsOpenApi } from "./eventsOpenApi.js"; -import { ApiFeatureWithMeta, featuresOpenApi } from "./featuresOpenApi.js"; -import { ApiProductWithMeta, productsOpenApi } from "./productsOpenApi.js"; -import { referralsOpenApi } from "./referralsOpenApi.js"; - -// Register schema with .meta() for OpenAPI spec generation - -const OPENAPI_1_2_0 = createDocument( - { - openapi: "3.1.0", - info: { - title: "Autumn API", - version: "1.2.0", - }, - - servers: [ - { - url: "https://api.useautumn.com/v1", - description: "Production server", - }, - ], - - security: [ - { - secretKey: [], - }, - ], - components: { - schemas: { - CustomerData: CustomerDataSchema, - EntityData: EntityDataSchema.meta({ - id: "EntityData", - description: "Entity data for creating an entity", - }), - Customer: ApiCustomerWithMeta, - CustomerProduct: ApiCusProductV3Schema, - CustomerFeature: ApiCusFeatureV3Schema.meta({ - id: "CustomerFeature", - description: "Customer feature object returned by the API", - }), - Product: ApiProductWithMeta, - ProductItem: ApiProductItemV0Schema, - Feature: ApiFeatureWithMeta, - Entity: ApiEntityWithMeta, - }, - parameters: { - XApiVersion: { - name: "x-api-version", - in: "header", - required: true, - schema: { - type: "string", - enum: ["2.0"], - }, - }, - }, - securitySchemes: { - secretKey: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - }, - }, - }, - "x-speakeasy-globals": { - parameters: [ - { - $ref: "#/components/parameters/XApiVersion", - "x-speakeasy-globals-hidden": true, - }, - ], - }, - - paths: { - ...productsOpenApi, - ...featuresOpenApi, - ...coreOpenApi, - ...customersOpenApi, - ...entitiesOpenApi, - ...eventsOpenApi, - ...balancesOpenApi, - ...referralsOpenApi, - }, - }, - { - // Disable the "Output" suffix that zod-openapi adds to response schemas - outputIdSuffix: "", - }, -); - -export const writeOpenApi_1_2_0 = ({ - outputFilePath, -}: { - outputFilePath: string; -}) => { - const yamlContent = yaml.stringify( - JSON.parse(JSON.stringify(OPENAPI_1_2_0, null, 2)), - ); - writeFileSync(outputFilePath, yamlContent, "utf8"); -}; diff --git a/packages/openapi/prevVersions/openapi1.2/productsOpenApi.ts b/packages/openapi/prevVersions/openapi1.2/productsOpenApi.ts deleted file mode 100644 index 5b814f9dc..000000000 --- a/packages/openapi/prevVersions/openapi1.2/productsOpenApi.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - ApiProductSchema, - CreateProductV2ParamsSchema, - PRODUCT_EXAMPLE, - SuccessResponseSchema, - UpdateProductV2ParamsSchema, -} from "@autumn/shared"; -import { z } from "zod/v4"; - -// Register schema with .meta() for OpenAPI spec generation -export const ApiProductWithMeta = ApiProductSchema.meta({ - id: "Product", - examples: [PRODUCT_EXAMPLE], -}); - -export const productsOpenApi = { - "/products": { - get: { - summary: "List Products", - tags: ["products"], - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: z.object({ - list: z.array(ApiProductWithMeta), - }), - }, - }, - }, - }, - }, - post: { - summary: "Create Product", - tags: ["products"], - requestBody: { - content: { - "application/json": { schema: CreateProductV2ParamsSchema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiProductWithMeta } }, - }, - }, - }, - }, - "/products/{product_id}": { - get: { - summary: "Get Product", - tags: ["products"], - requestParams: { - path: z.object({ - product_id: z.string(), - }), - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiProductWithMeta } }, - }, - }, - }, - post: { - summary: "Update Product", - tags: ["products"], - requestParams: { - path: z.object({ - product_id: z.string(), - }), - }, - requestBody: { - content: { - "application/json": { schema: UpdateProductV2ParamsSchema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiProductWithMeta } }, - }, - }, - }, - delete: { - summary: "Delete Product", - tags: ["products"], - requestParams: { - path: z.object({ - product_id: z.string(), - }), - query: z.object({ - all_versions: z.boolean().optional(), - }), - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: SuccessResponseSchema, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/prevVersions/openapi1.2/referralsOpenApi.ts b/packages/openapi/prevVersions/openapi1.2/referralsOpenApi.ts deleted file mode 100644 index f2c8c37e5..000000000 --- a/packages/openapi/prevVersions/openapi1.2/referralsOpenApi.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - CreateReferralCodeParamsSchema, - CreateReferralCodeResponseSchema, - RedeemReferralCodeParamsSchema, - RedeemReferralCodeResponseSchema, -} from "@autumn/shared"; -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -const ReferralCodeSchema = CreateReferralCodeResponseSchema.meta({ - id: "ReferralCode", - description: "Referral code object returned by the API", -}); - -const RedeemReferralCodeResponseSchemaWithMeta = - RedeemReferralCodeResponseSchema.meta({ - id: "RedeemReferralCodeResponse", - description: "Redemption response object returned by the API", - }); - -export const referralsOpenApi: ZodOpenApiPathsObject = { - "/referrals/code": { - post: { - summary: "Create a referral code", - tags: ["referrals"], - requestBody: { - content: { - "application/json": { schema: CreateReferralCodeParamsSchema }, - }, - }, - responses: { - "200": { - description: "Referral code generated successfully", - content: { - "application/json": { - schema: ReferralCodeSchema, - }, - }, - }, - }, - }, - }, - "/referrals/redeem": { - post: { - summary: "Redeem a referral code", - tags: ["referrals"], - requestBody: { - content: { - "application/json": { schema: RedeemReferralCodeParamsSchema }, - }, - }, - responses: { - "200": { - description: "Referral code redeemed successfully", - content: { - "application/json": { - schema: RedeemReferralCodeResponseSchemaWithMeta, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/v2.0/balancesOpenApi.ts b/packages/openapi/v2.0/balancesOpenApi.ts deleted file mode 100644 index cce79d450..000000000 --- a/packages/openapi/v2.0/balancesOpenApi.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - CreateBalanceParamsV0Schema, - ExtUpdateBalanceParamsV0Schema, - SuccessResponseSchema, -} from "@autumn/shared"; -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -export const balancesOpenApi: ZodOpenApiPathsObject = { - "/balances/update": { - post: { - summary: "Update Balance", - description: - "Update or set the balance or usage for a specific feature for a customer. Either current_balance or usage must be provided, but not both.", - tags: ["balances"], - requestBody: { - content: { - "application/json": { schema: ExtUpdateBalanceParamsV0Schema }, - }, - }, - responses: { - "200": { - description: "Balance updated successfully", - content: { - "application/json": { schema: SuccessResponseSchema }, - }, - }, - }, - }, - }, - "/balances/create": { - post: { - summary: "Create Balance", - description: - "Create a new balance for a specific feature for a customer.", - tags: ["balances"], - requestBody: { - content: { - "application/json": { schema: CreateBalanceParamsV0Schema }, - }, - }, - responses: { - "200": { - description: "Balance created successfully", - content: { - "application/json": { schema: SuccessResponseSchema }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/v2.0/coreOpenApi.ts b/packages/openapi/v2.0/coreOpenApi.ts deleted file mode 100644 index 24a365394..000000000 --- a/packages/openapi/v2.0/coreOpenApi.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { - attachJsDoc, - billingPortalJsDoc, - cancelJsDoc, - checkoutJsDoc, - queryJsDoc, - setupPaymentJsDoc, -} from "@api/common/jsDocs.js"; -import { - GetBillingPortalBodySchema, - GetBillingPortalResponseSchema, -} from "@api/customers/customerOpModels.js"; -import { - AttachBodyV0Schema, - AttachResponseV1Schema, - CancelBodySchema, - CancelResultSchema, - CheckoutParamsV0Schema, - CheckoutResponseV0Schema, - CheckResponseV2Schema, - ExtCheckParamsSchema, - QueryParamsSchema, - QueryResultSchema, - SetupPaymentParamsV0Schema, - SetupPaymentResponseV0Schema, - TrackParamsSchema, - TrackResponseV2Schema, -} from "@api/models.js"; -import { z } from "zod/v4"; -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -export const coreOps: ZodOpenApiPathsObject = { - "/attach": { - post: { - summary: "Attach Product", - description: attachJsDoc, - - tags: ["core"], - requestBody: { - content: { - "application/json": { - schema: AttachBodyV0Schema, - examples: { - basic: { - summary: "Attach a product immediately", - description: - "Enable a product for a customer with immediate activation", - value: { - customer_id: "cus_123", - product_id: "pro_plan", - }, - }, - }, - }, - }, - }, - responses: { - "200": { - description: "Product attached successfully", - content: { - "application/json": { - schema: AttachResponseV1Schema, - }, - }, - }, - }, - }, - }, - "/checkout": { - post: { - summary: "Checkout", - description: checkoutJsDoc, - tags: ["core"], - requestBody: { - content: { "application/json": { schema: CheckoutParamsV0Schema } }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: CheckoutResponseV0Schema } }, - }, - }, - }, - }, - "/cancel": { - post: { - summary: "Cancel Product", - description: cancelJsDoc, - tags: ["core"], - requestBody: { - content: { - "application/json": { schema: CancelBodySchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: CancelResultSchema } }, - }, - }, - }, - }, - "/track": { - post: { - summary: "Track Event", - // description: trackJsDoc, - tags: ["core"], - requestBody: { - content: { - "application/json": { schema: TrackParamsSchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: TrackResponseV2Schema } }, - }, - }, - }, - }, - "/query": { - post: { - summary: "Query Analytics", - description: queryJsDoc, - tags: ["core"], - requestBody: { - content: { - "application/json": { schema: QueryParamsSchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: QueryResultSchema } }, - }, - }, - }, - }, - "/check": { - post: { - summary: "Check Feature Access", - description: checkoutJsDoc, - tags: ["core"], - requestBody: { - content: { - "application/json": { schema: ExtCheckParamsSchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: CheckResponseV2Schema } }, - }, - }, - }, - }, - "/setup_payment": { - post: { - summary: "Setup Payment Method", - description: setupPaymentJsDoc, - tags: ["core"], - requestBody: { - content: { - "application/json": { schema: SetupPaymentParamsV0Schema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: SetupPaymentResponseV0Schema } }, - }, - }, - }, - }, - - "/customers/{customer_id}/billing_portal": { - post: { - summary: "Create Billing Portal Session", - description: billingPortalJsDoc, - tags: ["core"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - // query: GetBillingPortalQuerySchema, - }, - requestBody: { - content: { - "application/json": { - schema: GetBillingPortalBodySchema, - }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { - schema: GetBillingPortalResponseSchema, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/v2.0/customersOpenApi.ts b/packages/openapi/v2.0/customersOpenApi.ts deleted file mode 100644 index 9c05fc85d..000000000 --- a/packages/openapi/v2.0/customersOpenApi.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - ApiCustomerSchema, - BaseApiCustomerSchema, - CreateCustomerParamsV0Schema, - CreateCustomerQuerySchema, - createPagePaginatedResponseSchema, - GetCustomerQuerySchema, - ListCustomersV2ParamsSchema, - SuccessResponseSchema, - UpdateCustomerParamsV0Schema, -} from "@autumn/shared"; -import { z } from "zod/v4"; - -export const customersOpenApi = { - "/customers": { - post: { - summary: "Create Customer", - tags: ["customers"], - requestParams: { - query: CreateCustomerQuerySchema, - }, - requestBody: { - content: { - "application/json": { schema: CreateCustomerParamsV0Schema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: ApiCustomerSchema } }, - }, - }, - }, - }, - "/customers/list": { - post: { - summary: "List Customers", - tags: ["customers"], - requestBody: { - content: { - "application/json": { schema: ListCustomersV2ParamsSchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { - schema: createPagePaginatedResponseSchema( - BaseApiCustomerSchema, - true, - ), - }, - }, - }, - }, - }, - }, - "/customers/{customer_id}": { - get: { - summary: "Get Customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - query: GetCustomerQuerySchema, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: ApiCustomerSchema } }, - }, - }, - }, - post: { - summary: "Update Customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - query: z.object({ - expand: z.string().optional(), - }), - }, - requestBody: { - content: { - "application/json": { schema: UpdateCustomerParamsV0Schema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: ApiCustomerSchema } }, - }, - }, - }, - delete: { - summary: "Delete Customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { - schema: SuccessResponseSchema, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/v2.0/entitiesOpenApi.ts b/packages/openapi/v2.0/entitiesOpenApi.ts deleted file mode 100644 index 301b7f07f..000000000 --- a/packages/openapi/v2.0/entitiesOpenApi.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { SuccessResponseSchema } from "@api/common"; -import { CreateEntityParamsV0Schema } from "@api/entities/crud/createEntityParams"; -import { ApiEntitySchema, queryStringArray } from "@autumn/shared"; -import { EntityExpand } from "@models/cusModels/entityModels/entityExpand.js"; -import { z } from "zod/v4"; - -// Note: The meta with id is added in openapi.ts to avoid duplicate registration -// This schema is exported through the main index and should not have an id here -export const ApiEntityWithMeta = ApiEntitySchema.meta({ - id: "Entity", - // examples: [ENTITY_EXAMPLE], -}); - -export const entitiesOpenApi = { - "/customers/{customer_id}/entities": { - post: { - summary: "Create Entity", - tags: ["entities"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - }, - requestBody: { - content: { - "application/json": { schema: CreateEntityParamsV0Schema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: ApiEntityWithMeta } }, - }, - }, - }, - }, - "/customers/{customer_id}/entities/{entity_id}": { - get: { - summary: "Get Entity", - tags: ["entities"], - requestParams: { - path: z.object({ - customer_id: z.string(), - entity_id: z.string(), - }), - query: z.object({ - expand: queryStringArray(z.enum(EntityExpand)).optional(), - }), - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: ApiEntityWithMeta } }, - }, - }, - }, - delete: { - summary: "Delete Entity", - tags: ["entities"], - requestParams: { - path: z.object({ - customer_id: z.string(), - entity_id: z.string(), - }), - }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { - schema: SuccessResponseSchema, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/v2.0/eventsOpenApi.ts b/packages/openapi/v2.0/eventsOpenApi.ts deleted file mode 100644 index af69b5c9a..000000000 --- a/packages/openapi/v2.0/eventsOpenApi.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - ApiEventsListParamsSchema, - ApiEventsListResponseSchema, - EVENTS_AGGREGATE_EXAMPLE_V0, - EVENTS_LIST_EXAMPLE, - EventsAggregateResponseV0Schema, - ExtEventsAggregateParamsSchema, -} from "@autumn/shared"; -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -export const eventsOpenApi: ZodOpenApiPathsObject = { - "/events/list": { - post: { - summary: "List Events", - tags: ["events"], - requestBody: { - content: { - "application/json": { - schema: ApiEventsListParamsSchema, - }, - }, - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: ApiEventsListResponseSchema.meta({ - example: EVENTS_LIST_EXAMPLE, - }), - }, - }, - }, - }, - }, - }, - "/events/aggregate": { - post: { - summary: "Aggregate Events", - tags: ["events"], - requestBody: { - content: { - "application/json": { - schema: ExtEventsAggregateParamsSchema, - }, - }, - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: EventsAggregateResponseV0Schema.meta({ - example: EVENTS_AGGREGATE_EXAMPLE_V0, - }), - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/v2.0/openapi2.0.ts b/packages/openapi/v2.0/openapi2.0.ts deleted file mode 100644 index e474cc5cb..000000000 --- a/packages/openapi/v2.0/openapi2.0.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { - ApiCustomerSchema, - ApiPlanItemV0WithMeta, - BaseApiCustomerSchema, - CustomerDataSchema, - EntityDataSchema, -} from "@autumn/shared"; -import yaml from "yaml"; -import { createDocument } from "zod-openapi"; -import { balancesOpenApi } from "./balancesOpenApi.js"; -import { coreOps } from "./coreOpenApi.js"; -import { customersOpenApi } from "./customersOpenApi.js"; -import { ApiEntityWithMeta, entitiesOpenApi } from "./entitiesOpenApi.js"; -import { eventsOpenApi } from "./eventsOpenApi.js"; -import { ApiPlanWithMeta, plansOpenApi } from "./plansOpenApi.js"; -import { referralOps } from "./referralsOpenApi.js"; - -const openapi2_0 = createDocument( - { - openapi: "3.1.0", - info: { - title: "Autumn API", - version: "2.0.0", - }, - - servers: [ - { - url: "https://api.useautumn.com/v1", - description: "Production server", - }, - ], - - security: [ - { - secretKey: [], - }, - ], - components: { - schemas: { - CustomerData: CustomerDataSchema, - EntityData: EntityDataSchema, - Plan: ApiPlanWithMeta, - PlanFeature: ApiPlanItemV0WithMeta, - Customer: ApiCustomerSchema, - BaseCustomer: BaseApiCustomerSchema, - Entity: ApiEntityWithMeta, - }, - parameters: { - XApiVersion: { - name: "x-api-version", - in: "header", - required: true, - schema: { - type: "string", - default: "2.0", - }, - }, - }, - securitySchemes: { - secretKey: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - }, - }, - }, - "x-speakeasy-globals": { - parameters: [ - { - $ref: "#/components/parameters/XApiVersion", - "x-speakeasy-globals-hidden": true, - }, - ], - }, - - paths: { - ...plansOpenApi, - ...customersOpenApi, - ...entitiesOpenApi, - ...referralOps, - ...coreOps, - ...balancesOpenApi, - ...eventsOpenApi, - }, - }, - { - // Disable the "Output" suffix that zod-openapi adds to response schemas - outputIdSuffix: "", - }, -); - -const injectGlobalHeaderParameters = ({ - openApiDocument, -}: { - openApiDocument: Record; -}) => { - const methods = [ - "get", - "put", - "post", - "delete", - "patch", - "head", - "options", - "trace", - ]; - const headerParamRef = "#/components/parameters/XApiVersion"; - - const paths = (openApiDocument.paths ?? {}) as Record; - for (const pathItem of Object.values(paths)) { - if (!pathItem || typeof pathItem !== "object") continue; - for (const method of methods) { - const operation = (pathItem as Record)[method]; - if (!operation || typeof operation !== "object") continue; - if (!operation) continue; - - const operationRecord = operation as Record; - const parameters = Array.isArray(operationRecord.parameters) - ? [...operationRecord.parameters] - : []; - - const hasXApiVersionParam = parameters.some((parameter) => { - if (!parameter || typeof parameter !== "object") return false; - const parameterRecord = parameter as Record; - if (parameterRecord.$ref === headerParamRef) return true; - return ( - parameterRecord.in === "header" && - parameterRecord.name === "x-api-version" - ); - }); - - if (!hasXApiVersionParam) { - parameters.unshift({ $ref: headerParamRef }); - } - - operationRecord.parameters = parameters; - } - } -}; - -export const writeOpenApi_2_0_0 = ({ - outputFilePath, -}: { - outputFilePath: string; -}) => { - const openApiDocument = JSON.parse(JSON.stringify(openapi2_0, null, 2)); - injectGlobalHeaderParameters({ openApiDocument }); - const yamlContent = yaml.stringify(openApiDocument); - writeFileSync(outputFilePath, yamlContent, "utf8"); -}; diff --git a/packages/openapi/v2.0/plansOpenApi.ts b/packages/openapi/v2.0/plansOpenApi.ts deleted file mode 100644 index 4fa494f6d..000000000 --- a/packages/openapi/v2.0/plansOpenApi.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { - ApiPlanV0Schema, - CreatePlanParamsV1Schema, - ListPlansQuerySchema, - SuccessResponseSchema, - UpdatePlanParamsV1Schema, -} from "@autumn/shared"; -import { z } from "zod/v4"; - -export const ApiPlanWithMeta = ApiPlanV0Schema.meta({ - id: "Plan", - // examples: [PLAN_EXAMPLE], -}); - -export const plansOpenApi = { - "/plans": { - get: { - summary: "List Plans", - tags: ["plans"], - requestParams: { - query: ListPlansQuerySchema, - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: z.object({ - list: z.array(ApiPlanWithMeta), - }), - }, - }, - }, - }, - }, - post: { - summary: "Create Product", - tags: ["products"], - requestBody: { - content: { - "application/json": { schema: CreatePlanParamsV1Schema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiPlanWithMeta } }, - }, - }, - }, - }, - "/plans/{plan_id}": { - get: { - summary: "Get Plan", - tags: ["plans"], - requestParams: { - path: z.object({ - plan_id: z.string(), - }), - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiPlanWithMeta } }, - }, - }, - }, - post: { - summary: "Update Plan", - tags: ["plans"], - requestParams: { - path: z.object({ - plan_id: z.string(), - }), - }, - requestBody: { - content: { - "application/json": { schema: UpdatePlanParamsV1Schema }, - }, - }, - responses: { - "200": { - description: "", - content: { "application/json": { schema: ApiPlanWithMeta } }, - }, - }, - }, - delete: { - summary: "Delete Plan", - tags: ["plans"], - requestParams: { - path: z.object({ - plan_id: z.string(), - }), - query: z.object({ - all_versions: z.boolean().optional(), - }), - }, - responses: { - "200": { - description: "", - content: { - "application/json": { - schema: SuccessResponseSchema, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/v2.0/referralsOpenApi.ts b/packages/openapi/v2.0/referralsOpenApi.ts deleted file mode 100644 index 80d3a1eaa..000000000 --- a/packages/openapi/v2.0/referralsOpenApi.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - CreateReferralCodeParamsSchema, - CreateReferralCodeResponseSchema, - RedeemReferralCodeParamsSchema, - RedeemReferralCodeResponseSchema, -} from "@autumn/shared"; -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -const ReferralCodeSchema = CreateReferralCodeResponseSchema.meta({ - id: "ReferralCode", - description: "Referral code object returned by the API", -}); - -const RedeemReferralCodeResponseSchemaWithMeta = - RedeemReferralCodeResponseSchema.meta({ - id: "RedeemReferralCodeResponse", - description: "Redemption response object returned by the API", - }); - -export const referralOps: ZodOpenApiPathsObject = { - "/referrals/code": { - post: { - summary: "Create a referral code", - tags: ["referrals"], - requestBody: { - content: { - "application/json": { schema: CreateReferralCodeParamsSchema }, - }, - }, - responses: { - "200": { - description: "Referral code generated successfully", - content: { - "application/json": { - schema: ReferralCodeSchema, - }, - }, - }, - }, - }, - }, - "/referrals/redeem": { - post: { - summary: "Redeem a referral code", - tags: ["referrals"], - requestBody: { - content: { - "application/json": { schema: RedeemReferralCodeParamsSchema }, - }, - }, - responses: { - "200": { - description: "Referral code redeemed successfully", - content: { - "application/json": { - schema: RedeemReferralCodeResponseSchemaWithMeta, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/openapi/v2.1/contracts/attachContract.ts b/packages/openapi/v2.1/contracts/attachContract.ts deleted file mode 100644 index 9f623011c..000000000 --- a/packages/openapi/v2.1/contracts/attachContract.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AttachParamsV0Schema } from "@api/billing/attachV2/attachParamsV0.js"; -import { BillingResponseSchema } from "@api/billing/common/billingResponse.js"; -import { oc } from "@orpc/contract"; - -export const attachContract = oc - .route({ - method: "POST", - path: "/v1/attach", - operationId: "attach", - tags: ["billing"], - }) - .input(AttachParamsV0Schema.omit({ customer_id: true, customer_data: true })) - .output(BillingResponseSchema); diff --git a/server/src/external/autumn/autumnCliV2.ts b/server/src/external/autumn/autumnCliV2.ts deleted file mode 100644 index 28f64bafa..000000000 --- a/server/src/external/autumn/autumnCliV2.ts +++ /dev/null @@ -1,542 +0,0 @@ -/** biome-ignore-all lint/suspicious/noExplicitAny: AutumnCliV2 is used for internal testing & scripts */ -import dotenv from "dotenv"; - -dotenv.config(); - -import { - type ApiCustomerV3, - type ApiEntityBillingControlsParams, - type AttachBodyV0, - type CancelBody, - type CheckoutParams, - type CheckoutResponseV0, - type CheckParams, - type CheckQuery, - type CheckResponseV1, - type CreateEntityParams, - type CreateRewardProgram, - CustomerExpand, - EntityExpand, - ErrCode, - type OrgConfig, - type RewardRedemption, - type SetUsageParams, - type TrackParams, -} from "@autumn/shared"; - -class AutumnError extends Error { - message: string; - code: string; - - constructor({ message, code }: { message: string; code: string }) { - super(message); - this.message = message; - this.code = code; - } - - toString(): string { - return `${this.message} (code: ${this.code})`; - } -} - -/** - * Robust Autumn API client (V2) with proper version handling - * - * Key improvements over V1: - * - Properly respects x-api-version header for ALL requests - * - No legacy v1Schema params - * - Cleaner error handling - * - Type-safe version parameter - */ -export class AutumnCliV2 { - private apiKey: string; - public headers: Record; - public baseUrl: string; - public version?: string; - - constructor({ - apiKey, - secretKey, - baseUrl, - version, - orgConfig, - liveUrl = false, - }: { - apiKey?: string; - secretKey?: string; - baseUrl?: string; - version?: string; - orgConfig?: Partial; - liveUrl?: boolean; - } = {}) { - this.apiKey = - apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || ""; - - this.headers = { - Authorization: `Bearer ${this.apiKey}`, - "Content-Type": "application/json", - }; - - this.version = version; - - if (version) { - this.headers["x-api-version"] = version; - } - - if (orgConfig) { - this.headers["org-config"] = JSON.stringify(orgConfig); - } - - this.baseUrl = - baseUrl || - (liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1"); - } - - async get(path: string) { - const response = await fetch(`${this.baseUrl}${path}`, { - headers: this.headers, - }); - - if (response.status !== 200) { - let error: any; - try { - error = await response.json(); - } catch (_e) { - throw new AutumnError({ - message: `GET ${path} failed with status ${response.status}`, - code: ErrCode.InternalError, - }); - } - - throw new AutumnError({ - message: error.message || `GET ${path} failed`, - code: error.code || ErrCode.InternalError, - }); - } - - return response.json(); - } - - async post(path: string, body: any) { - const response = await fetch(`${this.baseUrl}${path}`, { - method: "POST", - headers: this.headers, - body: JSON.stringify(body), - }); - - if (response.status !== 200) { - let error: any; - try { - error = await response.json(); - } catch (_e) { - throw new AutumnError({ - message: `POST ${path} failed with status ${response.status}`, - code: ErrCode.InternalError, - }); - } - - throw new AutumnError({ - message: error.message || `POST ${path} failed`, - code: error.code || ErrCode.InternalError, - }); - } - - return response.json(); - } - - async patch(path: string, body: any) { - const response = await fetch(`${this.baseUrl}${path}`, { - method: "PATCH", - headers: this.headers, - body: JSON.stringify(body), - }); - - if (response.status !== 200) { - let error: any; - try { - error = await response.json(); - } catch (_e) { - throw new AutumnError({ - message: `PATCH ${path} failed with status ${response.status}`, - code: ErrCode.InternalError, - }); - } - - throw new AutumnError({ - message: error.message || `PATCH ${path} failed`, - code: error.code || ErrCode.InternalError, - }); - } - - return response.json(); - } - - async delete( - path: string, - { - deleteInStripe = false, - }: { - deleteInStripe?: boolean; - } = {}, - ) { - const queryParams = deleteInStripe ? "?delete_in_stripe=true" : ""; - const response = await fetch(`${this.baseUrl}${path}${queryParams}`, { - method: "DELETE", - headers: this.headers, - }); - - if (response.status !== 200) { - let error: any; - try { - error = await response.json(); - } catch (_e) { - throw new AutumnError({ - message: `DELETE ${path} failed with status ${response.status}`, - code: ErrCode.InternalError, - }); - } - - throw new AutumnError({ - message: error.message || `DELETE ${path} failed`, - code: error.code || ErrCode.InternalError, - }); - } - - return response.json(); - } - - async createCustomer({ - id, - email, - name, - fingerprint, - }: { - id: string; - email: string; - name: string; - fingerprint?: string; - }) { - return await this.post("/customers", { - id, - email, - name, - fingerprint, - }); - } - - async attach(params: AttachBodyV0) { - return await this.post(`/attach`, params); - } - - async checkout( - params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean }, - ) { - const data = await this.post(`/checkout`, params); - return data as CheckoutResponseV0; - } - - async transfer( - customerId: string, - params: { - from_entity_id?: string; - to_entity_id: string; - product_id: string; - }, - ) { - const data = await this.post(`/customers/${customerId}/transfer`, params); - return data; - } - - async sendEvent({ - customerId, - eventName, - properties, - customer_data, - idempotency_key, - }: { - customerId: string; - eventName: string; - properties?: any; - customer_data?: any; - idempotency_key?: string; - }) { - return await this.post(`/events`, { - customer_id: customerId, - event_name: eventName, - properties, - customer_data, - idempotency_key, - }); - } - - async entitled({ - customerId, - featureId, - quantity, - customer_data, - }: { - customerId: string; - featureId: string; - quantity?: number; - customer_data?: any; - }) { - return await this.post(`/entitled`, { - customer_id: customerId, - feature_id: featureId, - quantity, - customer_data, - }); - } - - customers = { - list: async (params?: { limit?: number; offset?: number }) => { - const queryString = params - ? `?${new URLSearchParams(params as Record).toString()}` - : ""; - return await this.get(`/customers${queryString}`); - }, - - get: async ( - customerId: string, - params?: { - expand?: CustomerExpand[]; - }, - ): Promise< - ApiCustomerV3 & { - invoices: any[]; - } - > => { - const queryParams = new URLSearchParams(); - const defaultParams = { - expand: [CustomerExpand.Invoices], - }; - - const finalParams = { ...defaultParams, ...params }; - if (finalParams.expand) { - queryParams.append("expand", finalParams.expand.join(",")); - } - - return await this.get( - `/customers/${customerId}?${queryParams.toString()}`, - ); - }, - - create: async (customer: { id: string; email?: string; name?: string }) => { - return await this.post(`/customers?with_autumn_id=true`, customer); - }, - - delete: async ( - customerId: string, - { - deleteInStripe = false, - }: { - deleteInStripe?: boolean; - } = {}, - ) => { - return await this.delete(`/customers/${customerId}`, { - deleteInStripe, - }); - }, - }; - - entities = { - get: async (customerId: string, entityId: string) => { - return await this.get( - `/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`, - ); - }, - - create: async ( - customerId: string, - entity: CreateEntityParams | CreateEntityParams[], - ) => { - return await this.post( - `/customers/${customerId}/entities?with_autumn_id=true`, - entity, - ); - }, - - list: async (customerId: string) => { - return await this.get(`/customers/${customerId}/entities`); - }, - - delete: async (customerId: string, entityId: string) => { - return await this.delete(`/customers/${customerId}/entities/${entityId}`); - }, - - update: async ( - customerId: string, - entityId: string, - updates: { - billing_controls?: ApiEntityBillingControlsParams; - }, - ) => { - return await this.post(`/entities.update`, { - customer_id: customerId, - entity_id: entityId, - ...updates, - }); - }, - }; - - products = { - /** - * Get product - respects x-api-version header set in constructor - */ - get: async (productId: string) => { - return await this.get(`/products/${productId}`); - }, - - /** - * Create product - respects x-api-version header - */ - create: async (product: any) => { - return await this.post(`/products`, product); - }, - - /** - * Update product - respects x-api-version header - */ - update: async (productId: string, product: any) => { - return await this.post(`/products/${productId}`, product); - }, - - /** - * Delete product - */ - delete: async (productId: string) => { - return await this.delete(`/products/${productId}`); - }, - - /** - * List products - respects x-api-version header - */ - list: async (params?: { limit?: number; offset?: number }) => { - const queryString = params - ? `?${new URLSearchParams(params as Record).toString()}` - : ""; - return await this.get(`/products${queryString}`); - }, - }; - - rewards = { - get: async (rewardId: string) => { - return await this.get(`/rewards/${rewardId}`); - }, - - create: async (reward: any) => { - return await this.post(`/rewards?legacyStripe=true`, reward); - }, - - delete: async (rewardId: string) => { - return await this.delete(`/rewards/${rewardId}`); - }, - }; - - rewardPrograms = { - create: async (rewardProgram: CreateRewardProgram) => { - return await this.post(`/reward_programs`, rewardProgram); - }, - }; - - referrals = { - createCode: async ({ - customerId, - referralId, - }: { - customerId: string; - referralId: string; - }) => { - return await this.post(`/referrals/code`, { - customer_id: customerId, - program_id: referralId, - }); - }, - - redeem: async ({ - customerId, - code, - }: { - customerId: string; - code: string; - }) => { - return await this.post(`/referrals/redeem`, { - customer_id: customerId, - code, - }); - }, - }; - - redemptions = { - get: async (redemptionId: string) => { - const data = await this.get(`/redemptions/${redemptionId}`); - return data as RewardRedemption; - }, - }; - - events = { - send: async ({ - customerId, - featureId, - value, - properties, - }: { - customerId: string; - featureId: string; - value: number; - properties?: any; - }) => { - return await this.post(`/events`, { - customer_id: customerId, - feature_id: featureId, - value, - properties, - }); - }, - }; - - stripe = { - connect: async (params: { - secret_key: string; - success_url: string; - default_currency: string; - }) => { - return await this.post(`/organization/stripe`, params); - }, - - delete: async () => { - return await this.delete(`/organization/stripe`); - }, - }; - - track = async (params: TrackParams) => { - return await this.post(`/track`, params); - }; - - usage = async (params: SetUsageParams) => { - return await this.post(`/usage`, params); - }; - - check = async ( - params: CheckParams & CheckQuery, - ): Promise => { - return await this.post(`/check`, params); - }; - - attachPreview = async (params: AttachBodyV0) => { - return await this.post(`/attach/preview`, params); - }; - - cancel = async (params: CancelBody) => { - return await this.post(`/cancel`, params); - }; - - migrate = async (params: { - from_product_id: string; - to_product_id: string; - from_version: number; - to_version: number; - }) => { - return await this.post(`/migrations`, params); - }; -} diff --git a/server/src/external/redis/loadCaCert.ts b/server/src/external/redis/loadCaCert.ts deleted file mode 100644 index 40e1d4844..000000000 --- a/server/src/external/redis/loadCaCert.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const loadCaCert = async ({ - caPath, - type, - caValue, -}: { - caPath?: string; - type: "queue" | "cache"; - caValue?: string; -}) => { - try { - if (caValue) { - if (caValue.startsWith("-----BEGIN CERTIFICATE-----")) { - return caValue; - } - - return undefined; - } - - const ca = Bun.file(caPath || `/etc/secrets/${type}-cert.pem`); - const caText = await ca.text(); - - return caText; - } catch (_error) { - return; - } -}; diff --git a/server/src/external/redis/redisFailover.ts b/server/src/external/redis/redisFailover.ts deleted file mode 100644 index 5fcf9c670..000000000 --- a/server/src/external/redis/redisFailover.ts +++ /dev/null @@ -1,283 +0,0 @@ -import type { Redis } from "ioredis"; -import { logger } from "@/external/logtail/logtailUtils.js"; - -// ── Config ────────────────────────────────────────────────────────── -/** How long primary must stay down before we switch to failover. */ -const FAILOVER_THRESHOLD_MS = 60_000; - -/** How long primary must stay healthy before we switch back. */ -const RECOVERY_THRESHOLD_MS = 5_000; - -/** Health-check polling interval. */ -const POLL_INTERVAL_MS = 2_000; - -/** Log a warning if blip count exceeds this in the trailing window. */ -const BLIP_WARN_THRESHOLD = 10; - -/** Trailing window for blip counting. */ -const BLIP_WINDOW_MS = 60 * 60 * 1_000; // 1 hour - -// ── State machine ─────────────────────────────────────────────────── -type FailoverPhase = "NORMAL" | "DEGRADED" | "FAILOVER" | "RECOVERING"; - -type FailoverState = { - phase: FailoverPhase; - active: Redis; - primary: Redis; - failover: Redis | null; - failoverRegion: string | null; - /** Timestamp when the current phase was entered. */ - phaseEnteredAt: number; -}; - -let state: FailoverState; -let primaryHasBeenReady = false; -let pollTimer: ReturnType | null = null; - -/** Tracks timestamps of recent transient blips (DEGRADED → NORMAL). */ -const blipTimestamps: number[] = []; - -// ── Callbacks ─────────────────────────────────────────────────────── -type StateChangeCallback = () => void; -const onChangeCallbacks: StateChangeCallback[] = []; - -/** Register a callback invoked whenever `active` changes. */ -export const onActiveChange = (cb: StateChangeCallback): void => { - onChangeCallbacks.push(cb); -}; - -const notifyChange = (): void => { - for (const cb of onChangeCallbacks) { - try { - cb(); - } catch (err) { - logger.error("[Redis failover] onActiveChange callback threw", { - error: err, - }); - } - } -}; - -// ── Helpers ───────────────────────────────────────────────────────── -const isPrimaryReady = (): boolean => state.primary.status === "ready"; -const isFailoverReady = (): boolean => state.failover?.status === "ready"; - -const setPhase = (phase: FailoverPhase): void => { - state.phase = phase; - state.phaseEnteredAt = Date.now(); -}; - -const msInPhase = (): number => Date.now() - state.phaseEnteredAt; - -const pruneBlips = (): void => { - const cutoff = Date.now() - BLIP_WINDOW_MS; - while (blipTimestamps.length > 0 && blipTimestamps[0] < cutoff) { - blipTimestamps.shift(); - } -}; - -const recordBlip = ({ durationMs }: { durationMs: number }): void => { - blipTimestamps.push(Date.now()); - pruneBlips(); - - logger.warn( - `[Redis failover] Primary blip #${blipTimestamps.length} (recovered in ${durationMs}ms)`, - { - type: "redis_failover_blip", - blipCount: blipTimestamps.length, - durationMs, - }, - ); - - if (blipTimestamps.length >= BLIP_WARN_THRESHOLD) { - logger.error( - `[Redis failover] ${blipTimestamps.length} blips in the last hour — check Redis health`, - { - type: "redis_failover_blip_alert", - blipCount: blipTimestamps.length, - }, - ); - } -}; - -// ── Core poll tick ────────────────────────────────────────────────── -const tick = (): void => { - const ready = isPrimaryReady(); - - switch (state.phase) { - case "NORMAL": { - if (!ready && primaryHasBeenReady) { - setPhase("DEGRADED"); - logger.warn("[Redis failover] Primary unhealthy — entering DEGRADED", { - type: "redis_failover_degraded", - primaryStatus: state.primary.status, - }); - } - break; - } - - case "DEGRADED": { - if (ready) { - // Blip — primary recovered before we had to failover - recordBlip({ durationMs: msInPhase() }); - setPhase("NORMAL"); - break; - } - - if (msInPhase() >= FAILOVER_THRESHOLD_MS) { - if (!state.failover || !isFailoverReady()) { - logger.error( - "[Redis failover] Threshold reached but failover instance not ready", - { - type: "redis_failover_switch", - failoverStatus: state.failover?.status ?? "none", - }, - ); - break; - } - - state.active = state.failover; - setPhase("FAILOVER"); - notifyChange(); - - logger.error( - `[Redis failover] SWITCHED to failover region (${state.failoverRegion})`, - { - type: "redis_failover_switch", - failoverRegion: state.failoverRegion, - }, - ); - } - break; - } - - case "FAILOVER": { - if (ready) { - setPhase("RECOVERING"); - logger.info("[Redis failover] Primary back — entering RECOVERING", { - type: "redis_failover_recovering", - }); - } - break; - } - - case "RECOVERING": { - if (!ready) { - // Primary dropped again — go back to failover - setPhase("FAILOVER"); - logger.warn( - "[Redis failover] Primary dropped during recovery — back to FAILOVER", - { type: "redis_failover_recovery_failed" }, - ); - break; - } - - if (msInPhase() >= RECOVERY_THRESHOLD_MS) { - state.active = state.primary; - setPhase("NORMAL"); - notifyChange(); - - logger.info("[Redis failover] RECOVERED to primary region", { - type: "redis_failover_recovered", - }); - } - break; - } - } -}; - -// ── Public API ────────────────────────────────────────────────────── - -/** Initialize failover. Call once after creating both Redis instances. */ -export const initFailover = ({ - primary, - failover, - failoverRegion, - currentRegion, -}: { - primary: Redis; - failover: Redis | null; - failoverRegion: string | null; - currentRegion: string; -}): void => { - state = { - phase: "NORMAL", - active: primary, - primary, - failover, - failoverRegion, - phaseEnteredAt: Date.now(), - }; - - if (!failover) { - logger.info( - "[Redis failover] No failover region configured — failover disabled", - { type: "redis_failover_init" }, - ); - return; - } - - logger.info( - `[Redis failover] Enabled: primary=${currentRegion}, failover=${failoverRegion}`, - { type: "redis_failover_init", currentRegion, failoverRegion }, - ); - - // Track when primary first connects so we don't failover during startup - primary.on("ready", () => { - primaryHasBeenReady = true; - }); - - // Clear any existing state from a previous init - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - primaryHasBeenReady = false; - blipTimestamps.length = 0; - - // Start the single polling loop - pollTimer = setInterval(tick, POLL_INTERVAL_MS); -}; - -/** Get the currently active Redis instance. */ -export const getActiveRedis = (): Redis => state.active; - -/** Get current failover state (for debug/monitoring). */ -export const getFailoverState = (): { - phase: FailoverPhase; - isUsingFailover: boolean; - failoverRegion: string | null; - primaryStatus: string; - failoverStatus: string | null; - msInPhase: number; - blipsLastHour: number; -} => { - pruneBlips(); - return { - phase: state.phase, - isUsingFailover: state.phase === "FAILOVER" || state.phase === "RECOVERING", - failoverRegion: state.failoverRegion, - primaryStatus: state.primary.status, - failoverStatus: state.failover?.status ?? null, - msInPhase: msInPhase(), - blipsLastHour: blipTimestamps.length, - }; -}; - -/** Force disconnect the primary (for testing). */ -export const disconnectPrimary = (): void => { - state.primary.disconnect(); -}; - -/** Force reconnect the primary (for testing). */ -export const reconnectPrimary = (): void => { - state.primary.connect(); -}; - -/** Stop the polling loop (for testing/cleanup). */ -export const stopFailoverPolling = (): void => { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } -}; diff --git a/server/src/external/redis/utils/index.ts b/server/src/external/redis/utils/index.ts deleted file mode 100644 index 471c3ea10..000000000 --- a/server/src/external/redis/utils/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { RedisUnavailableError } from "./errors.js"; -export { - runRedisOp, - tryRedisOp, - type UnavailableReason, -} from "./runRedisOp.js"; -export { withRedisFailOpen } from "./withRedisFailOpen.js"; diff --git a/server/src/external/stripe/checkoutSessions/operations/getExpandedStripeCheckoutSession.ts b/server/src/external/stripe/checkoutSessions/operations/getExpandedStripeCheckoutSession.ts deleted file mode 100644 index 76b3e74b8..000000000 --- a/server/src/external/stripe/checkoutSessions/operations/getExpandedStripeCheckoutSession.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type Stripe from "stripe"; -import { createStripeCli } from "@/external/connect/createStripeCli"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; - -export const getStripeCheckoutSession = async ({ - ctx, - checkoutSessionId, -}: { - ctx: AutumnContext; - checkoutSessionId: string; -}): Promise => { - const { org, env } = ctx; - const stripeCli = createStripeCli({ org, env }); - - return stripeCli.checkout.sessions.retrieve(checkoutSessionId); -}; diff --git a/server/src/external/stripe/subscriptions/subscriptionItems/index.ts b/server/src/external/stripe/subscriptions/subscriptionItems/index.ts deleted file mode 100644 index 9952ae103..000000000 --- a/server/src/external/stripe/subscriptions/subscriptionItems/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { findSubscriptionItemByAutumnPrice } from "@/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice"; - -export const stripeSubscriptionItemUtils = { - find: { - byAutumnPrice: findSubscriptionItemByAutumnPrice, - }, -}; diff --git a/server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts b/server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts deleted file mode 100644 index b2c46d828..000000000 --- a/server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - InternalError, - isFixedPrice, - type Price, - type Product, - type UsagePriceConfig, -} from "@autumn/shared"; - -import type Stripe from "stripe"; - -type FindSubscriptionItemParams = { - stripeSubscriptionItems: Stripe.SubscriptionItem[]; - price: Price; - product: Product; -}; - -// Overload: errorOnNotFound = true → guaranteed SubscriptionItem -export function findSubscriptionItemByAutumnPrice( - params: FindSubscriptionItemParams & { errorOnNotFound: true }, -): Stripe.SubscriptionItem; - -// Overload: errorOnNotFound = false/undefined → SubscriptionItem | undefined -export function findSubscriptionItemByAutumnPrice( - params: FindSubscriptionItemParams & { errorOnNotFound?: false }, -): Stripe.SubscriptionItem | undefined; - -// Implementation -export function findSubscriptionItemByAutumnPrice({ - stripeSubscriptionItems, - price, - product, - errorOnNotFound, -}: FindSubscriptionItemParams & { errorOnNotFound?: boolean }): - | Stripe.SubscriptionItem - | undefined { - const stripeProductId = product.processor?.id; - - let result: Stripe.SubscriptionItem | undefined; - - if (isFixedPrice(price)) { - const config = price.config; - - result = stripeSubscriptionItems.find((si) => { - return ( - config.stripe_price_id === si.price?.id || - (stripeProductId && si.price?.product === stripeProductId) - ); - }); - } else { - const config = price.config as UsagePriceConfig; - result = stripeSubscriptionItems.find( - (si: Stripe.SubscriptionItem | Stripe.LineItem) => { - return ( - config.stripe_price_id === si.price?.id || - config.stripe_product_id === si.price?.product || - config.stripe_empty_price_id === si.price?.id || - config.stripe_prepaid_price_v2_id === si.price?.id - ); - }, - ); - } - - if (errorOnNotFound && !result) { - throw new InternalError({ - message: `Stripe subscription item not found for price: ${price.id}`, - }); - } - - return result; -} diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts deleted file mode 100644 index f946492be..000000000 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { ACTIVE_STATUSES } from "@autumn/shared"; - -import type Stripe from "stripe"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { - submitBillingDataToVercel, - submitInvoiceToVercel, -} from "@/external/vercel/misc/vercelInvoicing.js"; -import { logVercelWebhook } from "@/external/vercel/misc/vercelMiddleware.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import { ProductService } from "@/internal/products/ProductService.js"; -import { stripeInvoiceToStripeSubscriptionId } from "../invoices/utils/convertStripeInvoice"; -import { - getFullStripeInvoice, - getStripeExpandedInvoice, -} from "../stripeInvoiceUtils.js"; -import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js"; - -/** - * Handles invoice.finalized webhook - * - * For regular invoices: Creates Autumn invoice records - * For Vercel custom payment method invoices: Submits invoice to Vercel marketplace for payment processing - */ -export const handleInvoiceFinalized = async ({ - ctx, -}: { - ctx: StripeWebhookContext; -}) => { - const { db, org, env, logger, stripeEvent, stripeCli, fullCustomer } = ctx; - - const invoiceData = stripeEvent.data.object as Stripe.Invoice; - - const invoice = await getFullStripeInvoice({ - stripeCli, - stripeId: invoiceData.id!, - }); - - const features = await FeatureService.list({ - db, - orgId: org.id, - env, - }); - - const subId = stripeInvoiceToStripeSubscriptionId(invoice); - - if (subId) { - const stripeCli = createStripeCli({ org, env }); - // Handle Vercel custom payment method invoices - if (subId && invoice.amount_due > 0) { - const subscription = await stripeCli.subscriptions.retrieve(subId); - - const vercelInstallationId = - subscription.metadata?.vercel_installation_id; - const vercelBillingPlanId = subscription.metadata?.vercel_billing_plan_id; - - if ( - vercelInstallationId && - vercelBillingPlanId && - subscription.default_payment_method - ) { - const paymentMethod = await stripeCli.paymentMethods.retrieve( - subscription.default_payment_method as string, - ); - - // Only process if it's a custom payment method (Vercel) - if (paymentMethod.type === "custom" && fullCustomer) { - logVercelWebhook({ - logger, - org, - event: { - type: "marketplace.invoice.finalized", - id: invoice.id, - }, - }); - - try { - const product = await ProductService.getFull({ - db, - orgId: org.id, - env, - idOrInternalId: vercelBillingPlanId, - }); - - if (!product) { - console.error("Product not found for Vercel billing plan", { - billingPlanId: vercelBillingPlanId, - }); - return; - } - - // Submit billing data to Vercel (detailed usage breakdown) - await submitBillingDataToVercel({ - installationId: vercelInstallationId, - invoice, - customer: fullCustomer, - product, - }); - - // Submit invoice to Vercel - await submitInvoiceToVercel({ - installationId: vercelInstallationId, - invoice, - customer: fullCustomer, - product, - org, - features, - }); - - // Do NOT report payment to Stripe here - we've only submitted the invoice to Vercel - // Vercel will process payment asynchronously and send marketplace.invoice.paid webhook - // handleMarketplaceInvoicePaid will then: - // 1. Create cus_product (user gets access) - // 2. Report payment as "guaranteed" to Stripe - // 3. Attach payment record to invoice (marks it as paid) - } catch (error) { - logger.error("Failed to process Vercel invoice", { - data: { - error: String(error), - invoiceId: invoice.id, - }, - }); - } - } - } - } - const expandedInvoice = await getStripeExpandedInvoice({ - stripeCli, - stripeInvoiceId: invoice.id!, - }); - - const activeProducts = await CusProductService.getByStripeSubId({ - db, - stripeSubId: subId, - orgId: org.id, - env, - inStatuses: ACTIVE_STATUSES, - }); - - if (activeProducts.length === 0) { - return; - } - } -}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts deleted file mode 100644 index 2475ba037..000000000 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { handleStripeInvoiceFinalized } from "./handleStripeInvoiceFinalized"; -export type { InvoiceFinalizedContext } from "./setupInvoiceFinalizedContext"; diff --git a/server/src/internal/billing/attachPreview/attachParamsToChanges.ts b/server/src/internal/billing/attachPreview/attachParamsToChanges.ts deleted file mode 100644 index 6677c7be6..000000000 --- a/server/src/internal/billing/attachPreview/attachParamsToChanges.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type FullCusProduct, isPrepaidPrice } from "@autumn/shared"; - -/** - * Convert cusProduct.options to feature_quantities with actual quantities - * (multiplied by billingUnits for prepaid features) - */ -function cusProductToFeatureQuantities({ - cusProduct, -}: { - cusProduct: FullCusProduct; -}) { - return cusProduct.options.map((option) => { - const cusPrice = cusProduct.customer_prices.find((cp) => { - const cusEnt = cusProduct.customer_entitlements.find( - (ce) => - ce.internal_feature_id === option.internal_feature_id || - ce.entitlement.feature_id === option.feature_id, - ); - return ( - cusEnt && - cp.price.config.internal_feature_id === - cusEnt.entitlement.internal_feature_id - ); - }); - - let quantity = option.quantity; - - if (cusPrice && isPrepaidPrice(cusPrice.price)) { - const billingUnits = cusPrice.price.config.billing_units ?? 1; - quantity = option.quantity * billingUnits; - } - - return { - feature_id: option.feature_id, - quantity, - }; - }); -} diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/buildCreateScheduleExecutionPlan.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/buildCreateScheduleExecutionPlan.ts deleted file mode 100644 index 2d909f8cd..000000000 --- a/server/src/internal/billing/v2/actions/createSchedule/compute/buildCreateScheduleExecutionPlan.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { AutumnBillingPlan } from "@autumn/shared"; -import type { MaterializedScheduledPhase } from "../utils/materializeScheduledPhases"; - -/** Merge immediate billing changes with future scheduled rows for Autumn execution. */ -export const buildCreateScheduleExecutionPlan = ({ - immediateAutumnBillingPlan, - futureScheduledPhases, -}: { - immediateAutumnBillingPlan: AutumnBillingPlan; - futureScheduledPhases: MaterializedScheduledPhase[]; -}): AutumnBillingPlan => ({ - ...immediateAutumnBillingPlan, - insertCustomerProducts: [ - ...immediateAutumnBillingPlan.insertCustomerProducts, - ...futureScheduledPhases.flatMap((phase) => phase.customerProducts), - ], - customPrices: [ - ...(immediateAutumnBillingPlan.customPrices ?? []), - ...futureScheduledPhases.flatMap((phase) => phase.customPrices), - ], - customEntitlements: [ - ...(immediateAutumnBillingPlan.customEntitlements ?? []), - ...futureScheduledPhases.flatMap((phase) => phase.customEntitlements), - ], -}); diff --git a/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts b/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts deleted file mode 100644 index 38ca6fcbf..000000000 --- a/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - addDuration, - BillingVersion, - type CreateScheduleParamsV0, - CusProductStatus, - type FullCustomer, -} from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; -import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; -import { setupAttachProductContext } from "../../attach/setup/setupAttachProductContext"; -import { validateCreateSchedulePhasePlans } from "../errors/validateCreateSchedulePhasePlans"; - -export type MaterializedScheduledPhase = { - starts_at: number; - customerProducts: Awaited>[]; - customPrices: NonNullable< - Awaited>["customPrices"] - >; - customEntitlements: NonNullable< - Awaited>["customEnts"] - >; -}; - -/** Build scheduled customer products for future phases. */ -export const materializeScheduledPhases = async ({ - ctx, - currentEpochMs, - fullCustomer, - phases, -}: { - ctx: AutumnContext; - currentEpochMs: number; - fullCustomer: FullCustomer; - phases: CreateScheduleParamsV0["phases"][number][]; -}): Promise => { - return await Promise.all( - phases.map(async (phase, index) => { - const nextPhaseStartsAt = phases[index + 1]?.starts_at; - const materializedProducts = await Promise.all( - phase.plans.map(async (plan) => { - const { - fullProduct, - customPrices = [], - customEnts: customEntitlements = [], - } = await setupAttachProductContext({ - ctx, - params: plan, - }); - const trialEndsAt = fullProduct.free_trial - ? addDuration({ - now: phase.starts_at, - durationType: fullProduct.free_trial.duration, - durationLength: fullProduct.free_trial.length, - }) - : undefined; - const featureQuantities = setupFeatureQuantitiesContext({ - ctx, - featureQuantitiesParams: { - feature_quantities: plan.feature_quantities, - }, - fullProduct, - initializeUndefinedQuantities: true, - }); - - return { - fullProduct, - customerProduct: initFullCustomerProduct({ - ctx, - initContext: { - fullCustomer, - fullProduct, - featureQuantities, - resetCycleAnchor: phase.starts_at, - freeTrial: fullProduct.free_trial ?? null, - trialEndsAt, - now: currentEpochMs, - billingVersion: BillingVersion.V2, - }, - initOptions: { - startsAt: phase.starts_at, - endedAt: nextPhaseStartsAt, - status: CusProductStatus.Scheduled, - isCustom: - customPrices.length > 0 || customEntitlements.length > 0, - }, - }), - customPrices, - customEntitlements, - }; - }), - ); - validateCreateSchedulePhasePlans({ - fullProducts: materializedProducts.map( - ({ fullProduct }) => fullProduct, - ), - }); - - return { - starts_at: phase.starts_at, - customerProducts: materializedProducts.map( - ({ customerProduct }) => customerProduct, - ), - customPrices: materializedProducts.flatMap( - ({ customPrices }) => customPrices, - ), - customEntitlements: materializedProducts.flatMap( - ({ customEntitlements }) => customEntitlements, - ), - }; - }), - ); -}; diff --git a/server/src/internal/billing/v2/actions/createSchedule/utils/resolveCurrentEpochMs.ts b/server/src/internal/billing/v2/actions/createSchedule/utils/resolveCurrentEpochMs.ts deleted file mode 100644 index fa4587a49..000000000 --- a/server/src/internal/billing/v2/actions/createSchedule/utils/resolveCurrentEpochMs.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getTestClockFrozenTimeMs } from "@/external/stripe/testClocks/utils/convertStripeTestClock"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { CusService } from "@/internal/customers/CusService"; - -/** Resolves "now" for schedule operations, respecting Stripe test clocks in sandbox. */ -export const resolveCurrentEpochMs = async ({ - ctx, - customerId, -}: { - ctx: AutumnContext; - customerId: string; -}): Promise => { - const customer = await CusService.get({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - const testClockMs = await getTestClockFrozenTimeMs({ - ctx, - stripeCustomerId: customer?.processor?.id, - }); - return testClockMs ?? Date.now(); -}; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/calculateUpdateQuantityEntitlementChange.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/calculateUpdateQuantityEntitlementChange.ts deleted file mode 100644 index 6dc53c1c7..000000000 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/calculateUpdateQuantityEntitlementChange.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - customerPriceToBillingUnits, - type FullCustomerEntitlement, - type FullCustomerPrice, - priceToProrationConfig, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; - -/** - * Calculates the entitlement balance change resulting from a quantity update. - * - * Computes balance change as: quantity_difference × billing_units_per_quantity. - * Returns entitlement ID from price association, or undefined if price has no entitlement. - * - * @param quantityDifferenceForEntitlements - Change in quantity (can be negative) - * @param billingUnitsPerQuantity - Multiplier for converting quantities to usage units - * @param customerPrice - Customer's price configuration - * @param customerEntitlements - Array of all entitlements for this customer product - * @returns Entitlement ID and balance change to apply - */ -export const calculateUpdateQuantityEntitlementChange = ({ - quantityDifferenceForEntitlements, - customerPrice, - customerEntitlement, -}: { - quantityDifferenceForEntitlements: number; - customerPrice: FullCustomerPrice; - customerEntitlement: FullCustomerEntitlement; -}): { - customerEntitlementId: string; - customerEntitlementBalanceChange: number; -} => { - const isUpgrade = quantityDifferenceForEntitlements > 0; - - const { shouldApplyProration } = priceToProrationConfig({ - price: customerPrice.price, - isUpgrade, - }); - - // If downgrade and no proration, don't change entitlement balance THIS cycle - if (!isUpgrade && !shouldApplyProration) { - return { - customerEntitlementId: customerEntitlement?.id, - customerEntitlementBalanceChange: 0, - }; - } - - const billingUnits = customerPriceToBillingUnits({ customerPrice }); - const customerEntitlementBalanceChange = new Decimal( - quantityDifferenceForEntitlements, - ) - .mul(billingUnits) - .toNumber(); - - return { - customerEntitlementId: customerEntitlement?.id, - customerEntitlementBalanceChange, - }; -}; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts deleted file mode 100644 index 07b4f5618..000000000 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { AutumnBillingPlan } from "@autumn/shared"; -import { - cusProductToPrices, - ErrCode, - isOneOffPrice, - isPrepaidPrice, - RecaseError, - type UsagePriceConfig, -} from "@autumn/shared"; - -export const handleFeatureQuantityErrors = ({ - autumnBillingPlan, -}: { - autumnBillingPlan: AutumnBillingPlan; -}) => { - const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0]; - if (!newCustomerProduct) return; - - const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct }); - const prepaidPrices = newPrices.filter( - (p) => isPrepaidPrice(p) && !isOneOffPrice(p), - ); - - if (prepaidPrices.length === 0) return; - - const options = newCustomerProduct.options || []; - const missingFeatures: string[] = []; - - for (const price of prepaidPrices) { - const config = price.config as UsagePriceConfig; - const internalFeatureId = config.internal_feature_id; - - // Check if there's an option for this prepaid price - const hasOption = options.some( - (opt) => opt.internal_feature_id === internalFeatureId, - ); - - if (!hasOption) { - // Try to find the feature_id from customer_entitlements - const cusEnt = newCustomerProduct.customer_entitlements?.find( - (ce) => ce.entitlement.internal_feature_id === internalFeatureId, - ); - const featureId = cusEnt?.entitlement.feature_id || internalFeatureId; - missingFeatures.push(featureId); - } - } - - if (missingFeatures.length > 0) { - throw new RecaseError({ - message: `Missing quantity options for prepaid features: ${missingFeatures.join(", ")}`, - code: ErrCode.InvalidOptions, - statusCode: 400, - }); - } -}; diff --git a/server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts b/server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts deleted file mode 100644 index 7d4de5633..000000000 --- a/server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BillingVersion, type FullCustomer } from "@autumn/shared"; -import type Stripe from "stripe"; - -/** Build a minimal BillingContext with just the fields createInvoiceForBilling needs. */ -export const buildMinimalBillingContext = ({ - fullCustomer, - stripeCustomerId, - paymentMethod, -}: { - fullCustomer: FullCustomer; - stripeCustomerId: string; - paymentMethod: Stripe.PaymentMethod; -}) => ({ - fullCustomer, - fullProducts: [], - featureQuantities: [], - currentEpochMs: Date.now(), - billingCycleAnchorMs: "now" as const, - resetCycleAnchorMs: "now" as const, - stripeCustomer: { id: stripeCustomerId } as Stripe.Customer, - paymentMethod, - billingVersion: BillingVersion.V2, -}); diff --git a/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts b/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts deleted file mode 100644 index 034405faf..000000000 --- a/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { BillingContext, BillingPlan } from "@autumn/shared"; -import { - type CheckoutLineV0, - type CheckoutResponseV0, - CheckoutResponseV0Schema, - orgToCurrency, - toProductItem, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { getPriceEntitlement } from "@/internal/products/prices/priceUtils"; -import { - getProductItemResponse, - getProductResponse, -} from "@/internal/products/productUtils/productResponseUtils/getProductResponse"; -import { notNullish } from "@/utils/genUtils"; -import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview"; - -export const billingContextToCheckoutResponse = async ({ - ctx, - billingContext, - billingPlan, -}: { - ctx: AutumnContext; - billingContext: BillingContext; - billingPlan: BillingPlan; -}): Promise => { - const { fullCustomer, fullProducts, featureQuantities } = billingContext; - const { features, org } = ctx; - const currency = orgToCurrency({ org }); - - // 1. Get primary product (first non-add-on or first product) - const mainProduct = fullProducts.find((p) => !p.is_add_on) ?? fullProducts[0]; - - const product = mainProduct - ? await getProductResponse({ - product: mainProduct, - features, - fullCus: fullCustomer, - currency, - db: ctx.db, - options: featureQuantities, - }) - : null; - - // 2. Build line items from billing plan - const planLineItems = billingPlan.autumn.lineItems ?? []; - - // Collect all prices and entitlements from products for lookup - const allPrices = fullProducts.flatMap((p) => p.prices); - const allEnts = fullProducts.flatMap((p) => p.entitlements); - - const lines: CheckoutLineV0[] = planLineItems - .filter((line) => line.chargeImmediately) - .map((line) => { - const { price } = line.context; - - // Find entitlement for this price - const ent = getPriceEntitlement(price, allEnts); - - // Build product item from price + entitlement - const productItem = toProductItem({ ent, price }); - - return { - description: line.description, - amount: line.amountAfterDiscounts, - item: getProductItemResponse({ - item: productItem, - features, - currency, - withDisplay: true, - options: featureQuantities, - }), - }; - }) - .filter(notNullish); - - // 3. Calculate total - const total = new Decimal(lines.reduce((acc, line) => acc + line.amount, 0)) - .toDecimalPlaces(2) - .toNumber(); - - // 4. Get next cycle preview - const { nextCycle } = billingPlanToNextCyclePreview({ - ctx, - billingContext, - billingPlan, - }); - - // 5. Build options from feature quantities - const options = featureQuantities - .map((fq) => { - const price = allPrices.find( - (p) => - p.config && - "feature_id" in p.config && - (p.config.feature_id === fq.feature_id || - p.config.internal_feature_id === fq.internal_feature_id), - ); - - if (!price) return undefined; - - const billingUnits = - price.config && "billing_units" in price.config - ? price.config.billing_units || 1 - : 1; - - return { - feature_id: fq.feature_id, - quantity: fq.quantity * billingUnits, - }; - }) - .filter(notNullish); - - return CheckoutResponseV0Schema.parse({ - customer_id: fullCustomer.id || fullCustomer.internal_id, - product, - current_product: null, - lines, - options, - total, - currency, - next_cycle: nextCycle, - }); -}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNewActiveCustomerProduct.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNewActiveCustomerProduct.ts deleted file mode 100644 index b7e1ab4dc..000000000 --- a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNewActiveCustomerProduct.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { cp } from "@autumn/shared"; -import type { AutumnBillingPlan } from "@autumn/shared"; - -export const billingPlanToNewActiveCustomerProduct = ({ - autumnBillingPlan, -}: { - autumnBillingPlan: AutumnBillingPlan; -}) => { - return autumnBillingPlan.insertCustomerProducts?.find( - (customerProduct) => cp(customerProduct).hasActiveStatus().valid, - ); -}; diff --git a/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts deleted file mode 100644 index 82f0eed45..000000000 --- a/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { - InsertDbInvoiceLineItem, - InvoiceLineItemDiscount, - LineItem, -} from "@autumn/shared"; - -/** - * Helper for full match case - converts an Autumn LineItem to InsertInvoiceLineItem. - */ -export const billingLineItemToInsertDbLineItem = ({ - lineItem, - invoiceId, - stripeInvoiceId, - stripeLineItemId, -}: { - lineItem: LineItem; - invoiceId: string; - stripeInvoiceId: string; - stripeLineItemId?: string; -}): InsertDbInvoiceLineItem => { - const { context } = lineItem; - - return { - id: lineItem.id, - invoice_id: invoiceId, - stripe_id: stripeLineItemId ?? null, - stripe_invoice_id: stripeInvoiceId, - stripe_product_id: lineItem.stripeProductId ?? null, - stripe_price_id: lineItem.stripePriceId ?? null, - stripe_discountable: context.discountable ?? true, - - amount: lineItem.amount, - amount_after_discounts: lineItem.amountAfterDiscounts, - currency: context.currency, - - total_quantity: lineItem.totalQuantity ?? null, - paid_quantity: lineItem.paidQuantity ?? null, - - description: lineItem.description, - direction: context.direction, - billing_timing: context.billingTiming, - prorated: lineItem.prorated, - - price_id: context.price.id, - customer_product_ids: context.customerProduct?.id - ? [context.customerProduct.id] - : [], - customer_price_ids: context.customerPrice?.id - ? [context.customerPrice.id] - : [], - customer_entitlement_ids: context.customerEntitlement?.id - ? [context.customerEntitlement.id] - : [], - internal_product_id: context.product.internal_id, - product_id: context.product.id, - internal_feature_id: context.feature?.internal_id ?? null, - feature_id: context.feature?.id ?? null, - - effective_period_start: context.effectivePeriod?.start ?? null, - effective_period_end: context.effectivePeriod?.end ?? null, - - discounts: lineItem.discounts.map( - (d): InvoiceLineItemDiscount => ({ - amount_off: d.amountOff, - percent_off: d.percentOff, - stripe_coupon_id: d.stripeCouponId, - }), - ), - }; -}; diff --git a/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts b/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts deleted file mode 100644 index 55ba0e96d..000000000 --- a/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { - BillingPreviewResponse, - FullCusProduct, - LineItem, -} from "@autumn/shared"; -import { formatMs } from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { addToExtraLogs } from "@/utils/logging/addToExtraLogs"; -import type { NextCyclePreviewDebug } from "../billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview"; - -const formatCustomerProduct = (customerProduct: FullCusProduct) => - `${customerProduct.product.name} (${customerProduct.product_id}) [${customerProduct.status}]`; - -const formatLineItem = (item: LineItem) => - `${item.description}: ${item.amountAfterDiscounts} (charge: ${item.chargeImmediately})`; - -export const logBillingPreview = ({ - ctx, - allLineItems, - immediateLineItems, - total, - currency, - nextCycleDebug, - nextCycle, -}: { - ctx: AutumnContext; - allLineItems: LineItem[]; - immediateLineItems: LineItem[]; - total: number; - currency: string; - nextCycleDebug: NextCyclePreviewDebug; - nextCycle: BillingPreviewResponse["next_cycle"]; -}) => { - const { - allCustomerProducts, - currentCustomerProducts, - smallestInterval, - anchorMs, - nextCycleStart, - filteredCustomerProducts, - } = nextCycleDebug; - - addToExtraLogs({ - ctx, - extras: { - billingPreview: { - // Immediate charge breakdown - total: `${total} ${currency}`, - allLineItems: - allLineItems.length > 0 ? allLineItems.map(formatLineItem) : "none", - immediateLineItems: - immediateLineItems.length > 0 - ? immediateLineItems.map(formatLineItem) - : "none", - - // Next cycle calculation - nextCycle: { - allCustomerProducts: - allCustomerProducts.map(formatCustomerProduct).join(", ") || "none", - currentCustomerProducts: - currentCustomerProducts.map(formatCustomerProduct).join(", ") || - "none", - smallestInterval: smallestInterval - ? `${smallestInterval.intervalCount} ${smallestInterval.interval}` - : "none (not a subscription)", - anchor: formatMs(anchorMs), - nextCycleStart: nextCycleStart ? formatMs(nextCycleStart) : "n/a", - filteredCustomerProducts: - filteredCustomerProducts.map(formatCustomerProduct).join(", ") || - "none", - result: nextCycle - ? `starts: ${formatMs(nextCycle.starts_at)} | total: ${nextCycle.total} | items: ${nextCycle.line_items.length}` - : "undefined", - }, - }, - }, - }); -}; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts deleted file mode 100644 index 049abb6ed..000000000 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { - type AttachConfig, - calculateProrationAmount, - cusProductToProduct, - customerPriceToCustomerEntitlement, - type Feature, - type FeatureOptions, - type FullCusProduct, - findCusPriceByFeature, - getFeatureInvoiceDescription, - OnDecrease, - priceToInvoiceAmount, - shouldBillNow, - shouldProrate, - type UsagePriceConfig, -} from "@autumn/shared"; -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 { 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 { notNullish } from "@/utils/genUtils.js"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; - -export const handleQuantityDowngrade = async ({ - ctx, - attachParams, - attachConfig, - cusProduct, - stripeSub, - oldOptions, - newOptions, - subItem, -}: { - ctx: AutumnContext; - attachParams: AttachParams; - attachConfig: AttachConfig; - cusProduct: FullCusProduct; - stripeSub: Stripe.Subscription; - oldOptions: FeatureOptions; - newOptions: FeatureOptions; - subItem: Stripe.SubscriptionItem; -}) => { - const { db, logger, org, features } = ctx; - const { stripeCli, paymentMethod } = attachParams; - - const cusPrice = findCusPriceByFeature({ - internalFeatureId: newOptions.internal_feature_id!, - cusPrices: cusProduct.customer_prices, - })!; - - const onDecrease = - cusPrice.price.proration_config?.on_decrease || - OnDecrease.ProrateImmediately; - - const subItemDifference = new Decimal(newOptions.quantity) - .minus( - notNullish(oldOptions.upcoming_quantity) - ? oldOptions.upcoming_quantity! - : oldOptions.quantity, - ) - .toNumber(); - - const billingUnits = - (cusPrice.price.config as UsagePriceConfig).billing_units || 1; - - const newSubItemQuantity = new Decimal(subItem.quantity || 0) - .plus(subItemDifference) - .toNumber(); - - let invoice = null; - const createDowngradeInvoice = async () => { - const { start, end } = subToPeriodStartEnd({ sub: stripeSub }); - - const prevAmount = priceToInvoiceAmount({ - price: cusPrice.price, - quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(), - }); - - const newAmount = priceToInvoiceAmount({ - price: cusPrice.price, - quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(), - }); - - let amount = new Decimal(newAmount).minus(prevAmount).toNumber(); - - amount = calculateProrationAmount({ - periodEnd: end * 1000, - periodStart: start * 1000, - now: attachParams.now || Date.now(), - amount, - allowNegative: true, - }); - - const product = cusProductToProduct({ cusProduct }); - const feature = features.find( - (f: Feature) => f.internal_id === newOptions.internal_feature_id, - )!; - const invoiceItem = constructStripeInvoiceItem({ - ctx, - product, - amount: amount, - price: cusPrice.price, - description: getFeatureInvoiceDescription({ - feature: feature, - usage: newOptions.quantity, - billingUnits: (cusPrice.price.config as UsagePriceConfig).billing_units, - prodName: product.name, - isPrepaid: true, - fromUnix: attachParams.now, - }), - stripeSubId: stripeSub.id, - stripeCustomerId: stripeSub.customer as string, - periodStart: Math.floor( - attachParams.now ? attachParams.now / 1000 : Date.now(), - ), - periodEnd: Math.floor(end * 1000), - }); - - logger.info( - `🔥 Creating downgrade prepaid invoice item: ${invoiceItem.description} - ${amount}`, - ); - - await stripeCli.invoiceItems.create(invoiceItem); - - if (shouldBillNow(onDecrease)) { - const { invoice: finalInvoice } = await createAndFinalizeInvoice({ - stripeCli, - stripeCusId: stripeSub.customer as string, - stripeSubId: stripeSub.id, - paymentMethod: paymentMethod || null, - chargeAutomatically: !attachConfig.invoiceOnly, - logger, - }); - - invoice = finalInvoice; - - try { - const invoiceItems = await getInvoiceItems({ - stripeInvoice: finalInvoice, - prices: [cusPrice.price], - logger, - }); - - await InvoiceService.createInvoiceFromStripe({ - db, - stripeInvoice: finalInvoice, - internalCustomerId: cusProduct.internal_customer_id!, - internalEntityId: cusProduct.internal_entity_id, - productIds: [cusProduct.product_id], - internalProductIds: [cusProduct.internal_product_id], - org, - sendRevenueEvent: true, - items: invoiceItems, - }); - } catch (error) { - logger.error(`Failed to create invoice from stripe: ${error}`); - } - } - }; - - await stripeCli.subscriptionItems.update(subItem.id, { - quantity: Math.max(newSubItemQuantity, 0), - // proration_behavior: stripeProration, - proration_behavior: "none", - }); - - if (!shouldProrate(onDecrease)) { - newOptions.upcoming_quantity = newOptions.quantity; - newOptions.quantity = oldOptions.quantity; - return; - } - - await createDowngradeInvoice(); - - const cusEnt = customerPriceToCustomerEntitlement({ - customerPrice: cusPrice, - customerEntitlements: cusProduct.customer_entitlements, - }); - - if (cusEnt) { - const decrementBy = new Decimal(oldOptions.quantity) - .minus(new Decimal(newOptions.quantity)) - .mul(billingUnits) - .toNumber(); - - await CusEntService.decrement({ - ctx, - id: cusEnt.id, - amount: decrementBy, - }); - } -}; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts deleted file mode 100644 index 236a8f184..000000000 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { - type AttachConfig, - calculateProrationAmount, - cusProductToProduct, - customerPriceToCustomerEntitlement, - type Feature, - type FeatureOptions, - type FullCusProduct, - type FullCustomerPrice, - getFeatureInvoiceDescription, - OnIncrease, - priceToInvoiceAmount, - shouldBillNow, - shouldProrate, - type UsagePriceConfig, -} from "@autumn/shared"; -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 { 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 { notNullish } from "@/utils/genUtils.js"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; - -export const handleQuantityUpgrade = async ({ - ctx, - attachParams, - cusProduct, - stripeSubs, - attachConfig, - oldOptions, - newOptions, - cusPrice, - stripeSub, - subItem, -}: { - ctx: AutumnContext; - attachParams: AttachParams; - cusProduct: FullCusProduct; - attachConfig: AttachConfig; - stripeSubs: Stripe.Subscription[]; - oldOptions: FeatureOptions; - newOptions: FeatureOptions; - cusPrice: FullCustomerPrice; - stripeSub: Stripe.Subscription; - subItem: Stripe.SubscriptionItem; -}) => { - // Manually calculate prorations... - const { features, org, logger, db } = ctx; - const { stripeCli, now, paymentMethod } = attachParams; - - const difference = new Decimal(newOptions.quantity) - .minus(oldOptions.quantity) - .toNumber(); - - const subItemDifference = new Decimal(newOptions.quantity) - .minus( - notNullish(oldOptions.upcoming_quantity) - ? oldOptions.upcoming_quantity! - : oldOptions.quantity, - ) - .toNumber(); - - const onIncrease = - cusPrice.price.proration_config?.on_increase || - OnIncrease.ProrateImmediately; - - const prorate = shouldProrate(onIncrease); - const config = cusPrice.price.config as UsagePriceConfig; - const billingUnits = config.billing_units || 1; - - let invoice = null; - if (prorate && stripeSub?.status !== "trialing") { - const { start, end } = subToPeriodStartEnd({ sub: stripeSub }); - - const prevAmount = priceToInvoiceAmount({ - price: cusPrice.price, - quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(), - }); - - const newAmount = priceToInvoiceAmount({ - price: cusPrice.price, - quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(), - }); - - let amount = new Decimal(newAmount).minus(prevAmount).toNumber(); - if (prorate) { - amount = calculateProrationAmount({ - periodEnd: end * 1000, - periodStart: start * 1000, - now: now || Date.now(), - amount, - }); - } - - const feature = features.find( - (f: Feature) => f.internal_id === newOptions.internal_feature_id, - )!; - - const product = cusProductToProduct({ cusProduct }); - const invoiceItem = constructStripeInvoiceItem({ - ctx, - product, - amount: amount, - price: cusPrice.price, - description: getFeatureInvoiceDescription({ - feature: feature, - usage: newOptions.quantity, - billingUnits, - prodName: product.name, - isPrepaid: true, - fromUnix: now, - }), - stripeSubId: stripeSub.id, - stripeCustomerId: stripeSub.customer as string, - periodStart: Math.floor((now || Date.now()) / 1000), - periodEnd: Math.floor(end * 1000), - }); - - logger.info( - `🔥 Creating prepaid invoice item: ${invoiceItem.description} - ${amount}`, - ); - - await stripeCli.invoiceItems.create(invoiceItem); - - if (shouldBillNow(onIncrease)) { - const { invoice: finalInvoice } = await createAndFinalizeInvoice({ - stripeCli, - stripeCusId: stripeSub.customer as string, - stripeSubId: stripeSub.id, - paymentMethod: paymentMethod || null, - chargeAutomatically: !attachConfig.invoiceOnly, - logger, - }); - - try { - const invoiceItems = await getInvoiceItems({ - stripeInvoice: finalInvoice, - prices: [cusPrice.price], - logger, - }); - - await InvoiceService.createInvoiceFromStripe({ - db, - stripeInvoice: finalInvoice, - internalCustomerId: cusProduct.internal_customer_id!, - internalEntityId: cusProduct.internal_entity_id, - productIds: [cusProduct.product_id], - internalProductIds: [cusProduct.internal_product_id], - org, - sendRevenueEvent: true, - items: invoiceItems, - }); - } catch (error) { - logger.error(`Failed to create invoice from stripe: ${error}`); - } - invoice = finalInvoice; - } - } - - // 1. If no sub item, add - if (!subItem) { - if (!cusPrice.price.config.stripe_price_id) { - throw new Error( - "Trying to add new sub item for upgrade quantity flow, but no Stripe price ID found", - ); - } - - await stripeCli.subscriptionItems.create({ - subscription: stripeSub.id, - price: cusPrice.price.config.stripe_price_id, - quantity: subItemDifference, - proration_behavior: "none", - }); - } else { - await stripeCli.subscriptionItems.update(subItem.id, { - // quantity: newOptions.quantity, - quantity: (subItem.quantity || 0) + subItemDifference, - proration_behavior: "none", - }); - } - - // Update cus ent - - const cusEnt = customerPriceToCustomerEntitlement({ - customerPrice: cusPrice, - customerEntitlements: cusProduct.customer_entitlements, - }); - - if (cusEnt) { - const incrementBy = new Decimal(difference).mul(billingUnits).toNumber(); - logger.info( - `🔥 Incrementing feature ${cusEnt.entitlement.feature.id} balance by ${incrementBy}`, - ); - await CusEntService.increment({ - ctx, - id: cusEnt.id, - amount: incrementBy, - }); - } - return { invoice }; -}; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts deleted file mode 100644 index 1caaf542a..000000000 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - type AttachConfig, - ErrCode, - type FeatureOptions, - type FullCusProduct, - findCusPriceByFeature, -} from "@autumn/shared"; -import type { Stripe } from "stripe"; -import { stripeSubscriptionItemUtils } from "@/external/stripe/subscriptions/subscriptionItems/index.js"; -import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import RecaseError from "@/utils/errorUtils.js"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; -import { handleQuantityDowngrade } from "./handleQuantityDowngrade.js"; -import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js"; - -export const handleUpdateFeatureQuantity = async ({ - ctx, - attachParams, - attachConfig, - cusProduct, - stripeSubs, - oldOptions, - newOptions, -}: { - ctx: AutumnContext; - attachParams: AttachParams; - attachConfig: AttachConfig; - cusProduct: FullCusProduct; - stripeSubs: Stripe.Subscription[]; - oldOptions: FeatureOptions; - newOptions: FeatureOptions; -}) => { - const subToUpdate = stripeSubs?.[0]; - - const cusPrice = findCusPriceByFeature({ - internalFeatureId: newOptions.internal_feature_id!, - cusPrices: cusProduct.customer_prices, - })!; - - const price = cusPrice.price; - - if (!subToUpdate) { - throw new RecaseError({ - message: `Failed to update prepaid quantity for ${newOptions.feature_id} because no subscription found`, - code: ErrCode.InternalError, - statusCode: 500, - }); - } - - const subItem = stripeSubscriptionItemUtils.find.byAutumnPrice({ - stripeSubscriptionItems: subToUpdate.items.data, - price, - product: cusProduct.product, - errorOnNotFound: true, - }); - - if (newOptions.quantity < oldOptions.quantity) { - return await handleQuantityDowngrade({ - ctx, - attachParams, - attachConfig, - cusProduct, - stripeSub: subToUpdate, - oldOptions, - newOptions, - subItem, - }); - } else { - return await handleQuantityUpgrade({ - ctx, - attachParams, - attachConfig, - cusProduct, - stripeSubs, - oldOptions, - newOptions, - cusPrice, - stripeSub: subToUpdate, - subItem, - }); - } -}; diff --git a/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts b/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts deleted file mode 100644 index bb8ceab72..000000000 --- a/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { AttachConfig, FullCusProduct } from "@autumn/shared"; -import type Stripe from "stripe"; -import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import type { AttachParams } from "../../cusProducts/AttachParams.js"; -import { CusProductService } from "../../cusProducts/CusProductService.js"; -import { paramsToScheduleItems } from "./paramsToScheduleItems.js"; -import { getCusProductsToRemove } from "./paramsToSubItems.js"; - -export const subToNewSchedule = async ({ - ctx, - sub, - attachParams, - config, - endOfBillingPeriod, - removeCusProducts, -}: { - ctx: AutumnContext; - sub: Stripe.Subscription; - attachParams: AttachParams; - config: AttachConfig; - endOfBillingPeriod: number; - removeCusProducts?: FullCusProduct[]; -}) => { - const itemSet = await getStripeSubItems2({ - attachParams, - config, - }); - - let cusProductsToRemove: FullCusProduct[] = []; - cusProductsToRemove = getCusProductsToRemove({ - attachParams, - includeCanceled: true, - }); - - console.log( - `REMOVING CUS PRODUCTS: ${cusProductsToRemove.map((cp) => `${cp.product.id} (E: ${cp.entity_id})`).join(", ")}`, - ); - - const res = await paramsToScheduleItems({ - ctx, - sub, - attachParams, - config, - removeCusProducts: removeCusProducts || cusProductsToRemove, - billingPeriodEnd: endOfBillingPeriod, - }); - - const { stripeCli } = attachParams; - let newSchedule: Stripe.SubscriptionSchedule | undefined; - - // if (sub.cancel_at) { - // logger.info(`UNCANCELING SUB ${sub.id}`); - // await stripeCli.subscriptions.update(sub.id, { - // cancel_at: null, - // }); - // } - - // console.log("New phase"); - // await logPhases({ - // phases: res.phases, - // db: req.db, - // }); - // throw new Error("test"); - - if (res.phases[0].items.length > 0) { - itemSet.subItems = res.phases[0].items; - - // Create schedule from existing subscription - newSchedule = await stripeCli.subscriptionSchedules.create({ - from_subscription: sub.id, - }); - - // console.log("SChedule ID: ", newSchedule.id); - - // const newScheduleId = "sub_sched_1RxyM89mx3u0jkgOgbbsAbDS"; - const newScheduleId = newSchedule.id; - await stripeCli.subscriptionSchedules.update(newScheduleId, { - phases: [ - { - items: newSchedule.phases[0].items.map((item) => { - const priceId = item.price as string; - - // Re-apply metadata from subscription items since - // Stripe's from_subscription doesn't copy item metadata - const subItem = sub.items.data.find( - (si) => si.price.id === priceId, - ); - const metadata = - subItem?.metadata && Object.keys(subItem.metadata).length > 0 - ? subItem.metadata - : item.metadata && Object.keys(item.metadata).length > 0 - ? item.metadata - : undefined; - - return { - price: priceId, - quantity: item.quantity, - ...(metadata && { metadata }), - }; - }), - start_date: newSchedule.phases[0].start_date, - end_date: endOfBillingPeriod, - trial_end: sub?.trial_end || undefined, - }, - { - items: res.phases[0].items, - start_date: endOfBillingPeriod, - }, - ], - end_behavior: "release", - }); - - await CusProductService.updateByStripeSubId({ - db: ctx.db, - stripeSubId: sub.id!, - updates: { - scheduled_ids: [newSchedule!.id], - }, - }); - } - - return newSchedule as Stripe.SubscriptionSchedule; -}; - -// phases: ([ -// { -// items: scheduleItems.items, -// // Set proration behavior for this phase transition -// proration_behavior: "create_prorations", // Options: 'create_prorations', 'none', 'always_invoice' -// // Optional: Set how long this phase should last -// iterations: 1, // Number of billing cycles for this phase -// // Optional: Add metadata for this phase -// metadata: { -// phase_type: "scheduled_update", -// created_by: "attach_flow", -// }, -// }, -// ], - -// Option 2: Use your existing createSubSchedule function -// newSchedule = await createSubSchedule({ -// db: req.db, -// attachParams, -// itemSet, -// endOfBillingPeriod, -// }); diff --git a/server/src/internal/customers/cusProducts/cusEnts/repos/customerEntitlementRepo.ts b/server/src/internal/customers/cusProducts/cusEnts/repos/customerEntitlementRepo.ts deleted file mode 100644 index 8cfc9b574..000000000 --- a/server/src/internal/customers/cusProducts/cusEnts/repos/customerEntitlementRepo.ts +++ /dev/null @@ -1 +0,0 @@ -export const customerEntitlementRepo = {}; diff --git a/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments copy.ts b/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments copy.ts deleted file mode 100644 index 0e4062e24..000000000 --- a/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments copy.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { type SQL, sql } from "drizzle-orm"; -import { getEntityOptionsAggregateFragments } from "./getEntityOptionsAggregateFragments.js"; - -/** - * Per-entity rollover rows sourced from the `rollovers` table, mirroring the - * four branches in `entity_balance_rows` (product-attached / loose, per-entity - * jsonb / top-level). Top-level rollovers are attributed to the owning entity - * via `cp.internal_entity_id` (product-attached) or `ce.internal_entity_id` - * (loose), matching main-balance behaviour. Also exposes per-entity and - * per-feature rollups used by the outer aggregate CTEs. - */ -const buildEntityRolloverCtes = ({ - statusFilter, -}: { - statusFilter: SQL; -}) => sql` - entity_rollover_rows AS ( - -- Product-attached cusEnt, per-entity rollover (rollovers.entities jsonb) - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - kv.entity_key AS entity_key, - COALESCE((kv.entity_value->>'balance')::numeric, 0) AS rollover_balance, - COALESCE((kv.entity_value->>'usage')::numeric, 0) AS rollover_usage - FROM rollovers r - JOIN customer_entitlements ce ON r.cus_ent_id = ce.id - JOIN customer_products cp ON ce.customer_product_id = cp.id - CROSS JOIN LATERAL jsonb_each(r.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - AND jsonb_typeof(r.entities) = 'object' - AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ${statusFilter} - - UNION ALL - - -- Product-attached cusEnt, top-level rollover (attributed to cp.internal_entity_id) - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - cp.internal_entity_id AS entity_key, - r.balance::numeric AS rollover_balance, - COALESCE(r.usage, 0)::numeric AS rollover_usage - FROM rollovers r - JOIN customer_entitlements ce ON r.cus_ent_id = ce.id - JOIN customer_products cp ON ce.customer_product_id = cp.id - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ${statusFilter} - - UNION ALL - - -- Loose cusEnt (no customer_product), per-entity rollover - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - kv.entity_key AS entity_key, - COALESCE((kv.entity_value->>'balance')::numeric, 0) AS rollover_balance, - COALESCE((kv.entity_value->>'usage')::numeric, 0) AS rollover_usage - FROM rollovers r - JOIN customer_entitlements ce ON r.cus_ent_id = ce.id - CROSS JOIN LATERAL jsonb_each(r.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND jsonb_typeof(r.entities) = 'object' - AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - - UNION ALL - - -- Loose cusEnt, top-level rollover (attributed to ce.internal_entity_id) - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - ce.internal_entity_id AS entity_key, - r.balance::numeric AS rollover_balance, - COALESCE(r.usage, 0)::numeric AS rollover_usage - FROM rollovers r - JOIN customer_entitlements ce ON r.cus_ent_id = ce.id - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ), - - entity_rollover_keys AS ( - SELECT - internal_feature_id, - internal_customer_id, - entity_key, - SUM(rollover_balance) AS rollover_balance, - SUM(rollover_usage) AS rollover_usage - FROM entity_rollover_rows - WHERE entity_key IS NOT NULL - GROUP BY internal_feature_id, internal_customer_id, entity_key - ), - - entity_rollover_feature AS ( - SELECT - internal_feature_id, - internal_customer_id, - SUM(rollover_balance) AS rollover_balance, - SUM(rollover_usage) AS rollover_usage - FROM entity_rollover_rows - GROUP BY internal_feature_id, internal_customer_id - ) -`; - -/** - * Per-feature/per-entity aggregates sourced strictly from `ce.entities` JSON. - * This powers the `entities` map in customer-level aggregates and intentionally - * excludes top-level entity attribution paths. - */ -const buildEntityEntitiesCtes = ({ - statusFilter, -}: { - statusFilter: SQL; -}) => sql` - entity_entities_rows AS ( - -- Product-attached entitlements: aggregate directly from ce.entities keys - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - kv.entity_key AS entity_key, - COALESCE((kv.entity_value->>'balance')::numeric, 0) AS entity_balance, - COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment, - COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - AND jsonb_typeof(ce.entities) = 'object' - ${statusFilter} - - UNION ALL - - -- Loose entitlements: aggregate directly from ce.entities keys - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - kv.entity_key AS entity_key, - COALESCE((kv.entity_value->>'balance')::numeric, 0) AS entity_balance, - COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment, - COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance - FROM customer_entitlements ce - CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND jsonb_typeof(ce.entities) = 'object' - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ), - - entity_entities_aggregate_keys AS ( - SELECT - internal_feature_id, - internal_customer_id, - entity_key, - SUM(entity_balance) AS balance, - SUM(entity_adjustment) AS adjustment, - SUM(entity_additional_balance) AS additional_balance - FROM entity_entities_rows - WHERE entity_key IS NOT NULL - GROUP BY internal_feature_id, internal_customer_id, entity_key - ) -`; - -export const getEntityAggregateFragments = ({ - entityId, - statusFilter, -}: { - entityId?: string; - statusFilter: SQL; -}) => { - if (entityId) { - return { - ctes: sql``, - productRefsUnion: sql``, - entitlementRefsUnion: sql``, - priceRefsUnion: sql``, - freeTrialRefsUnion: sql``, - selectColumns: sql``, - }; - } - - const entityOptionsAggregateFragments = getEntityOptionsAggregateFragments(); - - const ctes = sql`, - - entity_distinct_product_ids AS ( - SELECT DISTINCT cp.internal_product_id, cp.internal_customer_id - FROM customer_products cp - JOIN subject_customer_records scr - ON cp.internal_customer_id = scr.internal_id - WHERE cp.internal_entity_id IS NOT NULL - ${statusFilter} - ), - - entity_distinct_cus_products AS ( - SELECT sub.* - FROM entity_distinct_product_ids edpi - JOIN LATERAL ( - SELECT cp.* - FROM customer_products cp - WHERE cp.internal_customer_id = edpi.internal_customer_id - AND cp.internal_product_id = edpi.internal_product_id - AND cp.internal_entity_id IS NOT NULL - ${statusFilter} - ORDER BY cp.created_at DESC - LIMIT 1 - ) sub ON true - ), - - entity_cus_products_for_options AS ( - SELECT cp.* - FROM customer_products cp - JOIN subject_customer_records scr - ON cp.internal_customer_id = scr.internal_id - WHERE cp.internal_entity_id IS NOT NULL - ${statusFilter} - ), - - entity_cus_prices AS ( - SELECT cpr.* - FROM customer_prices cpr - WHERE cpr.customer_product_id IN (SELECT id FROM entity_distinct_cus_products) - ), - - ${entityOptionsAggregateFragments.ctes}, - - entity_balance_rows AS ( - SELECT - COALESCE(ce.external_id, ce.id) AS api_id, - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - COALESCE(ent.allowance, 0)::numeric AS allowance, - ce.balance::numeric AS balance, - ce.adjustment::numeric AS adjustment, - COALESCE(ce.additional_balance, 0)::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - cp.internal_entity_id AS entity_key, - ce.balance::numeric AS entity_balance, - COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment, - COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - JOIN entitlements ent ON ce.entitlement_id = ent.id - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - ${statusFilter} - - UNION ALL - - SELECT - COALESCE(ce.external_id, ce.id) AS api_id, - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - COALESCE(ent.allowance, 0)::numeric AS allowance, - 0::numeric AS balance, - 0::numeric AS adjustment, - 0::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - kv.entity_key AS entity_key, - (kv.entity_value->>'balance')::numeric AS entity_balance, - COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment, - COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - JOIN entitlements ent ON ce.entitlement_id = ent.id - CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - AND jsonb_typeof(ce.entities) = 'object' - ${statusFilter} - - UNION ALL - - SELECT - COALESCE(ce.external_id, ce.id) AS api_id, - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - COALESCE(ent.allowance, 0)::numeric AS allowance, - 0::numeric AS balance, - 0::numeric AS adjustment, - 0::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - kv.entity_key AS entity_key, - (kv.entity_value->>'balance')::numeric AS entity_balance, - COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment, - COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance - FROM customer_entitlements ce - JOIN entitlements ent ON ce.entitlement_id = ent.id - CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND jsonb_typeof(ce.entities) = 'object' - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - - UNION ALL - - SELECT - COALESCE(ce.external_id, ce.id) AS api_id, - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - COALESCE(ent.allowance, 0)::numeric AS allowance, - ce.balance::numeric AS balance, - COALESCE(ce.adjustment, 0)::numeric AS adjustment, - COALESCE(ce.additional_balance, 0)::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - ce.internal_entity_id AS entity_key, - ce.balance::numeric AS entity_balance, - COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment, - COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance - FROM customer_entitlements ce - JOIN entitlements ent ON ce.entitlement_id = ent.id - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ), - - ${buildEntityRolloverCtes({ statusFilter })}, - - ${buildEntityEntitiesCtes({ statusFilter })}, - - entity_aggregate_keys AS ( - SELECT - COALESCE(ebk.internal_feature_id, erk.internal_feature_id) AS internal_feature_id, - COALESCE(ebk.internal_customer_id, erk.internal_customer_id) AS internal_customer_id, - COALESCE(ebk.entity_key, erk.entity_key) AS entity_key, - COALESCE(ebk.balance, 0) AS balance, - COALESCE(ebk.adjustment, 0) AS adjustment, - COALESCE(ebk.additional_balance, 0) AS additional_balance, - COALESCE(erk.rollover_balance, 0) AS rollover_balance, - COALESCE(erk.rollover_usage, 0) AS rollover_usage - FROM ( - SELECT - internal_feature_id, - internal_customer_id, - entity_key, - SUM(entity_balance) AS balance, - SUM(entity_adjustment) AS adjustment, - SUM(entity_additional_balance) AS additional_balance - FROM entity_balance_rows - WHERE entity_key IS NOT NULL - GROUP BY internal_feature_id, internal_customer_id, entity_key - ) ebk - FULL OUTER JOIN entity_rollover_keys erk - ON erk.internal_feature_id = ebk.internal_feature_id - AND erk.internal_customer_id = ebk.internal_customer_id - AND erk.entity_key = ebk.entity_key - ), - - entity_aggregate_map AS ( - SELECT - ejk.internal_feature_id, - ejk.internal_customer_id, - jsonb_object_agg( - ejk.entity_key, - jsonb_build_object( - 'id', ejk.entity_key, - 'balance', ejk.balance, - 'adjustment', ejk.adjustment, - 'additional_balance', ejk.additional_balance, - 'rollover_balance', COALESCE(erk.rollover_balance, 0), - 'rollover_usage', COALESCE(erk.rollover_usage, 0) - ) - ) AS entities - FROM entity_entities_aggregate_keys ejk - LEFT JOIN entity_rollover_keys erk - ON erk.internal_feature_id = ejk.internal_feature_id - AND erk.internal_customer_id = ejk.internal_customer_id - AND erk.entity_key = ejk.entity_key - GROUP BY ejk.internal_feature_id, ejk.internal_customer_id - ), - - entity_aggregated_cus_entitlements AS ( - SELECT - MIN(ebr.api_id) AS api_id, - ebr.internal_feature_id, - ebr.internal_customer_id, - MIN(ebr.feature_id) AS feature_id, - SUM(ebr.allowance) AS allowance_total, - COALESCE(MAX(epgo.prepaid_grant_from_options), 0) AS prepaid_grant_from_options, - SUM(ebr.balance) AS balance, - SUM(ebr.adjustment) AS adjustment, - SUM(ebr.additional_balance) AS additional_balance, - COALESCE(MAX(erf.rollover_balance), 0) AS rollover_balance, - COALESCE(MAX(erf.rollover_usage), 0) AS rollover_usage, - BOOL_OR(ebr.unlimited) AS unlimited, - BOOL_OR(ebr.usage_allowed) AS usage_allowed, - COUNT(DISTINCT ebr.entity_key) FILTER (WHERE ebr.entity_key IS NOT NULL) AS entity_count, - eam.entities - FROM entity_balance_rows ebr - LEFT JOIN entity_aggregate_map eam - ON eam.internal_feature_id = ebr.internal_feature_id - AND eam.internal_customer_id = ebr.internal_customer_id - LEFT JOIN entity_rollover_feature erf - ON erf.internal_feature_id = ebr.internal_feature_id - AND erf.internal_customer_id = ebr.internal_customer_id - LEFT JOIN entity_prepaid_grant_from_options epgo - ON epgo.internal_feature_id = ebr.internal_feature_id - AND epgo.internal_customer_id = ebr.internal_customer_id - GROUP BY - ebr.internal_feature_id, - ebr.internal_customer_id, - eam.entities - ) - `; - - const productRefsUnion = sql` - UNION ALL - SELECT ecp.internal_customer_id, ecp.internal_product_id - FROM entity_distinct_cus_products ecp - `; - - const entitlementRefsUnion = sql` - UNION - SELECT DISTINCT - ce.internal_customer_id, - ce.entitlement_id - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - WHERE cp.internal_entity_id IS NOT NULL - ${statusFilter} - - UNION - SELECT DISTINCT - ce.internal_customer_id, - ce.entitlement_id - FROM customer_entitlements ce - WHERE ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - `; - - const priceRefsUnion = sql` - UNION ALL - SELECT ecpr.price_id, ecp.internal_customer_id - FROM entity_cus_prices ecpr - JOIN entity_distinct_cus_products ecp - ON ecp.id = ecpr.customer_product_id - `; - - const freeTrialRefsUnion = sql` - UNION ALL - SELECT ecp.free_trial_id, ecp.internal_customer_id - FROM entity_distinct_cus_products ecp - WHERE ecp.free_trial_id IS NOT NULL - `; - - const selectColumns = sql`, - - json_build_object( - 'aggregated_customer_products', COALESCE( - ( - SELECT json_agg(row_to_json(ecp)) - FROM entity_distinct_cus_products ecp - WHERE ecp.internal_customer_id = scr.internal_id - ), - '[]'::json - ), - 'aggregated_customer_entitlements', COALESCE( - ( - SELECT json_agg(row_to_json(eace)) - FROM entity_aggregated_cus_entitlements eace - WHERE eace.internal_customer_id = scr.internal_id - ), - '[]'::json - ) - ) AS entity_aggregations - `; - - return { - ctes, - productRefsUnion, - entitlementRefsUnion, - priceRefsUnion, - freeTrialRefsUnion, - selectColumns, - }; -}; diff --git a/server/src/internal/entities/actions/updateEntityDbAndCache.ts b/server/src/internal/entities/actions/updateEntityDbAndCache.ts deleted file mode 100644 index a6c5a412e..000000000 --- a/server/src/internal/entities/actions/updateEntityDbAndCache.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Entity } from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { EntityService } from "@/internal/api/entities/EntityService.js"; - -export const updateEntityDbAndCache = async ({ - ctx, - entity, - updates, -}: { - ctx: AutumnContext; - entity: Entity; - updates: Partial< - Pick - >; -}) => { - const filteredUpdates = Object.fromEntries( - Object.entries(updates).filter(([, value]) => value !== undefined), - ) as Partial< - Pick - >; - - if (Object.keys(filteredUpdates).length === 0) { - return entity; - } - - return EntityService.update({ - db: ctx.db, - internalId: entity.internal_id, - update: filteredUpdates, - }); -}; diff --git a/server/src/internal/misc/debug/debugRouter.ts b/server/src/internal/misc/debug/debugRouter.ts deleted file mode 100644 index 91a0e0182..000000000 --- a/server/src/internal/misc/debug/debugRouter.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { sql } from "drizzle-orm"; -import { Hono } from "hono"; -import { dbCritical, dbGeneral } from "@/db/initDrizzle.js"; -import { - forceDegraded, - forceHealthy, - getPgHealthState, -} from "@/db/pgHealthMonitor.js"; -import { redis } from "@/external/redis/initRedis.js"; -import { - disconnectPrimary, - getFailoverState, - reconnectPrimary, -} from "@/external/redis/redisFailover.js"; -import { orgConfigMiddleware } from "@/honoMiddlewares/orgConfigMiddleware.js"; -import { secretKeyMiddleware } from "@/honoMiddlewares/secretKeyMiddleware.js"; -import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; - -const ALLOWED_ORG_IDS = new Set([ - "org_2sWv2S8LJ9iaTjLI6UtNsfL88Kt", - "org_2rzkkRh7r5dBSaBC101QHG9KDgt", - "org_2vwdxwTdqxRrLEdUYddcynMv3n3", -]); - -export const debugRouter = new Hono(); - -debugRouter.use("*", secretKeyMiddleware); -debugRouter.use("*", orgConfigMiddleware); - -debugRouter.get("/memory", async (c) => { - const ctx = c.get("ctx"); - - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - const mem = process.memoryUsage(); - - return c.json({ - ok: true, - pid: process.pid, - timestamp: new Date().toISOString(), - memory: { - rssMB: +(mem.rss / 1024 / 1024).toFixed(1), - heapUsedMB: +(mem.heapUsed / 1024 / 1024).toFixed(1), - heapTotalMB: +(mem.heapTotal / 1024 / 1024).toFixed(1), - externalMB: +(mem.external / 1024 / 1024).toFixed(1), - arrayBuffersMB: +(mem.arrayBuffers / 1024 / 1024).toFixed(1), - }, - }); -}); - -/** Check what statement_timeout the DB connections actually see. */ -debugRouter.get("/statement-timeout", async (c) => { - if (process.env.NODE_ENV === "production") { - return c.json({ error: "Not available in production" }, 403); - } - - const ctx = c.get("ctx"); - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - const criticalResult = await dbCritical.execute(sql`SHOW statement_timeout`); - const generalResult = await dbGeneral.execute(sql`SHOW statement_timeout`); - - return c.json({ - ok: true, - critical: criticalResult[0], - general: generalResult[0], - }); -}); - -/** - * Pool isolation test endpoint. Runs pg_sleep or SELECT 1 on a specific pool. - * Blocked in production. - */ -debugRouter.post("/pool-test", async (c) => { - if (process.env.NODE_ENV === "production") { - return c.json({ error: "Not available in production" }, 403); - } - - const ctx = c.get("ctx"); - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - if (process.env.DATABASE_URL?.includes("us-east-2")) { - return c.json({ error: "Not available against production database" }, 403); - } - - const body = await c.req.json<{ - action: "sleep" | "ping" | "cpu"; - pool: "general" | "critical"; - seconds?: number; - /** Row count for CPU burn (default 5_000_000). Higher = more CPU time. */ - rows?: number; - }>(); - - const { action, pool, seconds = 5, rows = 5_000_000 } = body; - const db = pool === "critical" ? dbCritical : dbGeneral; - const start = Date.now(); - - try { - if (action === "sleep") { - await db.execute(sql`SELECT pg_sleep(${seconds})`); - } else if (action === "cpu") { - // CPU-intensive: hash millions of rows. Burns real CPU on the DB. - await db.execute( - sql`SELECT count(*) FROM generate_series(1, ${rows}) AS s WHERE md5(s::text) IS NOT NULL`, - ); - } else { - await db.execute(sql`SELECT 1`); - } - - return c.json({ - ok: true, - pool, - action, - durationMs: Date.now() - start, - }); - } catch (error) { - return c.json({ - ok: false, - pool, - action, - durationMs: Date.now() - start, - error: error instanceof Error ? error.message : String(error), - }); - } -}); - -/** - * Redis failover test endpoints. Blocked in production. - */ -debugRouter.post("/redis-failover", async (c) => { - if (process.env.NODE_ENV === "production") { - return c.json({ error: "Not available in production" }, 403); - } - - const ctx = c.get("ctx"); - - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - const body = await c.req.json<{ - action: "status" | "kill-primary" | "recover-primary" | "ping"; - }>(); - - if (body.action === "status") { - return c.json({ ok: true, ...getFailoverState() }); - } - - if (body.action === "kill-primary") { - disconnectPrimary(); - return c.json({ ok: true, message: "Primary disconnected" }); - } - - if (body.action === "recover-primary") { - reconnectPrimary(); - return c.json({ ok: true, message: "Primary reconnect triggered" }); - } - - if (body.action === "ping") { - const start = Date.now(); - try { - await redis.ping(); - return c.json({ - ok: true, - durationMs: Date.now() - start, - ...getFailoverState(), - }); - } catch (error) { - return c.json({ - ok: false, - durationMs: Date.now() - start, - error: error instanceof Error ? error.message : String(error), - ...getFailoverState(), - }); - } - } - - return c.json({ error: "Unknown action" }, 400); -}); - -/** - * PG health monitor test endpoints. Blocked in production. - */ -debugRouter.post("/pg-health", async (c) => { - if (process.env.NODE_ENV === "production") { - return c.json({ error: "Not available in production" }, 403); - } - - const ctx = c.get("ctx"); - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - const body = await c.req.json<{ - action: "status" | "force-degraded" | "force-healthy"; - }>(); - - if (body.action === "status") { - return c.json({ ok: true, ...getPgHealthState() }); - } - - if (body.action === "force-degraded") { - forceDegraded(); - return c.json({ - ok: true, - message: "Forced DEGRADED", - ...getPgHealthState(), - }); - } - - if (body.action === "force-healthy") { - forceHealthy(); - return c.json({ - ok: true, - message: "Forced HEALTHY", - ...getPgHealthState(), - }); - } - - return c.json({ error: "Unknown action" }, 400); -}); - -/** Write a V8 heap snapshot to disk. Requires secret key auth + dev-only. */ -// debugRouter.get("/heap-snapshot", async (c) => { -// if (process.env.NODE_ENV === "production") { -// return c.json({ error: "Not available in production" }, 403); -// } - -// const ctx = c.get("ctx"); -// if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { -// return c.json({ error: "Forbidden" }, 403); -// } - -// const snapshotDir = new URL("../../../perf/snapshots/", import.meta.url) -// .pathname; -// const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); -// const filename = `heap-${timestamp}-pid${process.pid}.heapsnapshot`; -// const filepath = `${snapshotDir}${filename}`; - -// writeHeapSnapshot(filepath); - -// return c.json({ ok: true, file: filename, path: filepath }); -// }); diff --git a/server/src/internal/products/planRouter.ts b/server/src/internal/products/planRouter.ts deleted file mode 100644 index fc194853c..000000000 --- a/server/src/internal/products/planRouter.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Hono } from "hono"; -import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js"; -import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js"; -import { handleCreatePlan } from "./handlers/handleCreateProduct/handleCreatePlan.js"; -import { handleCreatePlanV2 } from "./handlers/handleCreateProduct/handleCreatePlanV2.js"; -import { handleDeletePlanV1 } from "./handlers/handleDeletePlan/handleDeletePlanV1.js"; -import { handleDeletePlanV2 } from "./handlers/handleDeletePlan/handleDeletePlanV2.js"; -import { handleGetPlanV1 } from "./handlers/handleGetPlan/handleGetPlanV1.js"; -import { handleGetPlanV2 } from "./handlers/handleGetPlan/handleGetPlanV2.js"; -import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js"; -import { handleListPlansV2 } from "./handlers/handleListPlans/handleListPlansV2.js"; -import { handleListPlans } from "./handlers/handleListPlans.js"; -import { handleMigrateProductV2 } from "./handlers/handleMigrateProductV2.js"; -import { handleUpdatePlanV1 } from "./handlers/handleUpdatePlan/handleUpdatePlanV1.js"; -import { handleUpdatePlanV2 } from "./handlers/handleUpdatePlan/handleUpdatePlanV2.js"; - -export const honoProductBetaRouter = new Hono(); -honoProductBetaRouter.get("", ...handleListPlans); - -// Create a Hono app for products -export const honoProductRouter = new Hono(); -export const migrationRouter = new Hono(); - -// Migrations -migrationRouter.post("/migrations", ...handleMigrateProductV2); - -// CRUD -honoProductRouter.get("", ...handleListPlans); -honoProductRouter.post("", ...handleCreatePlan); -honoProductRouter.get("/:product_id", ...handleGetPlanV1); -honoProductRouter.post("/:product_id", ...handleUpdatePlanV1); // will be deprecated -honoProductRouter.patch("/:product_id", ...handleUpdatePlanV1); // will be deprecated -honoProductRouter.delete("/:product_id", ...handleDeletePlanV1); - -// Others -honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2); - -// Info before deleting plan -honoProductRouter.get( - "/:product_id/has_customers", - ...handlePlanHasCustomersV2, -); -honoProductRouter.post( - "/:product_id/has_customers", - ...handlePlanHasCustomersV2, -); -honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo); - -// RPC -export const plansRpcRouter = new Hono(); -plansRpcRouter.post("/plans.get", ...handleGetPlanV2); -plansRpcRouter.post("/plans.list", ...handleListPlansV2); -plansRpcRouter.post("/plans.create", ...handleCreatePlanV2); -plansRpcRouter.post("/plans.update", ...handleUpdatePlanV2); -plansRpcRouter.post("/plans.delete", ...handleDeletePlanV2); diff --git a/server/src/utils/importUtils/updateUsages.ts b/server/src/utils/importUtils/updateUsages.ts deleted file mode 100644 index a5c9bdba4..000000000 --- a/server/src/utils/importUtils/updateUsages.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - type FullCustomer, - fullCustomerToCustomerEntitlements, -} from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; - -export const updateUsages = async ({ - ctx, - featureId, - usage, - fullCus, -}: { - ctx: AutumnContext; - featureId: string; - usage: number; - fullCus: FullCustomer; -}) => { - const cusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer: fullCus, - inStatuses: RELEVANT_STATUSES, - featureId, - }); - if (cusEnts.length === 0) { - throw new Error(`No cus ent for ${featureId}`); - } - - const cusEnt = cusEnts[0]; - const newBalance = cusEnt.balance! - usage; - - await CusEntService.update({ - ctx, - id: cusEnt.id, - updates: { - balance: newBalance, - }, - }); -}; diff --git a/server/src/utils/scriptUtils/clearOrg.ts b/server/src/utils/scriptUtils/clearOrg.ts deleted file mode 100644 index 85851a970..000000000 --- a/server/src/utils/scriptUtils/clearOrg.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - AppEnv, - customers, - features, - type Organization, - products, -} from "@autumn/shared"; -import { and, eq, inArray } from "drizzle-orm"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; - -export const clearCustomersInBatches = async ({ - db, - org, - batchSize = 450, -}: { - db: DrizzleCli; - org: Organization; - batchSize?: number; -}) => { - let deletedCount = 0; - - while (true) { - // Get a batch of customer IDs to delete - const customerBatch = await db - .select({ internalId: customers.internal_id }) - .from(customers) - .where( - and(eq(customers.org_id, org.id), eq(customers.env, AppEnv.Sandbox)), - ) - .limit(batchSize); - - if (customerBatch.length === 0) { - break; // No more customers to delete - } - - // Delete the batch - const customerIds = customerBatch - .map((c) => c.internalId) - .filter((id) => id !== null); - - console.log("Deleting customers:", customerIds); - - await db - .delete(customers) - .where(inArray(customers.internal_id, customerIds)); - - deletedCount += customerBatch.length; - console.log( - `Deleted ${customerBatch.length} customers (total: ${deletedCount})`, - ); - } - - return deletedCount; -}; - -export const clearOrg = async ({ - db, - org, -}: { - db: DrizzleCli; - org: Organization; -}) => { - const deletedCount = await clearCustomersInBatches({ db, org }); - console.log(`Cleared ${deletedCount} customers`); - - await db - .delete(products) - .where(and(eq(products.org_id, org.id), eq(products.env, AppEnv.Sandbox))); - - console.log("Cleared products"); - - await db - .delete(features) - .where(and(eq(features.org_id, org.id), eq(features.env, AppEnv.Sandbox))); - - console.log("Cleared features"); -}; diff --git a/server/src/utils/scriptUtils/genScriptUtils.ts b/server/src/utils/scriptUtils/genScriptUtils.ts deleted file mode 100644 index b367b5d25..000000000 --- a/server/src/utils/scriptUtils/genScriptUtils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import csv from "csv-parser"; -import fs from "fs"; - -export const parseCsv = ({ - path, - delimiter = ",", -}: { - path: string; - delimiter?: string; -}) => { - return new Promise((resolve, reject) => { - const stream = fs.createReadStream(path); - const results: any[] = []; - const headers: string[] = []; - stream - .pipe(csv({ separator: delimiter })) - .on("data", (data) => { - results.push(data); - // if (headers.length === 0) { - // headers = Object.keys(data); - // } else { - // results.push(data); - // } - }) - .on("end", () => resolve(results)) - .on("error", (error) => reject(error)); - }) as Promise; -}; diff --git a/server/src/utils/scriptUtils/getAll/getAllCusProds.ts b/server/src/utils/scriptUtils/getAll/getAllCusProds.ts deleted file mode 100644 index 6c550394a..000000000 --- a/server/src/utils/scriptUtils/getAll/getAllCusProds.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { AppEnv, type FullCusProduct } from "@autumn/shared"; -import { sql } from "drizzle-orm"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; - -const cusProductsQuery = ({ - lastProductId, - internalProductId, - pageSize = 250, -}: { - lastProductId?: string; - internalProductId?: string; - pageSize?: number; -}) => { - // const withStatusFilter = () => { - // return inStatuses - // ? sql`AND cp.status = ANY(ARRAY[${sql.join( - // inStatuses.map((status) => sql`${status}`), - // sql`, `, - // )}])` - // : sql``; - // }; - - return sql` - SELECT - cp.*, - row_to_json(prod) AS product, - - -- Spread customer_prices fields + add price field - COALESCE( - json_agg(DISTINCT ( - to_jsonb(cpr.*) || jsonb_build_object('price', to_jsonb(p.*)) - )) FILTER (WHERE cpr.id IS NOT NULL), - '[]'::json - ) AS customer_prices, - - -- Spread customer_entitlements fields + add entitlement and replaceables - COALESCE( - json_agg(DISTINCT ( - to_jsonb(ce.*) || jsonb_build_object( - 'entitlement', ( - SELECT row_to_json(ent_with_feature) - FROM ( - SELECT e.*, row_to_json(f) AS feature - FROM entitlements e - JOIN features f ON e.internal_feature_id = f.internal_id - WHERE e.id = ce.entitlement_id - ) AS ent_with_feature - ), - 'replaceables', ( - SELECT COALESCE( - json_agg(row_to_json(r)) FILTER (WHERE r.id IS NOT NULL), - '[]'::json - ) - FROM replaceables r - WHERE r.cus_ent_id = ce.id - ) - ) - )) FILTER (WHERE ce.id IS NOT NULL), - '[]'::json - ) AS customer_entitlements, - - -- free_trial - ( - SELECT row_to_json(ft) - FROM free_trials ft - WHERE ft.id = cp.free_trial_id - ) AS free_trial - - FROM customer_products cp - JOIN products prod ON cp.internal_product_id = prod.internal_id - LEFT JOIN customer_prices cpr ON cpr.customer_product_id = cp.id - LEFT JOIN prices p ON cpr.price_id = p.id - LEFT JOIN customer_entitlements ce ON ce.customer_product_id = cp.id - WHERE cp.internal_product_id = ${internalProductId} - ${lastProductId ? sql`AND cp.id < ${lastProductId}` : sql``} - GROUP BY cp.id, prod.* - ORDER BY cp.id DESC - LIMIT ${pageSize} - `; -}; - -export const getAllFullCusProducts = async ({ - db, - internalProductId, -}: { - db: DrizzleCli; - internalProductId: string; -}) => { - let lastProductId = ""; - const allData: any[] = []; - const pageSize = 500; - - while (true) { - const data = await db.execute( - cusProductsQuery({ - lastProductId, - pageSize, - internalProductId, - }), - ); - - if (data.length === 0) break; - - console.log(`Fetched ${data.length} customer products`); - allData.push(...data); - lastProductId = data[data.length - 1].id as string; - } - - return allData as FullCusProduct[]; -}; diff --git a/server/src/utils/scriptUtils/getAll/getAllStripeSubs.ts b/server/src/utils/scriptUtils/getAll/getAllStripeSubs.ts deleted file mode 100644 index 5bd1c2b0e..000000000 --- a/server/src/utils/scriptUtils/getAll/getAllStripeSubs.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { Stripe } from "stripe"; -import { timeout } from "@/utils/genUtils.js"; - -export const getAllStripeSubscriptions = async ({ - numPages, - limit = 100, - stripeCli, - waitForSeconds, -}: { - numPages?: number; - limit?: number; - stripeCli: Stripe; - waitForSeconds?: number; -}) => { - let hasMore = true; - let startingAfter: string | null = null; - const allSubscriptions: any[] = []; - - let pageCount = 0; - while (hasMore) { - const response: any = await stripeCli.subscriptions.list({ - limit, - starting_after: startingAfter || undefined, - expand: ["data.discounts.coupon"], - }); - - if (response.data.length === 0) { - break; - } - - allSubscriptions.push(...response.data); - - hasMore = response.has_more; - startingAfter = response.data[response.data.length - 1].id; - - pageCount++; - if (numPages && pageCount >= numPages) { - break; - } - - console.log("Fetched", allSubscriptions.length, "subscriptions"); - if (waitForSeconds) { - await timeout(1000); - } - } - - return { - subscriptions: allSubscriptions, - total: allSubscriptions.length, - }; -}; -export const getAllStripeSchedules = async ({ - numPages, - limit = 100, - stripeCli, - waitForSeconds, -}: { - numPages?: number; - limit?: number; - stripeCli: Stripe; - waitForSeconds?: number; -}) => { - let hasMore = true; - let startingAfter: string | null = null; - const allSchedules: any[] = []; - - let pageCount = 0; - while (hasMore) { - const response: any = await stripeCli.subscriptionSchedules.list({ - limit, - starting_after: startingAfter || undefined, - expand: ["data.phases.items.price"], - }); - - if (response.data.length === 0) { - break; - } - - allSchedules.push(...response.data); - - hasMore = response.has_more; - startingAfter = response.data[response.data.length - 1].id; - - pageCount++; - if (numPages && pageCount >= numPages) { - break; - } - - console.log("Fetched", allSchedules.length, "schedules"); - if (waitForSeconds) { - await timeout(1000); - } - } - - return { - schedules: allSchedules, - total: allSchedules.length, - }; -}; diff --git a/server/src/utils/scriptUtils/getAll/getAllUsers.ts b/server/src/utils/scriptUtils/getAll/getAllUsers.ts deleted file mode 100644 index 90feab40c..000000000 --- a/server/src/utils/scriptUtils/getAll/getAllUsers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { user } from "@autumn/shared"; -import { desc } from "drizzle-orm"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; - -export const getAllUsers = async (db: DrizzleCli) => { - const users = []; - let offset = 0; - const limit = 200; - - while (true) { - const batch = await db - .select() - .from(user) - .limit(limit) - .offset(offset) - .orderBy(desc(user.createdAt)); - users.push(...batch); - - if (batch.length < limit) { - break; - } - - offset += limit; - console.log(`Fetched ${users.length} users`); - } - return users; -}; diff --git a/server/src/utils/scriptUtils/logUtils/logSubItems.ts b/server/src/utils/scriptUtils/logUtils/logSubItems.ts deleted file mode 100644 index 07dc09a92..000000000 --- a/server/src/utils/scriptUtils/logUtils/logSubItems.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { stripeToAtmnAmount } from "@autumn/shared"; -import { subItemToAutumnInterval } from "@tests/utils/stripeUtils"; -import type Stripe from "stripe"; -import type { Logger } from "../../../external/logtail/logtailUtils"; - -export const logSubItems = ({ - sub, - subItems, - withPriceId = false, - withItemId = false, - logger, -}: { - sub?: Stripe.Subscription; - subItems?: Stripe.SubscriptionItem[]; - withPriceId?: boolean; - withItemId?: boolean; - logger?: Logger | Console; -}) => { - const finalSubItems = subItems || sub!.items.data; - - if (!logger) { - logger = console; - } - - for (const item of finalSubItems) { - const isMetered = item.price.recurring?.usage_type === "metered"; - - const atmnPrice = stripeToAtmnAmount({ - amount: item.price.unit_amount || 0, - currency: item.price.currency, - }); - - if (isMetered) { - logger.info(`Usage price`); - } else { - const price = atmnPrice; - const subInterval = subItemToAutumnInterval(item); - logger.info( - `${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval} ${withPriceId ? `(${item.price.id})` : ""} ${withItemId ? `(${item.id})` : ""}`, - ); - } - } -}; diff --git a/server/src/utils/workerUtils/jobTypes/HandleCustomerCreatedData.ts b/server/src/utils/workerUtils/jobTypes/HandleCustomerCreatedData.ts deleted file mode 100644 index a5a93900e..000000000 --- a/server/src/utils/workerUtils/jobTypes/HandleCustomerCreatedData.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AppEnv } from "@autumn/shared"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; - -export interface HandleCustomerCreatedData { - req: Partial; - orgId: string; - env: AppEnv; - internalCustomerId: string; -} diff --git a/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts b/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts index 7bfcae858..9cd4d1b77 100644 --- a/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts +++ b/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts @@ -61,7 +61,10 @@ mock.module("@/internal/balances/track/v3/runRedisTrackV3.js", () => ({ }, })); -import { runTrackV3 } from "@/internal/balances/track/v3/runTrackV3.js"; +const { runTrackV3 } = await import( + // @ts-expect-error - Bun test cache-busting import query isolates module mocks. + "@/internal/balances/track/v3/runTrackV3.js?runTrackV3Idempotency" +); const ctx = { apiVersion: new ApiVersionClass(ApiVersion.V2_1), diff --git a/shared/api/balances/track/changes/V0.2_TrackChange.ts b/shared/api/balances/track/changes/V0.2_TrackChange.ts deleted file mode 100644 index 2cf3f058c..000000000 --- a/shared/api/balances/track/changes/V0.2_TrackChange.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; -import { - AffectedResource, - defineVersionChange, -} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; -import type { z } from "zod/v4"; -import { - TrackResponseV0Schema, - TrackResponseV1Schema, -} from "../prevVersions/trackResponseV1.js"; -import { - type TrackLegacyData, - TrackLegacyDataSchema, -} from "../trackLegacyData.js"; - -/** - * V0_2_CheckChange: Transforms check response TO V0_2 format - * - * Applied when: targetVersion <= V0_2 - * - * Breaking changes introduced in V0.2 (that we reverse here): - * - * 1. Structure: Single check result object → balances array format - * - V0.2+: Single object with allowed, feature_id, balance, unlimited, etc. - * - V0_2: { allowed, balances: [{ feature_id, required, balance, unlimited, usage_allowed }] } - * - * 2. Boolean features: No balance fields → balance: null - * 3. Unlimited features: Return unlimited: true, usage_allowed based on overage_allowed - * 4. Metered features: Return required_balance and balance - * - * Input: CheckResult (V0.2+ format) - * Output: CheckResponseV0 (V0_2 balances array format) - */ - -export const V0_2_CheckChange = defineVersionChange({ - name: "V0.2 Check Change", - newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1 - oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2 - description: [ - "Check response transformed to balances array format", - "Single check result object → { allowed, balances: [...] }", - ], - affectedResources: [AffectedResource.Check], - newSchema: TrackResponseV1Schema, - oldSchema: TrackResponseV0Schema, - legacyDataSchema: TrackLegacyDataSchema, - affectsResponse: true, - - // Response: V1.1+ (CheckResult) → V0_2 (CheckResponseV0) - transformResponse: ({ - input, - legacyData, - }: { - input: z.infer; - legacyData?: TrackLegacyData; - }): z.infer => { - return { - success: true, - }; - }, -}); diff --git a/shared/api/billing/openCustomerPortalParams.ts b/shared/api/billing/openCustomerPortalParams.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/shared/api/customers/cusFeatures/utils/convert/balancesToCheckFeature.ts b/shared/api/customers/cusFeatures/utils/convert/balancesToCheckFeature.ts deleted file mode 100644 index 9f0f341f0..000000000 --- a/shared/api/customers/cusFeatures/utils/convert/balancesToCheckFeature.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ApiBalanceInput } from "@api/customers/cusFeatures/utils/convert/apiBalanceToAllowed"; - -export const balancesToCheckFeature = ({ - balances, -}: { - balances: Record; -}) => { - return balances.map((balance) => { - return { - featureId: balance.featureId, - }; - }); -}; diff --git a/shared/api/customers/cusPlans/changes/V2.0_ApiSubscriptionChange.ts b/shared/api/customers/cusPlans/changes/V2.0_ApiSubscriptionChange.ts deleted file mode 100644 index bfc269261..000000000 --- a/shared/api/customers/cusPlans/changes/V2.0_ApiSubscriptionChange.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { planV1ToV0 } from "@api/products/mappers/planV1ToV0"; -import type { ApiSubscription } from "../apiSubscription"; -import type { ApiSubscriptionV1 } from "../apiSubscriptionV1"; - -export function transformApiSubscriptionV1ToV0({ - input, -}: { - input: ApiSubscriptionV1; -}): ApiSubscription { - return { - plan: input.plan ? planV1ToV0(input.plan) : undefined, - plan_id: input.plan_id, - default: input.auto_enable, - add_on: input.add_on, - status: input.status, - past_due: input.past_due, - canceled_at: input.canceled_at, - expires_at: input.expires_at, - trial_ends_at: input.trial_ends_at, - started_at: input.started_at, - current_period_start: input.current_period_start, - current_period_end: input.current_period_end, - quantity: input.quantity, - }; -} diff --git a/shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts b/shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts deleted file mode 100644 index 9395ab0ba..000000000 --- a/shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { planV1ToV0 } from "@api/products/mappers/planV1ToV0"; -import type { SharedContext } from "../../../../types/sharedContext"; -import type { ApiSubscription } from "../apiSubscription"; -import type { ApiPurchaseV0 } from "../apiSubscriptionV1"; - -/** - * Converts an ApiPurchaseV0 to an ApiSubscription (V0) for backwards compatibility. - * Purchases are represented as subscriptions with sensible defaults for missing fields. - */ -export function apiPurchaseV0ToSubscriptionV0({ - ctx, - input, -}: { - ctx: SharedContext; - input: ApiPurchaseV0; -}): ApiSubscription { - return { - plan: input.plan ? planV1ToV0({ ctx, plan: input.plan }) : undefined, - plan_id: input.plan_id, - default: false, - add_on: true, - status: "active", - past_due: false, - canceled_at: null, - expires_at: input.expires_at, - trial_ends_at: null, - started_at: input.started_at, - current_period_start: null, - current_period_end: null, - quantity: input.quantity, - }; -} diff --git a/shared/api/features/utils/findCreditSystemsByFeatureId.ts b/shared/api/features/utils/findCreditSystemsByFeatureId.ts deleted file mode 100644 index b53a89ecf..000000000 --- a/shared/api/features/utils/findCreditSystemsByFeatureId.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ApiFeatureV1 } from "@api/features/apiFeatureV1"; - -export const findCreditSystemsByFeatureId = ({ - featureId, - creditSystems, -}: { - featureId: string; - creditSystems: ApiFeatureV1[]; -}) => { - return creditSystems.filter((creditSystem) => - creditSystem.credit_schema?.some( - (schema) => schema.metered_feature_id === featureId, - ), - ); -}; diff --git a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts deleted file mode 100644 index cfebb2ec4..000000000 --- a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { FeatureNotFoundError } from "@api/errors/classes/featureErrClasses.js"; -import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js"; -import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1.js"; -import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js"; -import { featureUtils } from "@utils/index"; -import { subtractIncludedFromTiers } from "@utils/productV2Utils/productItemUtils/tierUtils.js"; -import type { SharedContext } from "../../../../types/sharedContext.js"; - -/** - * Converts V1 plan item params (CreatePlanItemParamsV1) to V0 response format (ApiPlanItemV0) - */ -export function planItemParamsV1ToPlanItemV0({ - ctx, - item, -}: { - ctx: SharedContext; - item: CreatePlanItemParamsV1; -}): ApiPlanItemV0 { - const { features } = ctx; - - const feature = features.find((f) => f.id === item.feature_id); - if (!feature) { - throw new FeatureNotFoundError({ featureId: item.feature_id }); - } - - const isAllocatedFeature = featureUtils.isAllocated(feature); - - const included = item.included ?? 0; - - // V1 API: tier `to` values INCLUDE included usage. - // Internal: tier `to` values do NOT include included usage. - const internalTiers = item.price?.tiers - ? subtractIncludedFromTiers({ tiers: item.price.tiers, included }) - : undefined; - - return { - feature_id: item.feature_id, - granted_balance: included, - unlimited: item.unlimited ?? false, - - reset: item.reset - ? { - interval: item.reset.interval, - interval_count: item.reset.interval_count, - reset_when_enabled: !isAllocatedFeature, - } - : null, - - price: item.price - ? { - amount: item.price.amount, - tiers: internalTiers, - tier_behavior: item.price.tier_behavior, - interval: item.price.interval, - interval_count: item.price.interval_count, - billing_units: item.price.billing_units ?? 1, - usage_model: billingMethodToUsageModel(item.price.billing_method), - max_purchase: item.price.max_purchase ?? null, - } - : null, - - rollover: item.rollover - ? { - max: item.rollover.max ?? null, - max_percentage: item.rollover.max_percentage ?? null, - expiry_duration_type: item.rollover.expiry_duration_type, - expiry_duration_length: item.rollover.expiry_duration_length, - } - : undefined, - - proration: item.proration, - }; -} diff --git a/shared/api/products/mappers/planV0ToProductV2.ts b/shared/api/products/mappers/planV0ToProductV2.ts deleted file mode 100644 index 5153b42dd..000000000 --- a/shared/api/products/mappers/planV0ToProductV2.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems"; -import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0"; -import type { ProductV2 } from "@models/productV2Models/productV2Models"; -import type { SharedContext } from "../../../types/sharedContext"; - -export function planV0ToProductV2({ - ctx, - plan, -}: { - ctx: SharedContext; - plan: ApiPlan; -}): ProductV2 { - // Convert plan to items using shared utility - const items = planV0ToProductItems({ ctx, plan }); - - // Check if archived field exists on plan (it's on ApiPlan, not CreatePlanParams) - const archived = - "archived" in plan && plan.archived !== undefined - ? plan.archived - : undefined; - - return { - id: plan.id, - name: plan.name, - description: plan.description ?? null, - is_add_on: plan.add_on, - is_default: plan.default, - group: plan.group ?? "", - items, - free_trial: plan.free_trial - ? { - duration: plan.free_trial.duration_type, - length: plan.free_trial.duration_length, - unique_fingerprint: false, - card_required: plan.free_trial.card_required, - } - : null, - ...(archived !== undefined && { archived }), - - version: plan.version, - env: plan.env, - created_at: plan.created_at, - }; -} diff --git a/shared/models/cusModels/fullSubjectModel.ts b/shared/models/cusModels/fullSubjectModel.ts deleted file mode 100644 index 3c5bf4663..000000000 --- a/shared/models/cusModels/fullSubjectModel.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { z } from "zod/v4"; -import { FullAggregatedCustomerEntitlementSchema } from "../cusProductModels/cusEntModels/aggregatedCusEnt.js"; -import { - type FullCustomerEntitlement, - FullCustomerEntitlementSchema, -} from "../cusProductModels/cusEntModels/cusEntModels.js"; -import { CustomerPriceSchema } from "../cusProductModels/cusPriceModels/cusPriceModels.js"; -import { - type FullCusProduct, - FullCusProductSchema, -} from "../cusProductModels/cusProductModels.js"; -import { SubscriptionSchema } from "../subModels/subModels.js"; -import { type Customer, CustomerSchema } from "./cusModels.js"; -import { type Entity, EntitySchema } from "./entityModels/entityModels.js"; -import { InvoiceSchema } from "./invoiceModels/invoiceModels.js"; - -export const SubjectType = { - Customer: "customer", - Entity: "entity", -} as const; -export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType]; - -export const FullSubjectSchema = z.object({ - subjectType: z.enum(["customer", "entity"]), - - customerId: z.string(), - internalCustomerId: z.string(), - entityId: z.string().optional(), - internalEntityId: z.string().optional(), - - customer: CustomerSchema, - entity: EntitySchema.optional(), - - customer_products: z.array(FullCusProductSchema), - extra_customer_entitlements: z.array(FullCustomerEntitlementSchema), - - subscriptions: z.array(SubscriptionSchema).optional(), - invoices: z.array(InvoiceSchema), - - aggregated_customer_products: z.array(FullCusProductSchema).optional(), - aggregated_customer_entitlements: z - .array(FullAggregatedCustomerEntitlementSchema) - .optional(), - aggregated_customer_prices: z.array(CustomerPriceSchema).optional(), -}); - -export type FullSubject = z.infer; - -/** Backward-compat type for entity DB layer files. */ -export type FullEntity = Entity & { - customer: Customer; - customer_products: FullCusProduct[]; - extra_customer_entitlements: FullCustomerEntitlement[]; -}; diff --git a/vite/src/components/forms/attach-product/attach-confirmation-info.tsx b/vite/src/components/forms/attach-product/attach-confirmation-info.tsx deleted file mode 100644 index 06f5adf86..000000000 --- a/vite/src/components/forms/attach-product/attach-confirmation-info.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { CheckoutResponseV0 } from "@autumn/shared"; -import type { ReactNode } from "react"; -import { useIsLatestVersion } from "@/hooks/stores/useProductStore"; -import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils"; -import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; - -export const AttachConfirmationInfo = ({ - previewData, -}: { - previewData?: CheckoutResponseV0 | null; -}) => { - const isLatestVersion = useIsLatestVersion(previewData?.product); - - const renderInfoBoxes = (): ReactNode[] => { - const boxes: ReactNode[] = []; - - if (!previewData) { - return boxes; - } - - if (!isLatestVersion) { - boxes.push( - - You're enabling a previous version (v{previewData.product.version}) of - this plan. - , - ); - } - - // Payment method required - if (previewData.url) { - let secondaryText = ""; - if (previewData.product.free_trial?.card_required === true) { - secondaryText = "to start this trial"; - } else { - secondaryText = "as this plan has prices"; - } - - boxes.push( - - A payment method is required {secondaryText} - , - ); - } - - if (previewData.product.free_trial) { - let secondaryText = ""; - if (previewData.product.free_trial?.card_required === true) { - secondaryText = " and the customer will be charged"; - } else { - secondaryText = " and this plan will expire"; - } - - boxes.push( - - Trial ends {formatUnixToDate(previewData.next_cycle?.starts_at)} - {secondaryText} - , - ); - } - - // Show scenario-based info if switching from another plan and not attaching add-on - if ( - previewData.current_product && - previewData.product && - !previewData.product.is_add_on - ) { - const scenario = previewData.product.scenario as - | "upgrade" - | "downgrade" - | "cancel" - | "new" - | string; - - switch (scenario) { - case "upgrade": - boxes.push( - - This upgrade will immediately replace the customer's current plan:{" "} - {previewData.current_product.name} - , - ); - break; - case "downgrade": - boxes.push( - - This downgrade will replace the customer's current plan:{" "} - {previewData.current_product.name} - , - ); - break; - case "cancel": - boxes.push( - - This will cancel the customer's current billing subscription:{" "} - {previewData.current_product.name} - , - ); - break; - case "new": - boxes.push( - - This will be enabled alongside existing plans{" "} - , - ); - } - } - - if ( - previewData.next_cycle?.starts_at && - previewData.product?.scenario === "downgrade" - ) { - const startsAtString = formatUnixToDate(previewData.next_cycle.starts_at); - - boxes.push( - - Plan change will take effect next cycle, on{" "} - {startsAtString} - , - ); - } - - // If switching products, show info about current product - - return boxes; - }; - - const infoBoxes = renderInfoBoxes(); - - if (infoBoxes.length === 0) { - return null; - } - - return ( -
- {infoBoxes.map((box, index) => ( -
{box}
- ))} -
- ); -}; diff --git a/vite/src/components/forms/attach-product/attach-product-actions.tsx b/vite/src/components/forms/attach-product/attach-product-actions.tsx deleted file mode 100644 index 587dec17d..000000000 --- a/vite/src/components/forms/attach-product/attach-product-actions.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared"; -import type { LucideIcon } from "lucide-react"; -import { ArrowUpRightFromSquare, CircleCheck } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; -import { useAttachProductMutation } from "@/components/forms/attach-product/use-attach-product-mutation"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/v2/buttons/Button"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { useEntity } from "@/hooks/stores/useSubscriptionStore"; -import { useEnv } from "@/utils/envUtils"; -import { openInNewTab } from "@/utils/genUtils"; -import { getStripeInvoiceLink } from "@/utils/linkUtils"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -function getAttachButtonConfig(isCheckout: boolean): { - text: string; - icon: LucideIcon; -} { - return isCheckout - ? { text: "Checkout", icon: ArrowUpRightFromSquare } - : { text: "Confirm", icon: CircleCheck }; -} - -interface AttachProductActionsProps { - form: UseAttachProductForm; - product: ProductV2; - customerId: string; - onSuccess?: () => void; - previewData?: CheckoutResponseV0 | null; - isPreviewLoading?: boolean; -} - -export function AttachProductActions({ - form, - product, - customerId, - onSuccess, - previewData, - isPreviewLoading, -}: AttachProductActionsProps) { - const { stripeAccount } = useOrgStripeQuery(); - const env = useEnv(); - const org = useOrg(); - const { entityId } = useEntity(); - const [activeAction, setActiveAction] = useState<"invoice" | "attach" | null>( - null, - ); - const { closeSheet } = useSheetStore(); - - const ownStripeAccount = org.org?.stripe_connection !== "default"; - - const attachMutation = useAttachProductMutation({ - customerId, - onSuccess: () => { - form.reset(); - setActiveAction(null); - onSuccess?.(); - }, - }); - - const handleAttach = async ({ - useInvoice, - enableProductImmediately, - action, - }: { - useInvoice: boolean; - enableProductImmediately?: boolean; - action: "invoice" | "attach"; - }) => { - const { prepaidOptions } = form.state.values; - setActiveAction(action); - - if (previewData?.url && action === "attach") { - window.open(previewData.url, "_blank"); - setActiveAction(null); - closeSheet(); - return; - } - - try { - const result = await attachMutation.mutateAsync({ - product, - prepaidOptions: prepaidOptions || {}, - useInvoice, - enableProductImmediately, - entityId: entityId ?? undefined, - }); - - // Handle checkout URLs and invoice links - if (result.data.checkout_url) { - openInNewTab({ url: result.data.checkout_url }); - } else if (result.data.invoice) { - const stripeInvoiceUrl = getStripeInvoiceLink({ - stripeInvoice: result.data.invoice, - env, - accountId: stripeAccount?.id, - }); - - openInNewTab({ url: stripeInvoiceUrl }); - toast.success("Redirected to Stripe to finalize the invoice"); - } - } catch (error) { - setActiveAction(null); - throw error; - } - }; - - const isLoading = attachMutation.isPending; - const isInvoiceLoading = isLoading && activeAction === "invoice"; - const isAttachLoading = isLoading && activeAction === "attach"; - - // Don't show buttons if preview is loading - if (isPreviewLoading || !product) { - return null; - } - - const isCheckout = !!previewData?.url; - const { text: attachText, icon: AttachIcon } = - getAttachButtonConfig(isCheckout); - - return ( -
- - - - - -
- - -
-
-
- - -
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-form-schema.ts b/vite/src/components/forms/attach-product/attach-product-form-schema.ts deleted file mode 100644 index 4eae52b28..000000000 --- a/vite/src/components/forms/attach-product/attach-product-form-schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod/v4"; - -export const AttachProductFormSchema = z.object({ - productId: z.string(), - prepaidOptions: z.record(z.string(), z.number().optional()), -}); - -export type AttachProductForm = z.infer; diff --git a/vite/src/components/forms/attach-product/attach-product-form.tsx b/vite/src/components/forms/attach-product/attach-product-form.tsx deleted file mode 100644 index fcdd9c108..000000000 --- a/vite/src/components/forms/attach-product/attach-product-form.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import type { - Entity, - FrontendProduct, - FullCustomer, - ProductV2, -} from "@autumn/shared"; -import { useStore } from "@tanstack/react-form"; -import { FormWrapper } from "@/components/general/form/form-wrapper"; -import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { usePrepaidItems } from "@/hooks/stores/useProductStore"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { useEntity } from "@/hooks/stores/useSubscriptionStore"; -import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; -import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; -import { AttachProductActions } from "./attach-product-actions"; -import { AttachProductPrepaidOptions } from "./attach-product-prepaid-options"; -import { AttachProductSelection } from "./attach-product-selection"; -import { AttachProductSummary } from "./attach-product-summary"; -import { useAttachPreview } from "./use-attach-preview"; -import type { UseAttachProductForm } from "./use-attach-product-form"; -import { useAttachProductForm } from "./use-attach-product-form"; - -interface FormContentProps { - products: ProductV2[]; - customerId: string; - form: UseAttachProductForm; - onSuccess?: () => void; -} - -function FormContent({ - products, - customerId, - form, - onSuccess, -}: FormContentProps) { - const sheetData = useSheetStore((s) => s.data); - const productId = useStore(form.store, (state) => state.values.productId); - const prepaidOptions = useStore( - form.store, - (state) => state.values.prepaidOptions, - ); - - // Use customized product from sheet data if available, otherwise find from products list - const customizedProduct = sheetData?.customizedProduct as - | FrontendProduct - | undefined; - const product = customizedProduct?.id - ? customizedProduct - : products.find((p) => p.id === productId && !p.archived); - - const { prepaidItems } = usePrepaidItems({ product }); - - const { entityId } = useEntity(); - - // Call preview once here and pass data down to children - const previewQuery = useAttachPreview({ - customerId, - product, - entityId: entityId ?? undefined, - prepaidOptions: prepaidOptions ?? undefined, - version: product?.version, - }); - - // Check if there are prepaid items and if any are not set (undefined/null) - // Note: 0 is a valid quantity value - if (prepaidItems.length > 0) { - const hasUnsetPrepaidQuantity = prepaidItems.some((item) => { - const quantity = prepaidOptions?.[item.feature_id as string]; - return quantity === undefined || quantity === null; - }); - - if (hasUnsetPrepaidQuantity) { - return null; - } - } - - if (!form.state.values.productId || !product) { - return null; - } - - return ( - <> - - - - - ); -} - -export function AttachProductForm({ - customerId, - onSuccess, -}: { - customerId: string; - onSuccess?: () => void; -}) { - const itemId = useSheetStore((s) => s.itemId); - const form = useAttachProductForm({ initialProductId: itemId || undefined }); - const { products, isLoading } = useProductsQuery(); - - const activeProducts = products.filter((p) => !p.archived); - - const { entityId } = useEntity(); - const { customer } = useCusQuery(); - - const entities = (customer as FullCustomer).entities || []; - - const fullEntity = entities.find( - (e: Entity) => e.id === entityId || e.internal_id === entityId, - ); - - if (isLoading) { - return
Loading products...
; - } - - return ( - - -
- - - - {entityId ? ( -
- - Attaching plan to entity{" "} - - {fullEntity?.name || fullEntity?.id} - - -
- ) : entities.length > 0 ? ( -
- - Attaching plan to customer - all entities will get access - -
- ) : null} -
-
- - -
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-line-items.tsx b/vite/src/components/forms/attach-product/attach-product-line-items.tsx deleted file mode 100644 index ce87810a0..000000000 --- a/vite/src/components/forms/attach-product/attach-product-line-items.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { CheckoutResponseV0 } from "@autumn/shared"; -import { - SheetAccordion, - SheetAccordionItem, -} from "@/components/v2/sheets/SheetAccordion"; - -export function AttachProductLineItems({ - previewData, -}: { - previewData?: CheckoutResponseV0 | null; -}) { - const lineItems = - previewData?.lines?.map((line) => { - return { - name: line.description || "Unknown", - total: line.amount, - }; - }) || []; - - if (lineItems.length === 0) { - return null; - } - - return ( - - -
- {lineItems.map((item, index) => ( -
- - {item.name} - - - ${item.total.toFixed(2)} - -
- ))} -
-
-
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-prepaid-options.tsx b/vite/src/components/forms/attach-product/attach-product-prepaid-options.tsx deleted file mode 100644 index ea42f3e31..000000000 --- a/vite/src/components/forms/attach-product/attach-product-prepaid-options.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { - type FrontendProductItem, - getFeaturePriceItemDisplay, -} from "@autumn/shared"; -import { useStore } from "@tanstack/react-form"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { - usePrepaidItems, - useProductStore, -} from "@/hooks/stores/useProductStore"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -interface PrepaidOptionsFieldProps { - form: UseAttachProductForm; -} - -export function AttachProductPrepaidOptions({ - form, -}: PrepaidOptionsFieldProps) { - const storeProduct = useProductStore((s) => s.product); - const { products = [] } = useProductsQuery(); - const selectedProductId = useStore( - form.store, - (state) => state.values.productId, - ); - const { org } = useOrg(); - const product = storeProduct?.id - ? storeProduct - : products.find((p) => p.id === selectedProductId && !p.archived); - - const { prepaidItems } = usePrepaidItems({ product }); - - if (prepaidItems.length === 0 || !selectedProductId) { - return null; - } - - return ( -
-
- {prepaidItems.map((item) => { - const display = getFeaturePriceItemDisplay({ - item: item as FrontendProductItem, - feature: item.feature, - currency: org?.default_currency || "USD", - fullDisplay: true, - amountFormatOptions: { - currencyDisplay: "narrowSymbol", - }, - }); - return ( -
- - {display.primary_text} - {display.secondary_text && ` ${display.secondary_text}`} - - - - {(quantityField) => ( - - )} - -
- ); - })} -
-
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-selection.tsx b/vite/src/components/forms/attach-product/attach-product-selection.tsx deleted file mode 100644 index 77ae309a1..000000000 --- a/vite/src/components/forms/attach-product/attach-product-selection.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { - isProductAlreadyEnabled, - isProductCurrentlyAttached, -} from "@autumn/shared"; -import { PencilSimpleIcon } from "@phosphor-icons/react"; -import { useNavigate } from "react-router"; -import { IconButton } from "@/components/v2/buttons/IconButton"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { useHasChanges } from "@/hooks/stores/useProductStore"; -import { useEntity } from "@/hooks/stores/useSubscriptionStore"; -import { pushPage } from "@/utils/genUtils"; -import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; -import { - type UseAttachProductForm, - useResetPrepaidOnProductChange, -} from "./use-attach-product-form"; - -interface AttachProductSelectionProps { - form: UseAttachProductForm; - customerId: string; -} - -export function AttachProductSelection({ - form, - customerId, -}: AttachProductSelectionProps) { - const { products } = useProductsQuery(); - const availableProducts = products.filter((p) => !p.archived); - const navigate = useNavigate(); - const productId = form.state.values.productId; - const hasChanges = useHasChanges(); - const { customer } = useCusQuery(); - const { entityId } = useEntity(); - - useResetPrepaidOnProductChange({ form }); - - const handleCustomize = ({ productId }: { productId: string }) => { - if (!productId || !customerId) { - return; - } - - pushPage({ - path: `/customers/${customerId}/${productId}`, - navigate, - }); - }; - - return ( -
-
- - {(field) => ( - { - const entityIdVal = entityId ?? undefined; - const alreadyEnabled = isProductAlreadyEnabled({ - productId: p.id, - customer, - entityId: entityIdVal, - }); - const currentlyAttached = - !alreadyEnabled && - isProductCurrentlyAttached({ - productId: p.id, - customer, - entityId: entityIdVal, - }); - - return { - label: p.name, - value: p.id, - disabledValue: alreadyEnabled ? "Already Enabled" : undefined, - badgeValue: currentlyAttached ? "Already Enabled" : undefined, - }; - })} - placeholder="Select Product" - hideFieldInfo - selectValueAfter={ - hasChanges && productId ? ( - - Custom - - ) : undefined - } - /> - )} - - -
- state.values.productId}> - {(productId) => ( - } - onClick={() => handleCustomize({ productId })} - disabled={!productId} - type="button" - > - Customize - - )} - -
-
-
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-summary.tsx b/vite/src/components/forms/attach-product/attach-product-summary.tsx deleted file mode 100644 index c964a41c3..000000000 --- a/vite/src/components/forms/attach-product/attach-product-summary.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { CheckoutResponseV0 } from "@autumn/shared"; -import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText"; -import { AttachConfirmationInfo } from "./attach-confirmation-info"; -import { AttachProductLineItems } from "./attach-product-line-items"; -import { AttachProductTotals } from "./attach-product-totals"; - -export function AttachProductSummary({ - previewData, - isLoading, -}: { - previewData?: CheckoutResponseV0 | null; - isLoading?: boolean; -}) { - if (isLoading) { - return ( - - ); - } - - return ( -
- - - {/* */} -
- - -
-
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-totals.tsx b/vite/src/components/forms/attach-product/attach-product-totals.tsx deleted file mode 100644 index e6fafc5d4..000000000 --- a/vite/src/components/forms/attach-product/attach-product-totals.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { CheckoutResponseV0 } from "@autumn/shared"; -import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils"; - -export function AttachProductTotals({ - previewData, -}: { - previewData?: CheckoutResponseV0 | null; -}) { - const total = previewData?.total || 0; - const nextCycleTotal = previewData?.next_cycle?.total || 0; - const nextCycleStartsAt = formatUnixToDate( - previewData?.next_cycle?.starts_at || 0, - ); - - return ( -
-
-
Total
-
${total.toFixed(2)}
-
- {nextCycleStartsAt && ( -
-
- Next Cycle ({nextCycleStartsAt}) -
-
- ${nextCycleTotal.toFixed(2)} -
-
- )} -
- ); -} diff --git a/vite/src/components/forms/attach-product/update-product-actions.tsx b/vite/src/components/forms/attach-product/update-product-actions.tsx deleted file mode 100644 index e4418fdcc..000000000 --- a/vite/src/components/forms/attach-product/update-product-actions.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared"; -import type { LucideIcon } from "lucide-react"; -import { ArrowUpRightFromSquare, CircleCheck } from "lucide-react"; -import { useAttachProductMutation } from "@/components/forms/attach-product/use-attach-product-mutation"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/v2/buttons/Button"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; -import { useEnv } from "@/utils/envUtils"; -import { getStripeInvoiceLink } from "@/utils/linkUtils"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -function getUpdateButtonConfig(isCheckout: boolean): { - text: string; - icon: LucideIcon; -} { - return isCheckout - ? { text: "Checkout", icon: ArrowUpRightFromSquare } - : { text: "Confirm Update", icon: CircleCheck }; -} - -interface UpdateProductActionsProps { - product?: ProductV2; - customerId?: string; - entityId?: string; - onSuccess?: () => void; - previewData?: CheckoutResponseV0 | null; - isPreviewLoading?: boolean; - version?: number; - form: UseAttachProductForm; -} - -export function UpdateProductActions({ - form, - product, - customerId, - entityId, - onSuccess, - previewData, - isPreviewLoading, - version, -}: UpdateProductActionsProps) { - const { stripeAccount } = useOrgStripeQuery(); - const env = useEnv(); - const org = useOrg(); - - const isOwnStripeAccount = stripeAccount?.id === org.org?.stripe_connection; - const attachMutation = useAttachProductMutation({ - customerId: customerId ?? "", - successMessage: "Plan updated successfully", - onSuccess: () => { - onSuccess?.(); - }, - }); - - const handleUpdate = async ({ - useInvoice, - enableProductImmediately, - }: { - useInvoice: boolean; - enableProductImmediately?: boolean; - }) => { - // Only redirect to checkout URL for the "Checkout" button flow (useInvoice: false) - // When useInvoice is true, we always call the attach mutation to generate an invoice - if (previewData?.url && !useInvoice) { - window.open(previewData.url, "_blank"); - return; - } - - // Does the update - const result = await attachMutation.mutateAsync({ - product, - entityId, - useInvoice, - enableProductImmediately, - prepaidOptions: form.state.values.prepaidOptions ?? undefined, - version, - }); - - // Handle checkout URLs and invoice links - if (result.data.invoice) { - window.open( - getStripeInvoiceLink({ - stripeInvoice: result.data.invoice, - env, - accountId: stripeAccount?.id, - }), - "_blank", - ); - } - }; - - const isLoading = attachMutation.isPending; - - // Don't show buttons if preview is loading - if (isPreviewLoading || !product) { - return null; - } - - const isCheckout = !!previewData?.url; - const { text: updateText, icon: UpdateIcon } = - getUpdateButtonConfig(isCheckout); - - return ( -
- - - - - -
- - -
-
-
- - -
- ); -} diff --git a/vite/src/components/forms/attach-product/update-product-prepaid-options.tsx b/vite/src/components/forms/attach-product/update-product-prepaid-options.tsx deleted file mode 100644 index ce779bd27..000000000 --- a/vite/src/components/forms/attach-product/update-product-prepaid-options.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { - type FrontendProductItem, - getFeaturePriceItemDisplay, -} from "@autumn/shared"; -import { useOrg } from "@/hooks/common/useOrg"; -import { - usePrepaidItems, - useProductStore, -} from "@/hooks/stores/useProductStore"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -export function UpdateProductPrepaidOptions({ - form, -}: { - form: UseAttachProductForm; -}) { - const storeProduct = useProductStore((s) => s.product); - const itemId = useSheetStore((s) => s.itemId); - - const { org } = useOrg(); - const { productV2 } = useSubscriptionById({ itemId }); - - // Use store product if it has a real ID, otherwise use productV2 from subscription - const product = storeProduct?.id ? storeProduct : (productV2 ?? undefined); - - const { prepaidItems } = usePrepaidItems({ product }); - - if (prepaidItems.length === 0) { - return null; - } - - console.log("prepaidItems", prepaidItems); - - return ( -
-
- {prepaidItems.map((item) => { - const display = getFeaturePriceItemDisplay({ - item: item as FrontendProductItem, - feature: item.feature, - currency: org?.default_currency || "USD", - fullDisplay: true, - amountFormatOptions: { - currencyDisplay: "narrowSymbol", - }, - }); - - return ( -
- - {display.primary_text} - {display.secondary_text && ` ${display.secondary_text}`} - - - - {(quantityField) => ( - - )} - -
- ); - })} -
-
- ); -} diff --git a/vite/src/components/forms/attach-product/update-product-summary.tsx b/vite/src/components/forms/attach-product/update-product-summary.tsx deleted file mode 100644 index 7ffb06d20..000000000 --- a/vite/src/components/forms/attach-product/update-product-summary.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { - PreviewUpdateSubscriptionResponse, - ProductV2, -} from "@autumn/shared"; -import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText"; -import { UpdateConfirmationInfo } from "../update-subscription/update-confirmation-info"; -import { AttachProductLineItems } from "./attach-product-line-items"; -import { AttachProductTotals } from "./attach-product-totals"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -export function UpdateProductSummary({ - product, - previewData, - isLoading, - form, -}: { - product?: ProductV2; - previewData?: PreviewUpdateSubscriptionResponse | null; - isLoading?: boolean; - form: UseAttachProductForm; -}) { - if (isLoading) { - return ( - - ); - } - - return ( - <> - - -
- - -
- - ); -} diff --git a/vite/src/components/forms/attach-product/use-attach-body-builder.ts b/vite/src/components/forms/attach-product/use-attach-body-builder.ts deleted file mode 100644 index f5444a0cc..000000000 --- a/vite/src/components/forms/attach-product/use-attach-body-builder.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { AppEnv, type ProductV2 } from "@autumn/shared"; -import { useMemo } from "react"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore"; -import { useEntity } from "@/hooks/stores/useSubscriptionStore"; -import { convertPrepaidOptionsToFeatureOptions } from "@/utils/billing/prepaidQuantityUtils"; -import { useEnv } from "@/utils/envUtils"; -import { getRedirectUrl } from "@/utils/genUtils"; -import { getAttachBody } from "@/views/customers/customer/product/components/attachProductUtils"; - -interface AttachBodyBuilderParams { - customerId?: string; - productId?: string; - product?: ProductV2; - entityId?: string; - prepaidOptions?: Record; - version?: number; - useInvoice?: boolean; - enableProductImmediately?: boolean; - successUrl?: string; -} - -/** - * Shared hook to build attach body from explicit params - * Used by both useAttachPreview and useAttachProductMutation to keep logic DRY - */ -export function useAttachBodyBuilder(params: AttachBodyBuilderParams = {}) { - const { products } = useProductsQuery(); - const hasChanges = useHasChanges(); - const storeProduct = useProductStore((s) => s.product); - const { entityId: storeEntityId } = useEntity(); - const env = useEnv(); - const { org, isLoading: isOrgLoading, error: orgError } = useOrg(); - - // Memoized builder function that can be called with runtime params - const buildAttachBody = useMemo( - () => (runtimeParams?: AttachBodyBuilderParams) => { - const mergedParams = { ...params, ...runtimeParams }; - - const redirectUrl = getRedirectUrl( - `/customers/${mergedParams.customerId}`, - env, - ); - - // Resolve the product: use provided product or find by ID - const product = - mergedParams.product || - products.find((p) => p.id === mergedParams.productId); - - if (!product || !mergedParams.customerId) { - return null; - } - - // Determine if this is a custom product (from store with changes) - const isCustom = - hasChanges && !!storeProduct?.id && product === storeProduct - ? true - : undefined; - const version = storeProduct?.id ? storeProduct.version : undefined; - - // Convert prepaidOptions to options array - const options = mergedParams.prepaidOptions - ? convertPrepaidOptionsToFeatureOptions({ - prepaidOptions: mergedParams.prepaidOptions, - product, - }) - : undefined; - - // Build the attach body - return getAttachBody({ - customerId: mergedParams.customerId, - product, - entityId: mergedParams.entityId ?? storeEntityId ?? undefined, - optionsInput: options, - isCustom, - version, - useInvoice: mergedParams.useInvoice, - enableProductImmediately: mergedParams.enableProductImmediately, - successUrl: - // env === AppEnv.Sandbox - // ? `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}` - // : undefined, - org?.success_url && !isOrgLoading && !orgError - ? org.success_url - : env === AppEnv.Sandbox - ? `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}` - : undefined, - }); - }, - [ - products, - hasChanges, - storeProduct, - storeEntityId, - params, - org, - isOrgLoading, - orgError, - ], - ); - - // For simple usage, return the built body with current params - const attachBody = useMemo(() => buildAttachBody(), [buildAttachBody]); - - return { attachBody, buildAttachBody }; -} diff --git a/vite/src/components/forms/attach-product/use-attach-preview.ts b/vite/src/components/forms/attach-product/use-attach-preview.ts deleted file mode 100644 index 4c2296ff2..000000000 --- a/vite/src/components/forms/attach-product/use-attach-preview.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared"; -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; -import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useAttachBodyBuilder } from "./use-attach-body-builder"; - -interface AttachPreviewParams { - // Required params - no fallbacks - customerId?: string; - product?: ProductV2; - entityId?: string; - prepaidOptions?: Record; - version?: number; - - // Control behavior - enabled?: boolean; -} - -export function useAttachPreview(params: AttachPreviewParams = {}) { - const axiosInstance = useAxiosInstance(); - const buildKey = useQueryKeyFactory(); - - // Build attach body using shared hook with explicit params - const { attachBody } = useAttachBodyBuilder({ - customerId: params.customerId, - product: params.product, - entityId: params.entityId, - prepaidOptions: params.prepaidOptions, - version: params.version, - }); - - // Auto-enable if not explicitly set and all required data is present - const shouldEnable = - params.enabled !== undefined - ? params.enabled - : !!(params.customerId && params.product && attachBody); - - // Create a stable serialized key from attachBody (which already captures all dependencies) - const queryKeyDeps = useMemo(() => JSON.stringify(attachBody), [attachBody]); - - // Debounce the query key to delay API calls by 150ms - const [debouncedQueryKey, setDebouncedQueryKey] = useState(queryKeyDeps); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedQueryKey(queryKeyDeps); - }, 300); - return () => clearTimeout(timer); - }, [queryKeyDeps]); - - // Track if we're in a debouncing state (query key has changed but debounce hasn't completed) - const isDebouncing = queryKeyDeps !== debouncedQueryKey; - - const query = useQuery({ - queryKey: buildKey(["attach-checkout", debouncedQueryKey]), - queryFn: async () => { - if (!attachBody || !params.customerId) { - return null; - } - - const response = await axiosInstance.post( - "/v1/checkout", - attachBody, - ); - - return response.data; - }, - enabled: shouldEnable, - staleTime: 0, // Always fetch fresh pricing - }); - - // Override isLoading to include debouncing state - // This prevents showing stale data during the transition between diff plans in the selector - return { - ...query, - isLoading: query.isLoading || isDebouncing, - }; -} diff --git a/vite/src/components/forms/attach-product/use-attach-product-form.ts b/vite/src/components/forms/attach-product/use-attach-product-form.ts deleted file mode 100644 index ccc26e13b..000000000 --- a/vite/src/components/forms/attach-product/use-attach-product-form.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useAppForm } from "@/hooks/form/form"; -import { - type AttachProductForm, - AttachProductFormSchema, -} from "./attach-product-form-schema"; - -export function useAttachProductForm({ - initialProductId, - initialPrepaidOptions, -}: { - initialProductId?: string; - initialPrepaidOptions?: Record; -} = {}) { - return useAppForm({ - defaultValues: { - productId: initialProductId || "", - prepaidOptions: initialPrepaidOptions ?? {}, - } as AttachProductForm, - validators: { - onChange: AttachProductFormSchema, - onSubmit: AttachProductFormSchema, - }, - }); -} - -// Subscribe to form changes and clear prepaid options when productId changes -// Prevents stale prepaid options from causing "no prepaid price found" in the `checkout` call -export function useResetPrepaidOnProductChange({ - form, -}: { - form: UseAttachProductForm; -}) { - const previousProductIdRef = useRef(); - - useEffect(() => { - const subscription = form.store.subscribe(() => { - const currentProductId = form.store.state.values.productId; - - if ( - previousProductIdRef.current !== undefined && - previousProductIdRef.current !== currentProductId - ) { - form.setFieldValue("prepaidOptions", {}); - } - previousProductIdRef.current = currentProductId; - }); - - return () => subscription(); - }, [form.store, form.setFieldValue]); -} - -export type UseAttachProductForm = ReturnType; diff --git a/vite/src/components/forms/attach-product/use-attach-product-mutation.ts b/vite/src/components/forms/attach-product/use-attach-product-mutation.ts deleted file mode 100644 index da323c8f2..000000000 --- a/vite/src/components/forms/attach-product/use-attach-product-mutation.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { ProductV2 } from "@autumn/shared"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { AxiosError } from "axios"; -import { toast } from "sonner"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { CusService } from "@/services/customers/CusService"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useAttachBodyBuilder } from "./use-attach-body-builder"; - -interface AttachProductParams { - // Product selection (provide one of these) - productId?: string; - product?: ProductV2; - - // Optional overrides - entityId?: string; - prepaidOptions?: Record; - version?: number; - - // Invoice options - useInvoice?: boolean; - enableProductImmediately?: boolean; -} - -export function useAttachProductMutation({ - customerId, - onSuccess, - onError, - successMessage = "Successfully attached product", -}: { - customerId: string; - onSuccess?: (data: unknown) => void | Promise; - onError?: (error: unknown) => void; - successMessage?: string; -}) { - const axiosInstance = useAxiosInstance(); - const queryClient = useQueryClient(); - const { closeSheet } = useSheetStore(); - - // Get builder function from shared hook - const { buildAttachBody } = useAttachBodyBuilder({ customerId }); - - return useMutation({ - mutationFn: async (params: AttachProductParams) => { - // Build attach body using shared builder function - const attachBody = buildAttachBody({ - productId: params.productId, - product: params.product, - entityId: params.entityId, - prepaidOptions: params.prepaidOptions, - version: params.version, - useInvoice: params.useInvoice, - enableProductImmediately: params.enableProductImmediately, - }); - - if (!attachBody) { - throw new Error( - "Failed to build attach body - product not found or missing data", - ); - } - - return await CusService.attach(axiosInstance, attachBody); - }, - onSuccess: async (response) => { - // Don't show success toast if checkout_url is returned - product not attached yet - if (response.data.checkout_url) { - toast.success("Redirecting to checkout URL"); - closeSheet(); - return; - } - - toast.success(successMessage); - closeSheet(); - queryClient.invalidateQueries({ queryKey: ["customer", customerId] }); - - if (onSuccess) { - await onSuccess(response.data); - } - }, - onError: (error) => { - if (onError) { - onError(error); - } else { - toast.error( - (error as AxiosError<{ message: string }>)?.response?.data?.message ?? - "Failed to attach product", - ); - console.error(error); - } - }, - }); -} diff --git a/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx b/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx deleted file mode 100644 index 03bd75812..000000000 --- a/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { TimerIcon } from "@phosphor-icons/react"; -import { motion } from "motion/react"; -import { - STAGGER_CONTAINER, - STAGGER_ITEM, -} from "@/components/forms/update-subscription-v2/constants/animationConstants"; -import { Skeleton } from "@/components/ui/skeleton"; -import { IconButton } from "@/components/v2/buttons/IconButton"; -import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; - -export function AttachPlanSkeleton() { - return ( - - - {/* Section title - static content with disabled buttons */} - -

- - - Plan Configuration - - } - variant="secondary" - className="h-7 whitespace-nowrap" - disabled - > - Free Trial - - -

-
- - {/* Price display skeleton */} - - - - - - - - {/* Item rows skeleton */} - {[0, 1].map((i) => ( - -
-
-
- - - -
- -
-
-
- ))} - - {/* Edit button skeleton */} - - - -
-
- ); -} diff --git a/vite/src/components/forms/attach-v2/components/AttachSettingsPopover.tsx b/vite/src/components/forms/attach-v2/components/AttachSettingsPopover.tsx deleted file mode 100644 index cb6c0a2f4..000000000 --- a/vite/src/components/forms/attach-v2/components/AttachSettingsPopover.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { CalendarIcon, GearIcon, LightningIcon } from "@phosphor-icons/react"; -import { useState } from "react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Separator } from "@/components/ui/separator"; -import { IconButton } from "@/components/v2/buttons/IconButton"; -import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/v2/tooltips/Tooltip"; -import { cn } from "@/lib/utils"; -import { usePlanScheduleField } from "../hooks/usePlanScheduleField"; - -export function AttachSettingsPopover() { - const [open, setOpen] = useState(false); - - const { - hasActiveSubscription, - hasOutgoing, - hasCustomSchedule, - isImmediateSelected, - isEndOfCycleSelected, - handleScheduleChange, - } = usePlanScheduleField(); - - if (!hasActiveSubscription) return null; - - return ( - - - - } - variant="secondary" - className={cn( - "h-7 whitespace-nowrap", - hasCustomSchedule && - "text-blue-400! border-blue-500/50 bg-blue-500/10", - )} - > - Settings - - - e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - > -
-
-

- Advanced Configuration -

-

Override default billing behavior

-
- -
- Plan Schedule -
- } - iconOrientation="left" - variant="secondary" - size="sm" - checked={isImmediateSelected} - onCheckedChange={() => handleScheduleChange("immediate")} - className={cn( - "rounded-r-none", - !isImmediateSelected && "border-r-0", - )} - > - Immediately - - - - - } - iconOrientation="left" - variant="secondary" - size="sm" - checked={isEndOfCycleSelected} - disabled={!hasOutgoing} - onCheckedChange={() => - handleScheduleChange("end_of_cycle") - } - className={cn( - "rounded-l-none", - !isEndOfCycleSelected && "border-l-0", - )} - > - End of cycle - - - - {!hasOutgoing && ( - - Only available when transitioning from an existing plan - - )} - -
-
-
-
-
- ); -} diff --git a/vite/src/components/forms/create-schedule/index.ts b/vite/src/components/forms/create-schedule/index.ts deleted file mode 100644 index 8b24c9a72..000000000 --- a/vite/src/components/forms/create-schedule/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./components/CreateScheduleSheetContent"; -export * from "./components/SchedulePhaseCard"; -export * from "./components/SchedulePlanRow"; -export * from "./context/CreateScheduleFormProvider"; -export * from "./createScheduleFormSchema"; -export * from "./hooks/useCreateScheduleForm"; -export * from "./hooks/useCreateScheduleMutation"; -export * from "./hooks/useCreateScheduleRequestBody"; diff --git a/vite/src/components/forms/update-subscription-v2/components/CompactValueChange.tsx b/vite/src/components/forms/update-subscription-v2/components/CompactValueChange.tsx deleted file mode 100644 index d29c63121..000000000 --- a/vite/src/components/forms/update-subscription-v2/components/CompactValueChange.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { cn } from "@/lib/utils"; - -interface CompactValueChangeProps { - oldValue: string | number | null; - newValue: string | number | null; - isUpgrade?: boolean; -} - -export function CompactValueChange({ - oldValue, - newValue, - isUpgrade = true, -}: CompactValueChangeProps) { - return ( - - - {oldValue} - - - - {newValue} - - - ); -} diff --git a/vite/src/components/forms/update-subscription-v2/utils/getEditIcon.tsx b/vite/src/components/forms/update-subscription-v2/utils/getEditIcon.tsx deleted file mode 100644 index b3a949a54..000000000 --- a/vite/src/components/forms/update-subscription-v2/utils/getEditIcon.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { EditIconType } from "@autumn/shared"; -import { - CurrencyDollarIcon, - HashIcon, - PackageIcon, - StackIcon, - TagIcon, -} from "@phosphor-icons/react"; -import { cn } from "@/lib/utils"; - -export function getEditIcon(iconType: EditIconType, isUpgrade: boolean) { - const iconProps = { - size: 14, - className: cn("shrink-0", isUpgrade ? "text-green-500" : "text-red-500"), - }; - switch (iconType) { - case "price": - return ; - case "tier": - return ; - case "usage": - return ; - case "units": - return ; - case "prepaid": - return ; - default: - return null; - } -} diff --git a/vite/src/components/forms/update-subscription/update-confirmation-info.tsx b/vite/src/components/forms/update-subscription/update-confirmation-info.tsx deleted file mode 100644 index 370d353d1..000000000 --- a/vite/src/components/forms/update-subscription/update-confirmation-info.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import type { - PreviewUpdateSubscriptionResponse, - ProductV2, -} from "@autumn/shared"; -import type { ReactNode } from "react"; -import { useMemo } from "react"; -import { useHasChanges, usePrepaidItems } from "@/hooks/stores/useProductStore"; -import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; -import type { UseUpdateSubscriptionForm } from "./use-update-subscription-form"; - -export const UpdateConfirmationInfo = ({ - previewData, - product, - form, -}: { - previewData?: PreviewUpdateSubscriptionResponse | null; - product?: ProductV2; - form: UseUpdateSubscriptionForm; -}) => { - const hasChanges = useHasChanges(); - // const hasBillingChanges = useHasBillingChanges({ - // baseProduct: previewData?.current_product, - // newProduct: previewData?.product, - // }); - - const hasPrepaidQuantityChanges = useHasPrepaidQuantityChanges(product, form); - - const renderInfoBoxes = (): ReactNode[] => { - const boxes: ReactNode[] = []; - - if (!previewData) { - return boxes; - } - - // Plan customization notice - if (hasChanges) { - boxes.push( - - This plan has been customized for this customer - , - ); - } - - // Version change notice - // if (previewData.current_product?.version !== previewData.product.version) { - // boxes.push( - // - // You're switching from v{previewData.current_product?.version} to v - // {previewData.product.version} of this plan - // , - // ); - // } - - // Prepaid quantity changes notice - if (hasPrepaidQuantityChanges) { - boxes.push( - - Prepaid quantities have been updated - , - ); - } - - // No billing changes notice - // if (!hasBillingChanges && !hasPrepaidQuantityChanges) { - // boxes.push( - // - // No changes to billing will be made - // , - // ); - // } - - // Free trial updated - // if (previewData.product.free_trial) { - // const trialEndDate = previewData.next_cycle?.starts_at - // ? formatUnixToDate(previewData.next_cycle.starts_at) - // : null; - - // boxes.push( - // - // Free trial updated - // {trialEndDate && ( - // <> - // {" "} - // - trial ends {trialEndDate} - // - // )} - // , - // ); - // } - - return boxes; - }; - - const infoBoxes = renderInfoBoxes(); - - if (infoBoxes.length === 0) { - return null; - } - - return ( -
- {infoBoxes.map((box, index) => ( -
{box}
- ))} -
- ); -}; - -const useHasPrepaidQuantityChanges = ( - product: ProductV2 | undefined, - form: UseUpdateSubscriptionForm, -) => { - const { prepaidItems } = usePrepaidItems({ product }); - const currentPrepaidOptions = form.state.values.prepaidOptions; - const defaultPrepaidOptions = form.options.defaultValues?.prepaidOptions; - - return useMemo(() => { - if (prepaidItems.length === 0 || !currentPrepaidOptions) { - return false; - } - - return prepaidItems.some((item) => { - const currentQuantity = currentPrepaidOptions[item.feature_id as string]; - const defaultQuantity = - defaultPrepaidOptions?.[item.feature_id as string]; - return currentQuantity !== defaultQuantity; - }); - }, [prepaidItems, currentPrepaidOptions, defaultPrepaidOptions]); -}; diff --git a/vite/src/components/forms/update-subscription/use-update-subscription-form.ts b/vite/src/components/forms/update-subscription/use-update-subscription-form.ts deleted file mode 100644 index 9f8c86088..000000000 --- a/vite/src/components/forms/update-subscription/use-update-subscription-form.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useAppForm } from "@/hooks/form/form"; -import { - type AttachProductForm, - AttachProductFormSchema, -} from "../attach-product/attach-product-form-schema"; - -export function useUpdateSubscriptionForm({ - initialProductId, - initialPrepaidOptions, -}: { - initialProductId?: string; - initialPrepaidOptions?: Record; -} = {}) { - return useAppForm({ - defaultValues: { - productId: initialProductId || "", - prepaidOptions: initialPrepaidOptions ?? {}, - } as AttachProductForm, - validators: { - onChange: AttachProductFormSchema, - onSubmit: AttachProductFormSchema, - }, - }); -} - -// Subscribe to form changes and clear prepaid options when productId changes -// Prevents stale prepaid options from causing "no prepaid price found" in the `checkout` call -function useResetPrepaidOnProductChange({ - form, -}: { - form: UseUpdateSubscriptionForm; -}) { - const previousProductIdRef = useRef(); - - useEffect(() => { - const subscription = form.store.subscribe(() => { - const currentProductId = form.store.state.values.productId; - - if ( - previousProductIdRef.current !== undefined && - previousProductIdRef.current !== currentProductId - ) { - form.setFieldValue("prepaidOptions", {}); - } - previousProductIdRef.current = currentProductId; - }); - - return () => subscription(); - }, [form.store, form.setFieldValue]); -} - -export type UseUpdateSubscriptionForm = ReturnType< - typeof useUpdateSubscriptionForm ->; diff --git a/vite/src/components/general/ToggleButton.tsx b/vite/src/components/general/ToggleButton.tsx deleted file mode 100644 index ea33993c7..000000000 --- a/vite/src/components/general/ToggleButton.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Check } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Button } from "../ui/button"; -import { Switch } from "../ui/switch"; -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -import { InfoTooltip } from "./modal-components/InfoTooltip"; - -export const ToggleButton = ({ - value, - setValue, - tooltipContent, - buttonText, - className, - disabled, - infoContent, - switchClassName, -}: { - value: boolean; - setValue: (value: boolean) => void; - tooltipContent?: string; - buttonText?: string | React.ReactNode; - className?: string; - disabled?: boolean; - infoContent?: string; - switchClassName?: string; -}) => { - const MainButton = ( - - ); - - if (tooltipContent) { - return ( - - {MainButton} - {tooltipContent} - - ); - } - - return MainButton; -}; diff --git a/vite/src/components/general/form/form-wrapper.tsx b/vite/src/components/general/form/form-wrapper.tsx deleted file mode 100644 index 233619e05..000000000 --- a/vite/src/components/general/form/form-wrapper.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { AnyFormApi } from "@tanstack/react-form"; -import { cn } from "@/lib/utils"; - -export function FormWrapper({ - form, - className, - children, -}: { - form: AnyFormApi; - className?: string; - children: React.ReactNode; -}) { - return ( -
{ - e.preventDefault(); - form.handleSubmit(); - }} - > - {children} -
- ); -} diff --git a/vite/src/components/ui/select.tsx b/vite/src/components/ui/select.tsx deleted file mode 100644 index 22abb42f7..000000000 --- a/vite/src/components/ui/select.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import * as SelectPrimitive from "@radix-ui/react-select"; -import { CheckIcon, ChevronDownIcon, ChevronUpIcon, X } from "lucide-react"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; -import { Button } from "./button"; - -function Select({ - ...props -}: React.ComponentProps) { - return ; -} - -function SelectGroup({ - ...props -}: React.ComponentProps) { - return ; -} - -function SelectValue({ - ...props -}: React.ComponentProps) { - return ; -} - -function SelectTrigger({ - className, - children, - iconClassName, - onClear, - ...props -}: React.ComponentProps & { - iconClassName?: string; - onClear?: () => void; -}) { - if (onClear) { - return ( -
- span]:line-clamp-1 dark:border-zinc-800 dark:ring-offset-zinc-950 dark:placeholder:text-zinc-400 dark:focus:ring-zinc-300 - h-8 - - data-[state=open]:border-focus data-[state=open]:shadow-focus - focus:ring-0 - data-[placeholder]:text-t3 - transition-colors duration-100 - p-2 - `, - className, - )} - {...props} - > - {children} - - - - - {onClear && ( -
- -
- )} -
- ); - } - - return ( - span]:line-clamp-1 dark:border-zinc-800 dark:ring-offset-zinc-950 dark:placeholder:text-zinc-400 dark:focus:ring-zinc-300 -h-8 - -data-[state=open]:border-focus data-[state=open]:shadow-focus -focus:ring-0 - -data-[placeholder]:text-t3 - -transition-colors duration-100 -p-2 -`, - className, - )} - {...props} - > - {children} - - - - - ); -} - -function SelectContent({ - className, - children, - position = "popper", - ...props -}: React.ComponentProps) { - return ( - - - - - {children} - - - - - ); -} - -function SelectLabel({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function SelectItem({ - className, - children, - endComponent, - ...props -}: React.ComponentProps & { - endComponent?: React.ReactNode; -}) { - return ( - - - {endComponent ? ( - endComponent - ) : ( - - - - )} - - - {children} - - ); -} - -function SelectSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function SelectScrollUpButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ); -} - -function SelectScrollDownButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ); -} - -export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }; diff --git a/vite/src/hooks/useMounted.ts b/vite/src/hooks/useMounted.ts deleted file mode 100644 index 09f269340..000000000 --- a/vite/src/hooks/useMounted.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect, useState } from "react"; - -/** - * Hook that returns true after the component has mounted and the browser has completed a paint cycle. - * Useful for deferring rendering until layout is stable, preventing visual glitches on navigation. - */ -export function useMounted(): boolean { - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - const frame = requestAnimationFrame(() => { - setIsMounted(true); - }); - return () => cancelAnimationFrame(frame); - }, []); - - return isMounted; -} diff --git a/vite/src/views/customers/customer/analytics/hooks/useTopEventNames.tsx b/vite/src/views/customers/customer/analytics/hooks/useTopEventNames.tsx deleted file mode 100644 index 12afdc92c..000000000 --- a/vite/src/views/customers/customer/analytics/hooks/useTopEventNames.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; - -export const useTopEventNames = () => { - const axiosInstance = useAxiosInstance(); - const buildKey = useQueryKeyFactory(); - - const { - data: eventNamesData, - isLoading: eventNamesLoading, - error: eventNamesError, - } = useQuery({ - queryKey: buildKey(["query-event-names"]), - queryFn: async () => { - const { data } = await axiosInstance.get("/query/event_names"); - return data; - }, - }); - - return { - topEvents: { - featureIds: eventNamesData?.featureIds ?? [], - eventNames: eventNamesData?.eventNames ?? [], - }, - isLoading: eventNamesLoading, - error: eventNamesError, - }; -}; diff --git a/vite/src/views/customers/customer/analytics/utils/getAllEventNames.ts b/vite/src/views/customers/customer/analytics/utils/getAllEventNames.ts deleted file mode 100644 index 6299f515c..000000000 --- a/vite/src/views/customers/customer/analytics/utils/getAllEventNames.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type Feature, FeatureType, FeatureUsageType } from "@autumn/shared"; - -export const getAllEventNames = ({ features }: { features: Feature[] }) => { - return features.flatMap((feature: Feature) => { - if (feature.type !== FeatureType.Metered) return []; - const eventNames = feature.event_names || []; - - return eventNames.filter( - (name: string) => - !features.some( - (f: Feature) => - f.id == name && f.config.usage_type == "continuous_use", - ), - ); - }); -}; - -export const eventNameBelongsToFeature = ({ - eventName, - features, -}: { - eventName: string; - features: Feature[]; -}) => { - return features.some( - (feature: Feature) => - feature.type === FeatureType.Metered && - feature.config.usage_type === FeatureUsageType.Single && - feature.event_names && - feature.event_names.includes(eventName), - ); -}; diff --git a/vite/src/views/customers/customer/product/components/attachProductUtils.ts b/vite/src/views/customers/customer/product/components/attachProductUtils.ts deleted file mode 100644 index e1ee8f40e..000000000 --- a/vite/src/views/customers/customer/product/components/attachProductUtils.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { FeatureOptions, ProductV2 } from "@autumn/shared"; - -// export type FrontendProduct = ProductV2 & { -// isActive: boolean; -// options: FeatureOptions[]; -// isCanceled: boolean; -// }; - -export const getAttachBody = ({ - customerId, - product, - entityId, - optionsInput, - useInvoice, - enableProductImmediately = true, - successUrl, - version, - isCustom = false, -}: { - customerId: string; - product: ProductV2; - entityId?: string; - optionsInput?: FeatureOptions[]; - useInvoice?: boolean; - enableProductImmediately?: boolean; - successUrl?: string; - version?: number; - isCustom?: boolean; -}) => { - const customData = isCustom - ? { - items: product.items, - free_trial: product.free_trial, - } - : {}; - - return { - customer_id: customerId, - product_id: product.id, - entity_id: entityId || undefined, - options: optionsInput - ? optionsInput.map((option) => ({ - feature_id: option.feature_id, - quantity: option.quantity || 0, - })) - : undefined, - is_custom: isCustom, - ...customData, - free_trial: isCustom ? product.free_trial || undefined : undefined, - - invoice: useInvoice, - enable_product_immediately: useInvoice - ? enableProductImmediately - : undefined, - finalize_invoice: useInvoice ? false : undefined, - - force_checkout: - useInvoice && enableProductImmediately === false ? true : undefined, - - success_url: successUrl, - version: version ? Number(version) : undefined, - }; -}; diff --git a/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTable.tsx b/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTable.tsx deleted file mode 100644 index ac8c849f2..000000000 --- a/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTable.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { FullCusEntWithFullCusProduct } from "@autumn/shared"; -import { useMemo } from "react"; -import { Table } from "@/components/general/table"; -import { useCustomerTable } from "@/views/customers2/hooks/useCustomerTable"; -import { CustomerBooleanBalanceTableColumns } from "./CustomerBooleanBalanceTableColumns"; - -export function CustomerBooleanBalanceTable({ - allEnts, - aggregatedMap, - isLoading, -}: { - allEnts: FullCusEntWithFullCusProduct[]; - aggregatedMap: Map; - isLoading: boolean; -}) { - const columns = useMemo( - () => - CustomerBooleanBalanceTableColumns({ - aggregatedMap, - }), - [aggregatedMap], - ); - - const enableSorting = false; - const table = useCustomerTable({ - data: allEnts, - columns, - options: {}, - }); - - return ( - - - - - - - - ); -} diff --git a/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTableColumns.tsx b/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTableColumns.tsx deleted file mode 100644 index 023f4522f..000000000 --- a/vite/src/views/customers2/components/table/customer-boolean-balance/CustomerBooleanBalanceTableColumns.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { FullCusEntWithFullCusProduct } from "@autumn/shared"; -import type { Row } from "@tanstack/react-table"; -import { CustomerFeatureConfiguration } from "../customer-feature-usage/CustomerFeatureConfiguration"; - -export const CustomerBooleanBalanceTableColumns = ({ - aggregatedMap, -}: { - aggregatedMap: Map; -}) => [ - { - header: "Feature", - size: 200, - accessorKey: "feature", - cell: ({ row }: { row: Row }) => { - const ent = row.original; - const featureId = ent.entitlement.feature.id; - const originalEnts = aggregatedMap.get(featureId); - const isAggregated = originalEnts && originalEnts.length > 1; - const balanceCount = originalEnts?.length || 1; - - return ( -
- - {ent.entitlement.feature.name} - - {isAggregated && ( -
- {balanceCount} -
- )} -
- ); - }, - }, - { - header: "Type", - size: 200, - accessorKey: "type", - cell: ({ row }: { row: Row }) => { - const ent = row.original; - - return ( -
- -
- ); - }, - }, -]; diff --git a/vite/src/views/onboarding4/steps/AttachStep.tsx b/vite/src/views/onboarding4/steps/AttachStep.tsx deleted file mode 100644 index 183b0d76b..000000000 --- a/vite/src/views/onboarding4/steps/AttachStep.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useMemo, useState } from "react"; -import { StepBadge } from "@/components/v2/badges/StepBadge"; -import { GroupedTabButton } from "@/components/v2/buttons/GroupedTabButton"; -import { getSnippet, type Snippet } from "@/lib/snippets"; -import { SnippetCodeBlock } from "./SnippetCodeBlock"; - -interface AttachStepProps { - snippet: Snippet; - stepNumber: number; -} - -export function AttachStep({ snippet: _, stepNumber }: AttachStepProps) { - const [attachMode, setAttachMode] = useState<"pricing-table" | "custom">( - "pricing-table", - ); - - // Get snippets based on mode - const pricingTableSnippet = useMemo( - () => - getSnippet({ - id: "attach-pricing-table", - sdk: "react", - }), - [], - ); - - const billingStateSnippet = useMemo( - () => - getSnippet({ - id: "billing-state", - sdk: "react", - }), - [], - ); - - const checkoutSnippet = useMemo( - () => - getSnippet({ - id: "checkout", - sdk: "react", - }), - [], - ); - - return ( -
- {/* Mode selector above steps */} - - setAttachMode(val as "pricing-table" | "custom") - } - options={[ - { - value: "pricing-table", - label: "Use ", - }, - { - value: "custom", - label: "Build your own", - }, - ]} - /> - - {attachMode === "pricing-table" ? ( - /* Single step for PricingTable */ -
-
- {stepNumber} - - {pricingTableSnippet.title} - -
-

- {pricingTableSnippet.description} -

-
- -
-
- ) : ( - /* Two steps for Build your own */ - <> - {/* Step 1: Billing State */} -
-
- {stepNumber} - - {billingStateSnippet.title} - -
-

- {billingStateSnippet.description} -

-
- -
-
- - {/* Step 2: Checkout */} -
-
- {stepNumber + 1} - - {checkoutSnippet.title} - -
-

- {checkoutSnippet.description} -

-
- -
-
- - )} -
- ); -} diff --git a/vite/src/views/onboarding4/templateConfigs.ts b/vite/src/views/onboarding4/templateConfigs.ts deleted file mode 100644 index 010e7f9fb..000000000 --- a/vite/src/views/onboarding4/templateConfigs.ts +++ /dev/null @@ -1,286 +0,0 @@ -export interface PricingTier { - name: string; - price: string; - interval?: string; - description?: string; - features: string[]; - highlighted?: boolean; -} - -interface TemplateConfig { - id: string; - name: string; - company: string; - tags: string[]; - description: string; - pricingTiers: PricingTier[]; - websiteUrl: string; -} - -const TEMPLATE_CONFIGS: TemplateConfig[] = [ - { - id: "cursor", - name: "Cursor", - company: "Cursor", - tags: ["Usage-based", "Trial", "Freemium"], - description: - "AI-powered code editor with a generous free tier and usage-based premium features. Users get free completions and can upgrade for more advanced AI capabilities with metered usage.", - pricingTiers: [ - { - name: "Hobby", - price: "Free", - description: "For casual developers", - features: [ - "2000 completions", - "50 slow premium requests", - "200 cursor-small uses", - ], - }, - { - name: "Pro", - price: "$20", - interval: "month", - description: "For professional developers", - features: [ - "Unlimited completions", - "500 fast premium requests", - "Unlimited slow premium requests", - "Unlimited cursor-small uses", - ], - highlighted: true, - }, - { - name: "Business", - price: "$40", - interval: "user/month", - description: "For teams", - features: [ - "Everything in Pro", - "Centralized billing", - "Admin dashboard", - "Enforce privacy mode", - "SAML/OIDC SSO", - ], - }, - ], - websiteUrl: "https://cursor.com/pricing", - }, - { - id: "railway", - name: "Railway", - company: "Railway", - tags: ["Credits", "Usage-based", "Pay-as-you-go"], - description: - "Infrastructure platform with credit-based pricing. Users receive monthly credits and pay for additional usage based on compute, memory, and egress consumption.", - pricingTiers: [ - { - name: "Hobby", - price: "Free", - description: "For personal projects", - features: [ - "$5 of usage per month", - "Limited to 500 execution hours", - "Community support", - ], - }, - { - name: "Pro", - price: "$20", - interval: "user/month", - description: "For teams and startups", - features: [ - "Includes $10 of usage", - "Unlimited execution hours", - "Team collaboration", - "Priority support", - ], - highlighted: true, - }, - { - name: "Enterprise", - price: "Custom", - description: "For large organizations", - features: [ - "Volume discounts", - "Dedicated support", - "SLA guarantees", - "Custom contracts", - ], - }, - ], - websiteUrl: "https://railway.app/pricing", - }, - { - id: "t3-chat", - name: "T3 Chat", - company: "T3 Chat", - tags: ["Prepaid", "Add-ons", "Subscription"], - description: - "AI chat platform with subscription tiers and prepaid message packs. Users subscribe to a base plan and can purchase additional message credits as needed.", - pricingTiers: [ - { - name: "Free", - price: "Free", - description: "Try it out", - features: ["Limited messages", "Basic models", "Web access only"], - }, - { - name: "Plus", - price: "$8", - interval: "month", - description: "For regular users", - features: [ - "1000 messages/month", - "All models", - "Mobile app access", - "Message history", - ], - highlighted: true, - }, - { - name: "Message Pack", - price: "$5", - description: "Add-on", - features: ["500 additional messages", "Never expires", "Use anytime"], - }, - ], - websiteUrl: "https://t3.chat", - }, - { - id: "openai", - name: "OpenAI API", - company: "OpenAI", - tags: ["Credits", "Prepaid", "Pay-as-you-go"], - description: - "API platform with prepaid credits and pay-as-you-go pricing. Developers purchase credits upfront and consume them based on token usage across different models.", - pricingTiers: [ - { - name: "Free Tier", - price: "Free", - description: "Get started", - features: [ - "$5 free credits", - "Rate limited", - "Access to GPT-3.5", - "3 months expiry", - ], - }, - { - name: "Pay as you go", - price: "Usage-based", - description: "For developers", - features: [ - "All models access", - "Higher rate limits", - "Pay per token", - "No monthly commitment", - ], - highlighted: true, - }, - { - name: "Enterprise", - price: "Custom", - description: "For organizations", - features: [ - "Volume discounts", - "Dedicated capacity", - "Custom models", - "Enterprise support", - ], - }, - ], - websiteUrl: "https://openai.com/pricing", - }, - { - id: "notion", - name: "Notion", - company: "Notion", - tags: ["Per-seat", "Add-ons", "Freemium"], - description: - "Workspace platform with per-seat pricing and AI add-ons. Teams pay per member with optional AI features available as an additional subscription.", - pricingTiers: [ - { - name: "Free", - price: "Free", - description: "For individuals", - features: [ - "Unlimited pages", - "Share with 10 guests", - "7 day page history", - "Basic integrations", - ], - }, - { - name: "Plus", - price: "$10", - interval: "user/month", - description: "For small teams", - features: [ - "Unlimited team members", - "Unlimited file uploads", - "30 day page history", - "100 guest collaborators", - ], - highlighted: true, - }, - { - name: "AI Add-on", - price: "$8", - interval: "user/month", - description: "Add-on", - features: [ - "AI writing assistant", - "AI autofill", - "AI summaries", - "Works on any plan", - ], - }, - ], - websiteUrl: "https://notion.so/pricing", - }, - { - id: "lovable", - name: "Lovable", - company: "Lovable", - tags: ["Prepaid", "Credits", "Subscription"], - description: - "AI app builder with prepaid credit packs. Users subscribe to plans with included credits and can purchase additional credit packs for more generation capacity.", - pricingTiers: [ - { - name: "Free", - price: "Free", - description: "Try it out", - features: [ - "Limited credits", - "1 project", - "Community support", - "Basic features", - ], - }, - { - name: "Starter", - price: "$20", - interval: "month", - description: "For builders", - features: [ - "100 credits/month", - "5 projects", - "Priority support", - "All features", - ], - highlighted: true, - }, - { - name: "Credit Pack", - price: "$10", - description: "Add-on", - features: [ - "50 additional credits", - "Never expires", - "Use across projects", - ], - }, - ], - websiteUrl: "https://lovable.dev/pricing", - }, -];