chore: knippy knip knip

This commit is contained in:
Charlie Lamb
2026-04-28 11:42:07 +01:00
parent be8a11686f
commit aa370ccf84
136 changed files with 22 additions and 10370 deletions

View File

@@ -25,4 +25,4 @@ jobs:
run: bun install
- name: Run Knip
run: bun knip --no-exit-code
run: bun knip

View File

@@ -1,5 +1,5 @@
bun knip
cd server && bun ts
(cd server && bun ts)
changed_files="$(git diff --cached --name-only)"

View File

@@ -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 (
<div className="bg-card relative overflow-hidden">
{/* Top-right diagonal gradient */}
<motion.div
className="absolute inset-0 pointer-events-none bg-[linear-gradient(135deg,color-mix(in_oklch,var(--foreground)_4%,var(--background))_0%,transparent_50%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
transition={{ duration: 0.6 }}
/>
{/* Bottom-left diagonal gradient (lighter) */}
<motion.div
className="absolute inset-0 pointer-events-none bg-[linear-gradient(315deg,color-mix(in_oklch,var(--foreground)_2%,var(--background))_0%,transparent_45%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 0.2 }}
transition={{ duration: 0.6, delay: 0.1 }}
/>
<div className="relative z-10">
{children}
</div>
</div>
);
}

View File

@@ -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 (
<div
data-slot="card"
data-size={size}
className={cn(
"text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm has-data-[slot=card-footer]:pb-0 has-[>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 (
<div
data-slot="card-header"
className={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

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

View File

@@ -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<string, { count: number; totalValue: number }>();
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<string, number>();
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`));
}

View File

@@ -1 +0,0 @@
export { eventsAggregateTestCommand } from "./command.js";

View File

@@ -1,2 +0,0 @@
export { pull } from "./pull.js";
export type { EnvironmentData, PullOptions, PullResult } from "./types.js";

View File

@@ -1,6 +0,0 @@
import { render } from "ink";
import { TemplateSelector } from "../../views/react/template/TemplateSelector.js";
export function testTemplateCommand() {
render(<TemplateSelector />);
}

View File

@@ -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<string, PlanData[]> = {
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];

View File

@@ -1 +0,0 @@
export * from "./monorepo.js";

View File

@@ -1,12 +0,0 @@
import { Box, Text } from "ink";
export default function App() {
return (
<Box flexDirection="column">
<Text color="cyan" bold>
atmn
</Text>
<Text dimColor>Autumn CLI - Interactive Mode</Text>
</Box>
);
}

View File

@@ -1 +0,0 @@
export { QueryProvider } from "./QueryProvider.js";

View File

@@ -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 (
<Box>
<Text color={markerColor}>{marker}</Text>
<Box width={colId}>
<Text bold={isSelected} dimColor={!isSelected}>
{shouldTruncate
? truncate(customer.id, colId - 1)
: customer.id || "-"}
</Text>
</Box>
<Box width={colName} marginLeft={1}>
<Text bold={isSelected} dimColor={!isSelected}>
{shouldTruncate
? truncate(customer.name, colName - 1)
: customer.name || "-"}
</Text>
</Box>
<Box width={colEmail} marginLeft={1}>
<Text bold={isSelected} dimColor={!isSelected}>
{shouldTruncate
? truncate(customer.email, colEmail - 1)
: customer.email || "-"}
</Text>
</Box>
<Box width={colCreated} marginLeft={1}>
<Text bold={isSelected} dimColor={!isSelected}>
{formatDate(customer.created_at)}
</Text>
</Box>
</Box>
);
}
export interface CustomerTableHeaderProps {
columnWidths: ColumnWidths;
}
/**
* Table header row
*/
export function CustomerTableHeader({
columnWidths,
}: CustomerTableHeaderProps) {
const { colId, colName, colEmail, colCreated } = columnWidths;
return (
<Box marginBottom={0}>
<Text color="gray">{" "}</Text>
<Box width={colId}>
<Text color="gray" bold>
ID
</Text>
</Box>
<Box width={colName} marginLeft={1}>
<Text color="gray" bold>
Name
</Text>
</Box>
<Box width={colEmail} marginLeft={1}>
<Text color="gray" bold>
Email
</Text>
</Box>
<Box width={colCreated} marginLeft={1}>
<Text color="gray" bold>
Created
</Text>
</Box>
</Box>
);
}

View File

@@ -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<ScrollListRef>(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 (
<Box flexDirection="column" flexGrow={1}>
<CustomerTableHeader columnWidths={columnWidths} />
<Box flexDirection="column" flexGrow={1}>
<ScrollList ref={listRef} selectedIndex={selectedIndex}>
{customers.map((customer, index) => (
<CustomerRow
key={customer.id}
customer={customer}
isSelected={index === selectedIndex}
isFocused={isFocused}
columnWidths={columnWidths}
/>
))}
</ScrollList>
</Box>
</Box>
);
}

View File

@@ -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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={2}
paddingY={1}
width="100%"
minHeight={10}
alignItems="center"
justifyContent="center"
>
<Text>🔍</Text>
<Box marginTop={1}>
<Text bold>No results for "{searchQuery}"</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>
Try a different search term or press <Text color="magenta">x</Text>{" "}
to clear the search.
</Text>
</Box>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="magenta"
paddingX={2}
paddingY={1}
width="100%"
minHeight={15}
alignItems="center"
justifyContent="center"
>
<Text>📭</Text>
<Box marginTop={1}>
<Text bold>No customers found</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>
There are no customers in your {envLabel} environment yet.
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>
Create customers via the API or dashboard to see them here.
</Text>
</Box>
</Box>
);
}

View File

@@ -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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor="red"
paddingX={2}
paddingY={1}
>
<Box>
<Text color="red" bold>
Error loading customers
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>{error.message}</Text>
</Box>
<Box marginTop={1}>
<Text>
Press <Text color="magenta">r</Text> to retry or{" "}
<Text color="magenta">q</Text> to quit
</Text>
</Box>
</Box>
);
}

View File

@@ -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 (
<Box
borderStyle="round"
borderColor="gray"
paddingX={1}
width="100%"
justifyContent="center"
gap={2}
>
<Text>
<Text color="magenta">Tab</Text>
<Text color="gray"> focus table</Text>
</Text>
<Text>
<Text color="magenta">Esc</Text>
<Text color="gray"> close</Text>
</Text>
<Text>
<Text color="magenta">c</Text>
<Text color="gray"> copy ID</Text>
</Text>
<Text>
<Text color="magenta">o</Text>
<Text color="gray"> open</Text>
</Text>
<Text>
<Text color="magenta">q</Text>
<Text color="gray"> quit</Text>
</Text>
</Box>
);
}
// Table focused hints
return (
<Box
borderStyle="round"
borderColor="gray"
paddingX={1}
width="100%"
justifyContent="center"
gap={2}
>
<Text>
<Text color="magenta"></Text>
<Text color="gray"> navigate</Text>
</Text>
{canGoPrev && (
<Text>
<Text color="magenta"></Text>
<Text color="gray"> prev page</Text>
</Text>
)}
{canGoNext && (
<Text>
<Text color="magenta"></Text>
<Text color="gray"> next page</Text>
</Text>
)}
<Text>
<Text color="magenta">Enter</Text>
<Text color="gray"> inspect</Text>
</Text>
<Text>
<Text color="magenta">/</Text>
<Text color="gray"> search</Text>
</Text>
<Text>
<Text color="magenta">r</Text>
<Text color="gray"> refresh</Text>
</Text>
<Text>
<Text color="magenta">q</Text>
<Text color="gray"> quit</Text>
</Text>
</Box>
);
}

View File

@@ -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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor="gray"
paddingX={2}
paddingY={1}
>
<Box>
<Text color="magenta">
<Spinner type="dots" />
</Text>
<Text> Loading customers from {envLabel}...</Text>
</Box>
</Box>
);
}

View File

@@ -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 (
<Box borderStyle="round" borderColor="magenta" paddingX={1} width="100%">
<Text color="magenta">Search: </Text>
<TextInput
value={value}
onChange={setValue}
placeholder="id, name, or email..."
/>
<Text color="gray"> (Enter to search, Esc to cancel)</Text>
</Box>
);
}

View File

@@ -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 (
<Box
borderStyle="round"
borderColor="gray"
paddingX={1}
width="100%"
justifyContent="center"
>
<Text color="gray">{APP_VERSION}</Text>
<Text color="gray"> </Text>
<Text bold color="white">
atmn customers
</Text>
<Text color="gray"> </Text>
<Text color="gray">{pagination.display}</Text>
{searchQuery && (
<>
<Text color="gray"> </Text>
<Text color="magenta">search: </Text>
<Text color="white">{searchQuery}</Text>
<Text color="gray"> (x to clear)</Text>
</>
)}
</Box>
);
}

View File

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

View File

@@ -1,2 +0,0 @@
export { FeatureSheet } from "./FeatureSheet.js";
export type { FeatureSheetProps } from "./FeatureSheet.js";

View File

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

View File

@@ -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<AgentState>("selecting");
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
const [error, setError] = useState<string | null>(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 (
<Box flexDirection="column" marginBottom={1}>
<StepHeader step={step} totalSteps={totalSteps} title="Agent Setup" />
<Box flexDirection="column">
<Text>Select agent configuration files to create/update:</Text>
<Text dimColor>(Space to select, Enter to confirm)</Text>
<Box marginTop={1}>
<MultiSelect
options={options}
onChange={handleChange}
onSubmit={handleSubmit}
visibleOptionCount={5}
/>
</Box>
</Box>
</Box>
);
}
if (state === "mcp-agents") {
return (
<Box flexDirection="column" marginBottom={1}>
<StepHeader step={step} totalSteps={totalSteps} title="Agent Setup" />
<Box flexDirection="column">
<Text>Which agent(s) are you using?</Text>
<Text dimColor>(Space to select, Enter to confirm)</Text>
<Box marginTop={1}>
<MultiSelect
options={agentOptions}
onChange={handleAgentChange}
onSubmit={handleAgentSubmit}
visibleOptionCount={5}
/>
</Box>
</Box>
</Box>
);
}
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 (
<Box flexDirection="column" marginBottom={1}>
<StepHeader step={step} totalSteps={totalSteps} title="Agent Setup" />
<Box flexDirection="column">
{installMessages.map((msg) => (
<StatusLine
key={msg}
status={msg.includes("Copied") ? "success" : "loading"}
message={msg}
/>
))}
</Box>
</Box>
);
}
if (state === "creating") {
return (
<Box flexDirection="column" marginBottom={1}>
<StepHeader step={step} totalSteps={totalSteps} title="Agent Setup" />
<StatusLine
status="loading"
message={`Creating ${selectedOptions.filter((o) => o !== "mcp").length} file${selectedOptions.filter((o) => o !== "mcp").length !== 1 ? "s" : ""}...`}
/>
</Box>
);
}
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 (
<Box flexDirection="column" marginBottom={1}>
<StepHeader step={step} totalSteps={totalSteps} title="Agent Setup" />
<StatusLine
status="success"
message={
createdFiles.length > 0
? `Created ${createdFiles.join(", ")}`
: "Setup complete"
}
/>
</Box>
);
}
if (state === "error") {
return (
<Box flexDirection="column" marginBottom={1}>
<StepHeader step={step} totalSteps={totalSteps} title="Agent Setup" />
<StatusLine
status="error"
message={error || "Failed to create files"}
/>
</Box>
);
}
return null;
}

View File

@@ -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<StripeState>("pending");
const [stripeError, setStripeError] = useState<string | null>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(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 (
<Box flexDirection="column" marginBottom={1}>
<StepHeader
step={step}
totalSteps={totalSteps}
title="Stripe Connection"
/>
{stripeState === "checking" && (
<StatusLine status="loading" message="Checking Stripe connection..." />
)}
{stripeState === "not_connected" && (
<StatusLine status="loading" message="Opening Stripe Connect..." />
)}
{stripeState === "connecting" && (
<Box flexDirection="column">
<StatusLine
status="loading"
message="Waiting for Stripe connection..."
/>
<Text dimColor>
{" "}
Complete the setup in your browser, then return here.
</Text>
</Box>
)}
{stripeState === "connected" && (
<StatusLine status="success" message="Stripe connected" />
)}
{stripeState === "error" && (
<StatusLine
status="error"
message={stripeError || "Stripe connection failed"}
/>
)}
</Box>
);
}

View File

@@ -1,2 +0,0 @@
export { ProductSheet } from "./ProductSheet.js";
export type { ProductSheetProps } from "./ProductSheet.js";

View File

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

View File

@@ -1 +0,0 @@
export { PushView } from "./Push.js";

View File

@@ -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<TemplateSelectorProps> = ({
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 <Text color="red">No template selected</Text>;
}
const plans = templateData[activeTemplate];
if (!plans) {
return <Text color="red">Invalid template data</Text>;
}
const [plan0, plan1, plan2] = plans;
if (!plan0 || !plan1 || !plan2) {
return <Text color="red">Incomplete plan data</Text>;
}
return (
<Box flexDirection="column" paddingX={2} paddingY={1}>
{/* Template Tabs - single box spanning all cards */}
<Box
flexDirection="row"
borderStyle="round"
borderColor="magenta"
paddingX={2}
paddingY={0}
marginBottom={1}
width={CARDS_TOTAL_WIDTH}
justifyContent="space-between"
>
<Text color="magenta"></Text>
{templates.map((template: string, idx: number) => (
<React.Fragment key={template}>
{idx > 0 && <Text color="gray"></Text>}
<Text color={idx === activeIndex ? "magenta" : "white"} bold>
{" "}
{template}{" "}
</Text>
</React.Fragment>
))}
<Text color="magenta"></Text>
</Box>
{/* Plan Cards - 3 columns, centered within fixed width */}
<Box
flexDirection="row"
width={CARDS_TOTAL_WIDTH}
justifyContent="center"
>
<Box flexDirection="row" gap={1} alignItems="center">
{/* Left Card */}
<Box
borderStyle="round"
borderColor="magenta"
paddingX={2}
paddingY={1}
flexDirection="column"
>
<Text bold color="cyan" dimColor>
{plan0.name}
</Text>
<Box marginTop={1} />
{plan0.features.map((feature: string) => (
<Box key={feature}>
<Text dimColor> {feature}</Text>
</Box>
))}
<Box marginTop={1}>
<Text bold color="green">
{plan0.price}
</Text>
</Box>
</Box>
{/* Center Card */}
<Box
borderStyle="round"
borderColor="magenta"
paddingX={2}
paddingY={1}
flexDirection="column"
>
<Text bold color="magenta">
{plan1.name}
</Text>
{plan1.badge && (
<Text italic color="yellow">
{plan1.badge}
</Text>
)}
<Box marginTop={1} />
{plan1.features.map((feature: string) => (
<Box key={feature}>
<Text> {feature}</Text>
</Box>
))}
<Box marginTop={1}>
<Text bold color="green">
{plan1.price}
</Text>
</Box>
</Box>
{/* Right Card */}
<Box
borderStyle="round"
borderColor="magenta"
paddingX={2}
paddingY={1}
flexDirection="column"
>
<Text bold color="cyan" dimColor>
{plan2.name}
</Text>
<Box marginTop={1} />
{plan2.features.map((feature: string) => (
<Box key={feature}>
<Text dimColor> {feature}</Text>
</Box>
))}
<Box marginTop={1}>
<Text bold color="green">
{plan2.price}
</Text>
</Box>
</Box>
</Box>
</Box>
{/* Hint for controls */}
<Box marginTop={1}>
<Text dimColor>
switch templates Enter to confirm Esc to cancel
</Text>
</Box>
</Box>
);
};

View File

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

View File

@@ -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 (
<AgentStep
step={3}
totalSteps={4}
onComplete={() => {
console.log("Agent setup complete!");
process.exit(0);
}}
/>
);
}
render(<TestAgentStep />);

View File

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

View File

@@ -1,50 +0,0 @@
export const handleFetchResult = async ({
response,
logger,
logError = true,
}: {
response: Response;
logger: Console;
logError?: boolean;
}): Promise<any> => {
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;
}
};

View File

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

View File

@@ -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 = <T>({
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;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>;
}) => {
const methods = [
"get",
"put",
"post",
"delete",
"patch",
"head",
"options",
"trace",
];
const headerParamRef = "#/components/parameters/XApiVersion";
const paths = (openApiDocument.paths ?? {}) as Record<string, unknown>;
for (const pathItem of Object.values(paths)) {
if (!pathItem || typeof pathItem !== "object") continue;
for (const method of methods) {
const operation = (pathItem as Record<string, unknown>)[method];
if (!operation || typeof operation !== "object") continue;
if (!operation) continue;
const operationRecord = operation as Record<string, unknown>;
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<string, unknown>;
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");
};

View File

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

View File

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

View File

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

View File

@@ -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<string, string>;
public baseUrl: string;
public version?: string;
constructor({
apiKey,
secretKey,
baseUrl,
version,
orgConfig,
liveUrl = false,
}: {
apiKey?: string;
secretKey?: string;
baseUrl?: string;
version?: string;
orgConfig?: Partial<OrgConfig>;
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<string, string>).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<string, string>).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 <T = CheckResponseV1>(
params: CheckParams & CheckQuery,
): Promise<T> => {
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);
};
}

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
export { RedisUnavailableError } from "./errors.js";
export {
runRedisOp,
tryRedisOp,
type UnavailableReason,
} from "./runRedisOp.js";
export { withRedisFailOpen } from "./withRedisFailOpen.js";

View File

@@ -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<Stripe.Checkout.Session> => {
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
return stripeCli.checkout.sessions.retrieve(checkoutSessionId);
};

View File

@@ -1,7 +0,0 @@
import { findSubscriptionItemByAutumnPrice } from "@/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice";
export const stripeSubscriptionItemUtils = {
find: {
byAutumnPrice: findSubscriptionItemByAutumnPrice,
},
};

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
export { handleStripeInvoiceFinalized } from "./handleStripeInvoiceFinalized";
export type { InvoiceFinalizedContext } from "./setupInvoiceFinalizedContext";

View File

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

View File

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

View File

@@ -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<ReturnType<typeof initFullCustomerProduct>>[];
customPrices: NonNullable<
Awaited<ReturnType<typeof setupAttachProductContext>>["customPrices"]
>;
customEntitlements: NonNullable<
Awaited<ReturnType<typeof setupAttachProductContext>>["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<MaterializedScheduledPhase[]> => {
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,
),
};
}),
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export const customerEntitlementRepo = {};

View File

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

View File

@@ -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<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
>;
}) => {
const filteredUpdates = Object.fromEntries(
Object.entries(updates).filter(([, value]) => value !== undefined),
) as Partial<
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
>;
if (Object.keys(filteredUpdates).length === 0) {
return entity;
}
return EntityService.update({
db: ctx.db,
internalId: entity.internal_id,
update: filteredUpdates,
});
};

View File

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

View File

@@ -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<HonoEnv>();
honoProductBetaRouter.get("", ...handleListPlans);
// Create a Hono app for products
export const honoProductRouter = new Hono<HonoEnv>();
export const migrationRouter = new Hono<HonoEnv>();
// 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<HonoEnv>();
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);

View File

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

View File

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

View File

@@ -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<any[]>;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
import type { AppEnv } from "@autumn/shared";
import type { ExtendedRequest } from "@/utils/models/Request.js";
export interface HandleCustomerCreatedData {
req: Partial<ExtendedRequest>;
orgId: string;
env: AppEnv;
internalCustomerId: string;
}

View File

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

View File

@@ -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<typeof TrackResponseV1Schema>;
legacyData?: TrackLegacyData;
}): z.infer<typeof TrackResponseV0Schema> => {
return {
success: true,
};
},
});

View File

@@ -1,13 +0,0 @@
import type { ApiBalanceInput } from "@api/customers/cusFeatures/utils/convert/apiBalanceToAllowed";
export const balancesToCheckFeature = ({
balances,
}: {
balances: Record<string, ApiBalanceInput>;
}) => {
return balances.map((balance) => {
return {
featureId: balance.featureId,
};
});
};

View File

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

View File

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

View File

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

View File

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

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