chore: knippy knip knip
This commit is contained in:
2
.github/workflows/knip.yml
vendored
2
.github/workflows/knip.yml
vendored
@@ -25,4 +25,4 @@ jobs:
|
||||
run: bun install
|
||||
|
||||
- name: Run Knip
|
||||
run: bun knip --no-exit-code
|
||||
run: bun knip
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
bun knip
|
||||
cd server && bun ts
|
||||
(cd server && bun ts)
|
||||
|
||||
changed_files="$(git diff --cached --name-only)"
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
27
knip.json
27
knip.json
@@ -16,18 +16,25 @@
|
||||
"entry": ["apps/scope-picker/**/*"]
|
||||
},
|
||||
"server": {
|
||||
"entry": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"entry": [
|
||||
"src/internal/customers/cusUtils/createNewCustomer.ts",
|
||||
"src/utils/importUtils/addProductFromSubs.ts",
|
||||
"src/utils/scriptUtils/readOnlyStripe.ts",
|
||||
"src/utils/scriptUtils/scriptUtils.ts",
|
||||
"src/utils/scriptUtils/getAll/getAllAutumnCustomers.ts",
|
||||
"src/utils/scriptUtils/getAll/getAllOrgs.ts",
|
||||
"tests/**/*.ts"
|
||||
],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"ignoreDependencies": ["@react-email/components", "@axiomhq/pino"]
|
||||
},
|
||||
"shared": {
|
||||
"entry": ["**/*.ts", "!utils/**"],
|
||||
"project": ["**/*.ts", "!utils/**"],
|
||||
"includeEntryExports": false
|
||||
},
|
||||
"vite": {
|
||||
"entry": ["src/**/*.{ts,tsx}"],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"entry": ["tests/**/*.{ts,tsx}"],
|
||||
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"],
|
||||
"ignore": ["src/components/ai-elements/**"],
|
||||
"ignoreDependencies": [
|
||||
"tailwindcss",
|
||||
@@ -53,32 +60,30 @@
|
||||
"project": ["**/*.{ts,tsx,mdx,mjs}"]
|
||||
},
|
||||
"apps/checkout": {
|
||||
"entry": ["src/**/*.{ts,tsx}"],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignoreDependencies": ["shadcn", "tailwindcss", "tw-animate-css"]
|
||||
},
|
||||
"packages/atmn": {
|
||||
"entry": ["src/**/*.{ts,tsx}"],
|
||||
"entry": ["src/cli.tsx", "src/compose/index.ts"],
|
||||
"project": ["src/**/*.{ts,tsx}", "*.ts"]
|
||||
},
|
||||
"packages/autumn-js": {
|
||||
"entry": ["src/**/*.ts", "*.ts"],
|
||||
"entry": ["src/{sdk,react,backend,better-auth}/index.ts", "src/backend/adapters/*.ts"],
|
||||
"project": ["src/**/*.ts", "*.ts"]
|
||||
},
|
||||
"packages/ksuid": {
|
||||
"entry": ["src/**/*.ts"],
|
||||
"project": ["src/**/*.ts"]
|
||||
},
|
||||
"packages/openapi": {
|
||||
"entry": ["**/*.ts"],
|
||||
"entry": ["scripts/**/*.ts", "v2.1/contracts/index.ts"],
|
||||
"project": ["**/*.ts"]
|
||||
},
|
||||
"packages/sdk": {
|
||||
"entry": ["src/**/*.ts", "examples/**/*.ts"],
|
||||
"entry": ["src/*.ts", "examples/**/*.ts"],
|
||||
"project": ["src/**/*.ts", "examples/**/*.ts"]
|
||||
},
|
||||
"packages/stripe-sync": {
|
||||
"entry": ["src/**/*.ts", "scripts/**/*.ts"],
|
||||
"entry": ["scripts/**/*.ts"],
|
||||
"project": ["src/**/*.ts", "scripts/**/*.ts"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { eventsAggregateTestCommand } from "./command.js";
|
||||
@@ -1,2 +0,0 @@
|
||||
export { pull } from "./pull.js";
|
||||
export type { EnvironmentData, PullOptions, PullResult } from "./types.js";
|
||||
@@ -1,6 +0,0 @@
|
||||
import { render } from "ink";
|
||||
import { TemplateSelector } from "../../views/react/template/TemplateSelector.js";
|
||||
|
||||
export function testTemplateCommand() {
|
||||
render(<TemplateSelector />);
|
||||
}
|
||||
@@ -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];
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./monorepo.js";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { QueryProvider } from "./QueryProvider.js";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,2 +0,0 @@
|
||||
export { FeatureSheet } from "./FeatureSheet.js";
|
||||
export type { FeatureSheetProps } from "./FeatureSheet.js";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ProductSheet } from "./ProductSheet.js";
|
||||
export type { ProductSheetProps } from "./ProductSheet.js";
|
||||
@@ -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";
|
||||
@@ -1 +0,0 @@
|
||||
export { PushView } from "./Push.js";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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 />);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
// })
|
||||
// );
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
51
packages/openapi/v2.0/balancesOpenApi.ts
vendored
51
packages/openapi/v2.0/balancesOpenApi.ts
vendored
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
206
packages/openapi/v2.0/coreOpenApi.ts
vendored
206
packages/openapi/v2.0/coreOpenApi.ts
vendored
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
119
packages/openapi/v2.0/customersOpenApi.ts
vendored
119
packages/openapi/v2.0/customersOpenApi.ts
vendored
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
78
packages/openapi/v2.0/entitiesOpenApi.ts
vendored
78
packages/openapi/v2.0/entitiesOpenApi.ts
vendored
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
62
packages/openapi/v2.0/eventsOpenApi.ts
vendored
62
packages/openapi/v2.0/eventsOpenApi.ts
vendored
@@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
151
packages/openapi/v2.0/openapi2.0.ts
vendored
151
packages/openapi/v2.0/openapi2.0.ts
vendored
@@ -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");
|
||||
};
|
||||
111
packages/openapi/v2.0/plansOpenApi.ts
vendored
111
packages/openapi/v2.0/plansOpenApi.ts
vendored
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
63
packages/openapi/v2.0/referralsOpenApi.ts
vendored
63
packages/openapi/v2.0/referralsOpenApi.ts
vendored
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
542
server/src/external/autumn/autumnCliV2.ts
vendored
542
server/src/external/autumn/autumnCliV2.ts
vendored
@@ -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);
|
||||
};
|
||||
}
|
||||
26
server/src/external/redis/loadCaCert.ts
vendored
26
server/src/external/redis/loadCaCert.ts
vendored
@@ -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;
|
||||
}
|
||||
};
|
||||
283
server/src/external/redis/redisFailover.ts
vendored
283
server/src/external/redis/redisFailover.ts
vendored
@@ -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;
|
||||
}
|
||||
};
|
||||
7
server/src/external/redis/utils/index.ts
vendored
7
server/src/external/redis/utils/index.ts
vendored
@@ -1,7 +0,0 @@
|
||||
export { RedisUnavailableError } from "./errors.js";
|
||||
export {
|
||||
runRedisOp,
|
||||
tryRedisOp,
|
||||
type UnavailableReason,
|
||||
} from "./runRedisOp.js";
|
||||
export { withRedisFailOpen } from "./withRedisFailOpen.js";
|
||||
@@ -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);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { findSubscriptionItemByAutumnPrice } from "@/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice";
|
||||
|
||||
export const stripeSubscriptionItemUtils = {
|
||||
find: {
|
||||
byAutumnPrice: findSubscriptionItemByAutumnPrice,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { handleStripeInvoiceFinalized } from "./handleStripeInvoiceFinalized";
|
||||
export type { InvoiceFinalizedContext } from "./setupInvoiceFinalizedContext";
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
// });
|
||||
@@ -1 +0,0 @@
|
||||
export const customerEntitlementRepo = {};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
// });
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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[]>;
|
||||
};
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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})` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user