This commit is contained in:
John Yeo
2026-04-28 16:52:46 +01:00
112 changed files with 363 additions and 7060 deletions

View File

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

View File

@@ -3,9 +3,33 @@ name: Server Type Check
on:
pull_request:
permissions:
contents: read
pull-requests: read
jobs:
changes:
name: Check changed files
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- name: Check changed files
id: filter
uses: dorny/paths-filter@6852f92c20ea7fd3b0c25de3b5112db3a98da050
with:
filters: |
server:
- "server/**"
- "shared/**"
- "package.json"
- "bun.lock"
- ".github/workflows/server-typecheck.yml"
typecheck:
name: Type Check
needs: changes
if: needs.changes.outputs.server == 'true'
runs-on: ubuntu-latest
steps:

View File

@@ -2,10 +2,36 @@ name: Server Unit Tests
on:
pull_request:
paths:
- "server/**"
permissions:
contents: read
pull-requests: read
jobs:
changes:
name: Check changed files
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- name: Check changed files
id: filter
uses: dorny/paths-filter@6852f92c20ea7fd3b0c25de3b5112db3a98da050
with:
filters: |
server:
- "server/**"
- "shared/**"
- "package.json"
- "bun.lock"
- ".github/workflows/server-unit-tests.yml"
unit-tests:
name: Unit Tests
needs: changes
if: needs.changes.outputs.server == 'true'
runs-on: ubuntu-latest
steps:

32
.github/workflows/validate-schema.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Validate Production Schema
on:
pull_request:
branches:
- dev
push:
branches:
- dev
jobs:
validate-schema:
name: Validate Schema
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
- name: Install dependencies
run: bun install
- name: Validate schema
env:
DATABASE_URL: ${{ secrets.DATABASE_READ_ONLY_URL }}
DB_MAX_CONNECTIONS: 1
run: bun scripts/migrations/validate-schema.ts

View File

@@ -3,9 +3,35 @@ name: Vite Build Check
on:
pull_request:
permissions:
contents: read
pull-requests: read
jobs:
changes:
name: Check changed files
runs-on: ubuntu-latest
outputs:
vite: ${{ steps.filter.outputs.vite }}
steps:
- name: Check changed files
id: filter
uses: dorny/paths-filter@6852f92c20ea7fd3b0c25de3b5112db3a98da050
with:
filters: |
vite:
- "vite/**"
- "shared/**"
- "packages/atmn/**"
- "packages/autumn-js/**"
- "package.json"
- "bun.lock"
- ".github/workflows/vite-build.yml"
typecheck:
name: Type Check
needs: changes
if: needs.changes.outputs.vite == 'true'
runs-on: ubuntu-latest
steps:
@@ -25,6 +51,8 @@ jobs:
build:
name: Build
needs: changes
if: needs.changes.outputs.vite == 'true'
runs-on: ubuntu-latest
steps:

View File

@@ -2,10 +2,38 @@ name: Vite Unit Tests
on:
pull_request:
paths:
- "vite/**"
permissions:
contents: read
pull-requests: read
jobs:
changes:
name: Check changed files
runs-on: ubuntu-latest
outputs:
vite: ${{ steps.filter.outputs.vite }}
steps:
- name: Check changed files
id: filter
uses: dorny/paths-filter@6852f92c20ea7fd3b0c25de3b5112db3a98da050
with:
filters: |
vite:
- "vite/**"
- "shared/**"
- "packages/atmn/**"
- "packages/autumn-js/**"
- "package.json"
- "bun.lock"
- ".github/workflows/vite-unit-tests.yml"
unit-tests:
name: Unit Tests
needs: changes
if: needs.changes.outputs.vite == 'true'
runs-on: ubuntu-latest
steps:

View File

@@ -1 +1,14 @@
cd server && bun ts
set -e
bun knip
(cd server && bun ts)
changed_files="$(git diff --cached --name-only)"
if printf '%s\n' "$changed_files" | grep -q '^server/'; then
(cd server && bun test tests/unit)
fi
if printf '%s\n' "$changed_files" | grep -q '^vite/'; then
(cd vite && bun test tests/)
fi

View File

@@ -1,32 +0,0 @@
import type { ReactNode } from "react";
import { motion } from "motion/react";
/**
* Full-screen background wrapper with subtle diagonal gradients from primary color.
* Includes entrance animation for the content container.
*/
export function CardBackground({ children }: { children: ReactNode }) {
return (
<div className="bg-card relative overflow-hidden">
{/* Top-right diagonal gradient */}
<motion.div
className="absolute inset-0 pointer-events-none bg-[linear-gradient(135deg,color-mix(in_oklch,var(--foreground)_4%,var(--background))_0%,transparent_50%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
transition={{ duration: 0.6 }}
/>
{/* Bottom-left diagonal gradient (lighter) */}
<motion.div
className="absolute inset-0 pointer-events-none bg-[linear-gradient(315deg,color-mix(in_oklch,var(--foreground)_2%,var(--background))_0%,transparent_45%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 0.2 }}
transition={{ duration: 0.6, delay: 0.1 }}
/>
<div className="relative z-10">
{children}
</div>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const cardVariants = {
default: "ring-foreground/10 bg-card ring-1 shadow-[0_0_10px_2px_rgba(0,0,0,0.02)]",
muted: "bg-muted/50",
}
function Card({
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<"div"> & {
size?: "default" | "sm"
variant?: "default" | "muted"
}) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
cardVariants[variant],
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -8,7 +8,7 @@ import { DynamicResponseField } from "/components/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/components/dynamic-response-example.jsx";
<Note>
The update endpoint modifies an existing subscription. Use this to change prepaid quantities, cancel subscriptions, or modify plan configuration. For subscribing to a new plan, use [attach](/api-reference/billing/billingAttach) instead.
The update endpoint modifies an existing subscription. Use this to change prepaid quantities, cancel subscriptions, or modify plan configuration. For subscribing to a new plan, use [attach](/api-reference/billing/attach) instead.
</Note>
### Common Use Cases

View File

@@ -7,7 +7,7 @@ import { DynamicParamField } from "/components/dynamic-param-field.jsx";
import { DynamicResponseField } from "/components/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/components/dynamic-response-example.jsx";
Creates a new plan with optional base price and feature configurations. See [How plans work](/documentation/pricing/plans) for concepts and [Adding features to plans](/documentation/pricing/plan-features) for item configuration.
Creates a new plan with optional base price and feature configurations. See [How plans work](/documentation/concepts/plans) for concepts and [Adding features to plans](/documentation/concepts/plan-items) for item configuration.
### Plan Configuration

View File

@@ -7,7 +7,7 @@ import { DynamicParamField } from "/components/dynamic-param-field.jsx";
import { DynamicResponseField } from "/components/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/components/dynamic-response-example.jsx";
Updates an existing plan. By default, creates a new version of the plan. See [Adding features to plans](/documentation/pricing/plan-features) for item configuration.
Updates an existing plan. By default, creates a new version of the plan. See [Adding features to plans](/documentation/concepts/plan-items) for item configuration.
<Note>
Updates create a new plan version by default. Existing customers remain on their current version until their subscription renews or they explicitly upgrade.

View File

@@ -61,7 +61,7 @@ You'll be prompted to choose a starter template:
| **Linear** | Per-seat pricing with team limits |
| **OpenAI** | Credit system mapping multiple AI models |
Pick whichever is closest to your use case, or start from scratch and build your own using the [config reference](/api-reference/cli/config).
Pick whichever is closest to your use case, or start from scratch and build your own using the [config reference](/cli/config).
## Push and pull
@@ -115,7 +115,7 @@ Learn how to define features, plans, and pricing in your `autumn.config.ts`.
<Card
title="Configuration reference"
href="/api-reference/cli/config"
href="/cli/config"
>
Complete reference for features, plans, and plan features
</Card>

View File

@@ -12,7 +12,7 @@ flowchart LR
S -->|provisions| B[Balances]
```
When a subscription is created, Autumn provisions [balances](/documentation/customers/balances) for each feature in the plan. Balances determine what the customer can access and track how much they've used. For example, a Pro plan might grant 1,000 API requests per month—this becomes a balance that decrements as the customer uses your product.
When a subscription is created, Autumn provisions [balances](/documentation/concepts/balances) for each feature in the plan. Balances determine what the customer can access and track how much they've used. For example, a Pro plan might grant 1,000 API requests per month—this becomes a balance that decrements as the customer uses your product.
Balances can also be created for [credit systems](/documentation/modelling-pricing/credit-systems) (eg, $10 credits per month) and boolean toggle features (eg, access to a premium analytics dashboard).
@@ -27,10 +27,10 @@ Balances can also be created for [credit systems](/documentation/modelling-prici
| `expired` | Subscription has ended |
<CardGroup cols={2}>
<Card title="Accepting Payments" icon="credit-card" href="/documentation/subscriptions/accepting-payments">
<Card title="Accepting Payments" icon="credit-card" href="/documentation/customers/payment-flow">
Learn about the checkout and payment flow
</Card>
<Card title="Managing Subscriptions" icon="sliders" href="/documentation/subscriptions/managing-subscriptions">
<Card title="Managing Subscriptions" icon="sliders" href="/documentation/customers/subscription-lifecycle">
Handle upgrades, downgrades and cancellations
</Card>
</CardGroup>

View File

@@ -10,7 +10,7 @@ Entities are sub accounts of a customer. For example, you may have a product tha
There are two ways you can handle entities depending on your use case.
1. **Entity-level balances**: this is the simplest way of handling entities, and keeps everything under 1 subscription. This is useful when all entities get the same features and limits.
2. **Entity-level subscriptions**: if an entity can have its own subscription tiers (ie basic seats, pro seats), then you can attach a product directly at the entity level by passing the [entity ID](/api-reference/billing/billingAttach#body-entity-id) into an attach function. Each will be under its own subscription in Stripe with the billing cycles synced.
2. **Entity-level subscriptions**: if an entity can have its own subscription tiers (ie basic seats, pro seats), then you can attach a product directly at the entity level by passing the [entity ID](/api-reference/billing/attach#body-entity-id) into an attach function. Each will be under its own subscription in Stripe with the billing cycles synced.
<Info>
**Example**

View File

@@ -76,7 +76,7 @@ curl -X POST https://api.useautumn.com/v1/balances/create \
</CodeGroup>
See the [Create Balance API reference](/api-reference/features/create-balance) for all available parameters.
See the [Create Balance API reference](/api-reference/balances/createBalance) for all available parameters.
## Updating Balances (API)
@@ -116,7 +116,7 @@ curl -X POST https://api.useautumn.com/v1/customers/user_123/balances \
</CodeGroup>
See the [Set Feature Balance API reference](/api-reference/features/set-feature-balances) for all available parameters.
See the [Set Feature Balance API reference](/api-reference/balances/updateBalance) for all available parameters.
## Querying Balances

View File

@@ -196,4 +196,4 @@ The `billing.attach` response has two key fields:
**`payment_url`** — a URL the customer should be redirected to, or `null` if no redirect is needed.
**`required_action`** — present when payment couldn't be processed automatically. See [Edge Cases](/documentation/customers/billing/edge-cases) for details on handling 3DS, payment failures, and retries.
**`required_action`** — present when payment couldn't be processed automatically. See [Edge Cases](/documentation/customers/edge-cases) for details on handling 3DS, payment failures, and retries.

View File

@@ -168,7 +168,7 @@ By default, updating a subscription with billing changes will generate an invoic
### Previewing changes before executing
Similar to [`billing.previewAttach`](/documentation/customers/attaching-plans#step-1-preview-the-change), you can use `billing.previewUpdate` to see exactly what will be charged before making changes. This returns line items and totals that you can display in a confirmation UI.
Similar to [`billing.previewAttach`](/documentation/customers/payment-flow#step-1-preview-the-charge), you can use `billing.previewUpdate` to see exactly what will be charged before making changes. This returns line items and totals that you can display in a confirmation UI.
<CodeGroup>

View File

@@ -148,7 +148,7 @@ const { data } = await autumn.checkout({
## Cancelling add-ons
Cancel an add-on using the same [cancel](/documentation/customers/managing-subscriptions#cancellations) flow:
Cancel an add-on using the same [cancel](/documentation/customers/subscription-lifecycle#cancellations) flow:
<CodeGroup>

View File

@@ -56,7 +56,7 @@ Push changes with `atmn push`.
## Attaching a subscription
Use [billing.attach](/documentation/customers/attaching-plans) to attach a subscription to a customer. With `redirectMode: "always"`, a checkout URL is always returned for the customer to complete payment or confirm the plan change.
Use [billing.attach](/documentation/customers/payment-flow) to attach a subscription to a customer. With `redirectMode: "always"`, a checkout URL is always returned for the customer to complete payment or confirm the plan change.
<CodeGroup>
@@ -212,7 +212,7 @@ This is useful when you want to offer an annual discount while still metering us
## Managing subscriptions
Once a customer has an active subscription, you can manage upgrades, downgrades, and cancellations. See [Managing Subscriptions](/documentation/customers/s) for details on:
Once a customer has an active subscription, you can manage upgrades, downgrades, and cancellations. See [Managing Subscriptions](/documentation/customers/subscription-lifecycle) for details on:
- **Upgrades** — prorated charges for switching to a higher-priced plan
- **Downgrades** — scheduled at end of billing period

View File

@@ -198,7 +198,7 @@ curl -X POST "https://api.useautumn.com/v1/check" \
## Upgrading an entity's plan
To upgrade or downgrade an entity, attach the new plan with the same `entity_id`. The same [upgrade/downgrade](/documentation/customers/billing/subscription-lifecycle) logic applies:
To upgrade or downgrade an entity, attach the new plan with the same `entity_id`. The same [upgrade/downgrade](/documentation/customers/subscription-lifecycle) logic applies:
<CodeGroup>
@@ -233,7 +233,7 @@ curl -X POST "https://api.useautumn.com/v1/billing.attach" \
## Cancelling an entity's plan
Use `billing.update` with `cancelAction` to cancel an entity's plan. The same [cancel/uncancel](/documentation/customers/billing/subscription-lifecycle#cancellations) behavior applies.
Use `billing.update` with `cancelAction` to cancel an entity's plan. The same [cancel/uncancel](/documentation/customers/subscription-lifecycle#cancellations) behavior applies.
<CodeGroup>

View File

@@ -126,7 +126,7 @@ export default function UpgradeButton() {
Open checkout URL in a new tab instead of redirecting.
</ParamField>
See the [API reference](/api-reference/billing/billingAttach) for all available parameters.
See the [API reference](/api-reference/billing/attach) for all available parameters.
### `check()`

View File

@@ -1,77 +0,0 @@
---
title: Understanding how Autumn works
sidebarTitle: Understand Autumn
description: "Autumn is built to manage your feature access and your billing together. Learn how it all works."
---
## The Autumn flow
The typical Autumn flow looks like this:
<Steps>
<Step title="Define your pricing model in the Autumn dashboard">
The dashboard is where you define which of your users get access to what, and how much they're charged for it. Here, you'll [create products](/products/create-product) and [manage your customers](/customers/managing-customers).
</Step>
<Step title="Call attach when a customer wants to purchase a product">
From your application, when your customer wants to [purchase](/products/enabling-product) one of your price plans, the `attach` endpoint will return a Stripe checkout URL.
Once they've purchased, Autumn grants access to the product's features that were defined in the dashboard.
If the customer is already paying for a plan, this will automatically handle any [upgrades and downgrades](products/enabling-product#upgrades-and-downgrades) too.
</Step>
<Step title="Check whether a customer has access to something">
From your application, you can [check](/features/check) in real-time whether a customer has access to:
- a product (eg, pro tier)
- a feature balance (eg, 10 remaining credits)
- a feature flag (eg, premium AI models)
Autumn will return whether they're `allowed` access, and you can use this to control access to your features. When you want to update your pricing model, you can do so without changing any of your code.
</Step>
<Step title="Track usage for usage-based features">
For your usage-based features, if Autumn tells you they're `allowed` access, you can [track their usage](/features/tracking-usage) to update their balance.
This means Autumn can enforce any usage limits you set (ie, by returning `allowed: false` once they hit a balance of 0). It also allows you to charge for usage (eg, $1 per AI tokens used), if you have a usage-based pricing model.
</Step>
<Step title="Display customer's information and balances">
When using usage-based features, you'll typically want to [display the customer's balance](/api-reference/customers/get) to them. You can do this from the `customers` route or hook, which will return customer information, the product they've purchased, and the balance of any usage-based features.
</Step>
</Steps>
This means all of the logic that you would typically have to build yourself is done for you.
- How do I limit usage of this feature to 10 per month, then reset?
- How would I bill users who go over the limit?
- How do I build in-app flows for upgrading, downgrading, cancelling?
- What if I want add-ons, or credits?
- My billing is at the org level. How do I track feature limits at the user level?
- What happens to their usage if they upgrade, or downgrade, or cancel?
- How can I block access if payment fails?
- How would I migrate people if I change my pricing?
- What should I do if I want a custom plan for different customers?
Autumn takes care of all of this, and more.
## Why use Autumn?
Autumn is loved by developers, founders and fast-growing startups because it's easy to integrate and **billing never has to be touched again**.
1. **The simplest integration you'll find** - Just 2 API calls to set up any pricing model. You don't need to deal with webhooks, state syncing, or deal with any of Stripe's APIs.
2. **Enforce access limits** - Autumn knows who's paying for which product and what features they have access to. Just [check](/features/check.mdx) if a customer has access, then [track](/features/tracking-usage.mdx) any usage if needed.
3. **Billing flows handled** - Upgrades, downgrades, failed payments, and more. Autumn takes care of it all, with customizable UI components that "just work" out of the box.
4. **Make changes without touching code** - Make pricing changes, set custom plans, or launch new types of pricing without going through engineering. Versioning, grandfathering and migrations is easy through the Autumn dashboard.
## What Autumn isn't
- A Stripe billing replacement: although you don't need to deal with Stripe's APIs, you are still using Stripe's subscriptions, payments and invoicing.
- Another metered billing product: Autumn easily handles usage-based billing up to 100 events per second, but there are many providers out there that specialize if you need more.
- For purely sales-led companies: Autumn is designed to be used by product-led, or hybrid product-sales teams. If your customers only pay via custom invoices, Autumn is probably not for you.
Now that you understand how Autumn works, check out one of the quickstart guides to get started!

131
bun.lock
View File

@@ -24,6 +24,7 @@
"dotenv": "^16.6.1",
"husky": "^9.1.7",
"inquirer": "^12.10.0",
"knip": "^6.7.0",
"ts-to-zod": "^5.1.0",
},
},
@@ -995,9 +996,9 @@
"@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
@@ -1305,7 +1306,7 @@
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.6", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="],
@@ -1505,6 +1506,88 @@
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.127.0", "", { "os": "android", "cpu": "arm" }, "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ=="],
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.127.0", "", { "os": "android", "cpu": "arm64" }, "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg=="],
"@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.127.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg=="],
"@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.127.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw=="],
"@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.127.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA=="],
"@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.127.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ=="],
"@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.127.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g=="],
"@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.127.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ=="],
"@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.127.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA=="],
"@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.127.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ=="],
"@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.127.0", "", { "os": "linux", "cpu": "none" }, "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ=="],
"@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.127.0", "", { "os": "linux", "cpu": "none" }, "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g=="],
"@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.127.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q=="],
"@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.127.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ=="],
"@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.127.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg=="],
"@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.127.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ=="],
"@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.127.0", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ=="],
"@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.127.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw=="],
"@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.127.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw=="],
"@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.127.0", "", { "os": "win32", "cpu": "x64" }, "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w=="],
"@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="],
"@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.19.1", "", { "os": "android", "cpu": "arm64" }, "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA=="],
"@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.19.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ=="],
"@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.19.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ=="],
"@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.19.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw=="],
"@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A=="],
"@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ=="],
"@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig=="],
"@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew=="],
"@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.19.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ=="],
"@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w=="],
"@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw=="],
"@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.19.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA=="],
"@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ=="],
"@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw=="],
"@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.19.1", "", { "os": "none", "cpu": "arm64" }, "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA=="],
"@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.19.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg=="],
"@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.19.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ=="],
"@oxc-resolver/binding-win32-ia32-msvc": ["@oxc-resolver/binding-win32-ia32-msvc@11.19.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA=="],
"@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="],
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
"@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
@@ -3399,6 +3482,8 @@
"favicons": ["favicons@7.2.0", "", { "dependencies": { "escape-html": "^1.0.3", "sharp": "^0.33.1", "xml2js": "^0.6.1" } }, "sha512-k/2rVBRIRzOeom3wI9jBPaSEvoTSQEW4iM0EveBmBBKFxO8mSyyRWtDlfC3VnEfu0avmjrMzy8/ZFPSe6F71Hw=="],
"fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -3453,6 +3538,8 @@
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
@@ -3989,6 +4076,8 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"knip": ["knip@6.7.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.16", "unbash": "^3.0.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-ckL51NDH1YJxnv1kNB0iUdDngB4f/e9Igz8uIqYfmNDoyOFmmk1V0WFv3LQ7/hzC63b2Z9X41gGUE9eOWrZpaA=="],
"ksuid": ["ksuid@3.0.0", "", { "dependencies": { "base-convert-int-array": "^1.0.1" } }, "sha512-81CkBGn/06ZVAjGvFZi6fVG8VcPeMH0JpJ4V1Z9VwrMMaGIeAjY4jrVdrIcxhL9I2ZUU6t5uiyswcmkk+KZegA=="],
"kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
@@ -4485,6 +4574,10 @@
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"oxc-parser": ["oxc-parser@0.127.0", "", { "dependencies": { "@oxc-project/types": "^0.127.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.127.0", "@oxc-parser/binding-android-arm64": "0.127.0", "@oxc-parser/binding-darwin-arm64": "0.127.0", "@oxc-parser/binding-darwin-x64": "0.127.0", "@oxc-parser/binding-freebsd-x64": "0.127.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", "@oxc-parser/binding-linux-arm64-musl": "0.127.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", "@oxc-parser/binding-linux-x64-gnu": "0.127.0", "@oxc-parser/binding-linux-x64-musl": "0.127.0", "@oxc-parser/binding-openharmony-arm64": "0.127.0", "@oxc-parser/binding-wasm32-wasi": "0.127.0", "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", "@oxc-parser/binding-win32-x64-msvc": "0.127.0" } }, "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA=="],
"oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="],
"p-any": ["p-any@4.0.0", "", { "dependencies": { "p-cancelable": "^3.0.0", "p-some": "^6.0.0" } }, "sha512-S/B50s+pAVe0wmEZHmBs/9yJXeZ5KhHzOsgKzt0hRdgkoR3DxW9ts46fcsWi/r3VnzsnkKS7q4uimze+zjdryw=="],
"p-cancelable": ["p-cancelable@4.0.1", "", {}, "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg=="],
@@ -5043,6 +5136,8 @@
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
"socket.io-adapter": ["socket.io-adapter@2.5.6", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" } }, "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ=="],
@@ -5143,7 +5238,7 @@
"strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"stripe": ["stripe@19.3.0-beta.1", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-tZLZYj2LPBt+FvYKQYRcop9IAvkDxIsqealtgG2kJ9zVK0DAy8yqC5aXJZxJ+81I5mRWXXpyLKGu298/owkX8Q=="],
@@ -5381,6 +5476,8 @@
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"unbash": ["unbash@3.0.0", "", {}, "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="],
@@ -5913,10 +6010,14 @@
"@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"@fortawesome/fontawesome-svg-core/@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@7.2.0", "", {}, "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw=="],
"@humanwhocodes/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@infisical/sdk/@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.600.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.600.0", "@aws-sdk/client-sso": "3.598.0", "@aws-sdk/client-sts": "3.600.0", "@aws-sdk/credential-provider-cognito-identity": "3.600.0", "@aws-sdk/credential-provider-env": "3.598.0", "@aws-sdk/credential-provider-http": "3.598.0", "@aws-sdk/credential-provider-ini": "3.598.0", "@aws-sdk/credential-provider-node": "3.600.0", "@aws-sdk/credential-provider-process": "3.598.0", "@aws-sdk/credential-provider-sso": "3.598.0", "@aws-sdk/credential-provider-web-identity": "3.598.0", "@aws-sdk/types": "3.598.0", "@smithy/credential-provider-imds": "^3.1.1", "@smithy/property-provider": "^3.1.1", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-cC9uqmX0rgx1efiJGqeR+i0EXr8RQ5SAzH7M45WNBZpYiLEe6reWgIYJY9hmOxuaoMdWSi8kekuN3IjTIORRjw=="],
"@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -6473,6 +6574,8 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@useautumn/sdk/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@useautumn/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -6835,6 +6938,8 @@
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"knip/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"langchain/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"langium/chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="],
@@ -6879,6 +6984,8 @@
"mocha/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"mocha/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"module-lookup-amd/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
@@ -6959,8 +7066,6 @@
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"pino-pretty/strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"pkg-conf/find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "^7.1.0", "path-exists": "^5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
@@ -8041,6 +8146,10 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -8711,6 +8820,8 @@
"xo/@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"xo/@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"xo/@typescript-eslint/typescript-estree/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="],
"xo/eslint/@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="],
@@ -9017,6 +9128,8 @@
"@mintlify/prebuild/@mintlify/scraping/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@mintlify/prebuild/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@mintlify/previewing/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@mintlify/previewing/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -9267,6 +9380,8 @@
"eslint-formatter-pretty/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"favicons/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"find-cache-dir/pkg-dir/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="],
"find-cache-dir/pkg-dir/find-up/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
@@ -9399,6 +9514,8 @@
"xo/@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"xo/eslint/@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"xo/eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"xo/eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -9573,6 +9690,8 @@
"artillery/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"atmn/eslint-plugin-react-hooks/eslint/@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"atmn/eslint-plugin-react-hooks/eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"atmn/eslint-plugin-react-hooks/eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],

View File

@@ -1,30 +1,46 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"$schema": "https://unpkg.com/knip@6/schema.json",
"exclude": [
"dependencies",
"unlisted",
"unresolved",
"binaries",
"catalog",
"exports",
"types",
"enumMembers",
"duplicates"
],
"ignoreWorkspaces": [
"packages/atmn",
"packages/autumn-js",
"packages/openapi",
"packages/sdk"
],
"workspaces": {
".": {
"ignore": [".opencode/**"]
"entry": ["apps/scope-picker/**/*"]
},
"server": {
"entry": ["tests/**/*.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignore": [
"src/_luaScriptsV2/**",
"src/utils/importUtils/**",
"src/utils/scriptUtils/**",
"src/utils/workerUtils/**",
"src/db/**"
"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": ["index.ts"],
"project": ["**/*.ts"],
"includeEntryExports": false,
"ignore": ["api/balances/track/changes/V0.2_TrackChange.ts"]
"project": ["**/*.ts", "!utils/**"],
"includeEntryExports": false
},
"vite": {
"entry": ["src/main.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"entry": ["tests/**/*.{ts,tsx}"],
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"],
"ignore": ["src/components/ai-elements/**"],
"ignoreDependencies": [
"tailwindcss",
@@ -37,11 +53,28 @@
"entry": ["**/*.{ts,js}"],
"project": ["**/*.{ts,js}"]
},
"apps/docs": {
"entry": ["**/*"],
"project": ["**/*.{ts,js,jsx,css,mdx}"]
},
"apps/sdk-test": {
"entry": ["app/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}", "*.ts"],
"project": ["**/*.{ts,tsx}"]
},
"apps/website": {
"entry": ["app/**/*.{ts,tsx,mdx}", "components/**/*.{ts,tsx}", "content/**/*.mdx", "*.mjs"],
"project": ["**/*.{ts,tsx,mdx,mjs}"]
},
"apps/checkout": {
"entry": ["src/main.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/components/ui/**", "src/hooks/**"],
"ignoreDependencies": ["shadcn", "tailwindcss", "tw-animate-css"]
},
"packages/ksuid": {
"project": ["src/**/*.ts"]
},
"packages/stripe-sync": {
"entry": ["scripts/**/*.ts"],
"project": ["src/**/*.ts", "scripts/**/*.ts"]
}
},
"ignoreBinaries": ["infisical", "lsof", "serve"]

View File

@@ -142,6 +142,7 @@
"dotenv": "^16.6.1",
"husky": "^9.1.7",
"inquirer": "^12.10.0",
"knip": "^6.7.0",
"ts-to-zod": "^5.1.0"
}
}

View File

@@ -2,7 +2,9 @@ import { initDrizzle } from "@server/db/initDrizzle";
import { validateDbSchema } from "@server/db/validateDbSchema";
import { validateSqlFunctions } from "@server/db/validateSqlFunctions";
const { db } = initDrizzle({ maxConnections: 5 });
const { db } = initDrizzle({
maxConnections: Number(process.env.DB_MAX_CONNECTIONS ?? 5),
});
// Check if --validate-content flag is passed
const validateContent = process.argv.includes("--validate-content");

View File

@@ -64,4 +64,3 @@ SVIX_API_KEY=
# ANTHROPIC
# This is used to generate singular / plural names for your features, which are used when in the dashboard and when you use Autumn UI components
ANTHROPIC_API_KEY=

View File

@@ -2,7 +2,7 @@
"name": "@autumn/server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"main": "src/index.ts",
"type": "module",
"scripts": {
"email": "email dev -p 3001",

View File

@@ -1,542 +0,0 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: AutumnCliV2 is used for internal testing & scripts */
import dotenv from "dotenv";
dotenv.config();
import {
type ApiCustomerV3,
type ApiEntityBillingControlsParams,
type AttachBodyV0,
type CancelBody,
type CheckoutParams,
type CheckoutResponseV0,
type CheckParams,
type CheckQuery,
type CheckResponseV1,
type CreateEntityParams,
type CreateRewardProgram,
CustomerExpand,
EntityExpand,
ErrCode,
type OrgConfig,
type RewardRedemption,
type SetUsageParams,
type TrackParams,
} from "@autumn/shared";
class AutumnError extends Error {
message: string;
code: string;
constructor({ message, code }: { message: string; code: string }) {
super(message);
this.message = message;
this.code = code;
}
toString(): string {
return `${this.message} (code: ${this.code})`;
}
}
/**
* Robust Autumn API client (V2) with proper version handling
*
* Key improvements over V1:
* - Properly respects x-api-version header for ALL requests
* - No legacy v1Schema params
* - Cleaner error handling
* - Type-safe version parameter
*/
export class AutumnCliV2 {
private apiKey: string;
public headers: Record<string, string>;
public baseUrl: string;
public version?: string;
constructor({
apiKey,
secretKey,
baseUrl,
version,
orgConfig,
liveUrl = false,
}: {
apiKey?: string;
secretKey?: string;
baseUrl?: string;
version?: string;
orgConfig?: Partial<OrgConfig>;
liveUrl?: boolean;
} = {}) {
this.apiKey =
apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
this.headers = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
this.version = version;
if (version) {
this.headers["x-api-version"] = version;
}
if (orgConfig) {
this.headers["org-config"] = JSON.stringify(orgConfig);
}
this.baseUrl =
baseUrl ||
(liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1");
}
async get(path: string) {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: this.headers,
});
if (response.status !== 200) {
let error: any;
try {
error = await response.json();
} catch (_e) {
throw new AutumnError({
message: `GET ${path} failed with status ${response.status}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message || `GET ${path} failed`,
code: error.code || ErrCode.InternalError,
});
}
return response.json();
}
async post(path: string, body: any) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
});
if (response.status !== 200) {
let error: any;
try {
error = await response.json();
} catch (_e) {
throw new AutumnError({
message: `POST ${path} failed with status ${response.status}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message || `POST ${path} failed`,
code: error.code || ErrCode.InternalError,
});
}
return response.json();
}
async patch(path: string, body: any) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "PATCH",
headers: this.headers,
body: JSON.stringify(body),
});
if (response.status !== 200) {
let error: any;
try {
error = await response.json();
} catch (_e) {
throw new AutumnError({
message: `PATCH ${path} failed with status ${response.status}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message || `PATCH ${path} failed`,
code: error.code || ErrCode.InternalError,
});
}
return response.json();
}
async delete(
path: string,
{
deleteInStripe = false,
}: {
deleteInStripe?: boolean;
} = {},
) {
const queryParams = deleteInStripe ? "?delete_in_stripe=true" : "";
const response = await fetch(`${this.baseUrl}${path}${queryParams}`, {
method: "DELETE",
headers: this.headers,
});
if (response.status !== 200) {
let error: any;
try {
error = await response.json();
} catch (_e) {
throw new AutumnError({
message: `DELETE ${path} failed with status ${response.status}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message || `DELETE ${path} failed`,
code: error.code || ErrCode.InternalError,
});
}
return response.json();
}
async createCustomer({
id,
email,
name,
fingerprint,
}: {
id: string;
email: string;
name: string;
fingerprint?: string;
}) {
return await this.post("/customers", {
id,
email,
name,
fingerprint,
});
}
async attach(params: AttachBodyV0) {
return await this.post(`/attach`, params);
}
async checkout(
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean },
) {
const data = await this.post(`/checkout`, params);
return data as CheckoutResponseV0;
}
async transfer(
customerId: string,
params: {
from_entity_id?: string;
to_entity_id: string;
product_id: string;
},
) {
const data = await this.post(`/customers/${customerId}/transfer`, params);
return data;
}
async sendEvent({
customerId,
eventName,
properties,
customer_data,
idempotency_key,
}: {
customerId: string;
eventName: string;
properties?: any;
customer_data?: any;
idempotency_key?: string;
}) {
return await this.post(`/events`, {
customer_id: customerId,
event_name: eventName,
properties,
customer_data,
idempotency_key,
});
}
async entitled({
customerId,
featureId,
quantity,
customer_data,
}: {
customerId: string;
featureId: string;
quantity?: number;
customer_data?: any;
}) {
return await this.post(`/entitled`, {
customer_id: customerId,
feature_id: featureId,
quantity,
customer_data,
});
}
customers = {
list: async (params?: { limit?: number; offset?: number }) => {
const queryString = params
? `?${new URLSearchParams(params as Record<string, string>).toString()}`
: "";
return await this.get(`/customers${queryString}`);
},
get: async (
customerId: string,
params?: {
expand?: CustomerExpand[];
},
): Promise<
ApiCustomerV3 & {
invoices: any[];
}
> => {
const queryParams = new URLSearchParams();
const defaultParams = {
expand: [CustomerExpand.Invoices],
};
const finalParams = { ...defaultParams, ...params };
if (finalParams.expand) {
queryParams.append("expand", finalParams.expand.join(","));
}
return await this.get(
`/customers/${customerId}?${queryParams.toString()}`,
);
},
create: async (customer: { id: string; email?: string; name?: string }) => {
return await this.post(`/customers?with_autumn_id=true`, customer);
},
delete: async (
customerId: string,
{
deleteInStripe = false,
}: {
deleteInStripe?: boolean;
} = {},
) => {
return await this.delete(`/customers/${customerId}`, {
deleteInStripe,
});
},
};
entities = {
get: async (customerId: string, entityId: string) => {
return await this.get(
`/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`,
);
},
create: async (
customerId: string,
entity: CreateEntityParams | CreateEntityParams[],
) => {
return await this.post(
`/customers/${customerId}/entities?with_autumn_id=true`,
entity,
);
},
list: async (customerId: string) => {
return await this.get(`/customers/${customerId}/entities`);
},
delete: async (customerId: string, entityId: string) => {
return await this.delete(`/customers/${customerId}/entities/${entityId}`);
},
update: async (
customerId: string,
entityId: string,
updates: {
billing_controls?: ApiEntityBillingControlsParams;
},
) => {
return await this.post(`/entities.update`, {
customer_id: customerId,
entity_id: entityId,
...updates,
});
},
};
products = {
/**
* Get product - respects x-api-version header set in constructor
*/
get: async (productId: string) => {
return await this.get(`/products/${productId}`);
},
/**
* Create product - respects x-api-version header
*/
create: async (product: any) => {
return await this.post(`/products`, product);
},
/**
* Update product - respects x-api-version header
*/
update: async (productId: string, product: any) => {
return await this.post(`/products/${productId}`, product);
},
/**
* Delete product
*/
delete: async (productId: string) => {
return await this.delete(`/products/${productId}`);
},
/**
* List products - respects x-api-version header
*/
list: async (params?: { limit?: number; offset?: number }) => {
const queryString = params
? `?${new URLSearchParams(params as Record<string, string>).toString()}`
: "";
return await this.get(`/products${queryString}`);
},
};
rewards = {
get: async (rewardId: string) => {
return await this.get(`/rewards/${rewardId}`);
},
create: async (reward: any) => {
return await this.post(`/rewards?legacyStripe=true`, reward);
},
delete: async (rewardId: string) => {
return await this.delete(`/rewards/${rewardId}`);
},
};
rewardPrograms = {
create: async (rewardProgram: CreateRewardProgram) => {
return await this.post(`/reward_programs`, rewardProgram);
},
};
referrals = {
createCode: async ({
customerId,
referralId,
}: {
customerId: string;
referralId: string;
}) => {
return await this.post(`/referrals/code`, {
customer_id: customerId,
program_id: referralId,
});
},
redeem: async ({
customerId,
code,
}: {
customerId: string;
code: string;
}) => {
return await this.post(`/referrals/redeem`, {
customer_id: customerId,
code,
});
},
};
redemptions = {
get: async (redemptionId: string) => {
const data = await this.get(`/redemptions/${redemptionId}`);
return data as RewardRedemption;
},
};
events = {
send: async ({
customerId,
featureId,
value,
properties,
}: {
customerId: string;
featureId: string;
value: number;
properties?: any;
}) => {
return await this.post(`/events`, {
customer_id: customerId,
feature_id: featureId,
value,
properties,
});
},
};
stripe = {
connect: async (params: {
secret_key: string;
success_url: string;
default_currency: string;
}) => {
return await this.post(`/organization/stripe`, params);
},
delete: async () => {
return await this.delete(`/organization/stripe`);
},
};
track = async (params: TrackParams) => {
return await this.post(`/track`, params);
};
usage = async (params: SetUsageParams) => {
return await this.post(`/usage`, params);
};
check = async <T = CheckResponseV1>(
params: CheckParams & CheckQuery,
): Promise<T> => {
return await this.post(`/check`, params);
};
attachPreview = async (params: AttachBodyV0) => {
return await this.post(`/attach/preview`, params);
};
cancel = async (params: CancelBody) => {
return await this.post(`/cancel`, params);
};
migrate = async (params: {
from_product_id: string;
to_product_id: string;
from_version: number;
to_version: number;
}) => {
return await this.post(`/migrations`, params);
};
}

View File

@@ -1,26 +0,0 @@
export const loadCaCert = async ({
caPath,
type,
caValue,
}: {
caPath?: string;
type: "queue" | "cache";
caValue?: string;
}) => {
try {
if (caValue) {
if (caValue.startsWith("-----BEGIN CERTIFICATE-----")) {
return caValue;
}
return undefined;
}
const ca = Bun.file(caPath || `/etc/secrets/${type}-cert.pem`);
const caText = await ca.text();
return caText;
} catch (_error) {
return;
}
};

View File

@@ -1,283 +0,0 @@
import type { Redis } from "ioredis";
import { logger } from "@/external/logtail/logtailUtils.js";
// ── Config ──────────────────────────────────────────────────────────
/** How long primary must stay down before we switch to failover. */
const FAILOVER_THRESHOLD_MS = 60_000;
/** How long primary must stay healthy before we switch back. */
const RECOVERY_THRESHOLD_MS = 5_000;
/** Health-check polling interval. */
const POLL_INTERVAL_MS = 2_000;
/** Log a warning if blip count exceeds this in the trailing window. */
const BLIP_WARN_THRESHOLD = 10;
/** Trailing window for blip counting. */
const BLIP_WINDOW_MS = 60 * 60 * 1_000; // 1 hour
// ── State machine ───────────────────────────────────────────────────
type FailoverPhase = "NORMAL" | "DEGRADED" | "FAILOVER" | "RECOVERING";
type FailoverState = {
phase: FailoverPhase;
active: Redis;
primary: Redis;
failover: Redis | null;
failoverRegion: string | null;
/** Timestamp when the current phase was entered. */
phaseEnteredAt: number;
};
let state: FailoverState;
let primaryHasBeenReady = false;
let pollTimer: ReturnType<typeof setInterval> | null = null;
/** Tracks timestamps of recent transient blips (DEGRADED → NORMAL). */
const blipTimestamps: number[] = [];
// ── Callbacks ───────────────────────────────────────────────────────
type StateChangeCallback = () => void;
const onChangeCallbacks: StateChangeCallback[] = [];
/** Register a callback invoked whenever `active` changes. */
export const onActiveChange = (cb: StateChangeCallback): void => {
onChangeCallbacks.push(cb);
};
const notifyChange = (): void => {
for (const cb of onChangeCallbacks) {
try {
cb();
} catch (err) {
logger.error("[Redis failover] onActiveChange callback threw", {
error: err,
});
}
}
};
// ── Helpers ─────────────────────────────────────────────────────────
const isPrimaryReady = (): boolean => state.primary.status === "ready";
const isFailoverReady = (): boolean => state.failover?.status === "ready";
const setPhase = (phase: FailoverPhase): void => {
state.phase = phase;
state.phaseEnteredAt = Date.now();
};
const msInPhase = (): number => Date.now() - state.phaseEnteredAt;
const pruneBlips = (): void => {
const cutoff = Date.now() - BLIP_WINDOW_MS;
while (blipTimestamps.length > 0 && blipTimestamps[0] < cutoff) {
blipTimestamps.shift();
}
};
const recordBlip = ({ durationMs }: { durationMs: number }): void => {
blipTimestamps.push(Date.now());
pruneBlips();
logger.warn(
`[Redis failover] Primary blip #${blipTimestamps.length} (recovered in ${durationMs}ms)`,
{
type: "redis_failover_blip",
blipCount: blipTimestamps.length,
durationMs,
},
);
if (blipTimestamps.length >= BLIP_WARN_THRESHOLD) {
logger.error(
`[Redis failover] ${blipTimestamps.length} blips in the last hour — check Redis health`,
{
type: "redis_failover_blip_alert",
blipCount: blipTimestamps.length,
},
);
}
};
// ── Core poll tick ──────────────────────────────────────────────────
const tick = (): void => {
const ready = isPrimaryReady();
switch (state.phase) {
case "NORMAL": {
if (!ready && primaryHasBeenReady) {
setPhase("DEGRADED");
logger.warn("[Redis failover] Primary unhealthy — entering DEGRADED", {
type: "redis_failover_degraded",
primaryStatus: state.primary.status,
});
}
break;
}
case "DEGRADED": {
if (ready) {
// Blip — primary recovered before we had to failover
recordBlip({ durationMs: msInPhase() });
setPhase("NORMAL");
break;
}
if (msInPhase() >= FAILOVER_THRESHOLD_MS) {
if (!state.failover || !isFailoverReady()) {
logger.error(
"[Redis failover] Threshold reached but failover instance not ready",
{
type: "redis_failover_switch",
failoverStatus: state.failover?.status ?? "none",
},
);
break;
}
state.active = state.failover;
setPhase("FAILOVER");
notifyChange();
logger.error(
`[Redis failover] SWITCHED to failover region (${state.failoverRegion})`,
{
type: "redis_failover_switch",
failoverRegion: state.failoverRegion,
},
);
}
break;
}
case "FAILOVER": {
if (ready) {
setPhase("RECOVERING");
logger.info("[Redis failover] Primary back — entering RECOVERING", {
type: "redis_failover_recovering",
});
}
break;
}
case "RECOVERING": {
if (!ready) {
// Primary dropped again — go back to failover
setPhase("FAILOVER");
logger.warn(
"[Redis failover] Primary dropped during recovery — back to FAILOVER",
{ type: "redis_failover_recovery_failed" },
);
break;
}
if (msInPhase() >= RECOVERY_THRESHOLD_MS) {
state.active = state.primary;
setPhase("NORMAL");
notifyChange();
logger.info("[Redis failover] RECOVERED to primary region", {
type: "redis_failover_recovered",
});
}
break;
}
}
};
// ── Public API ──────────────────────────────────────────────────────
/** Initialize failover. Call once after creating both Redis instances. */
export const initFailover = ({
primary,
failover,
failoverRegion,
currentRegion,
}: {
primary: Redis;
failover: Redis | null;
failoverRegion: string | null;
currentRegion: string;
}): void => {
state = {
phase: "NORMAL",
active: primary,
primary,
failover,
failoverRegion,
phaseEnteredAt: Date.now(),
};
if (!failover) {
logger.info(
"[Redis failover] No failover region configured — failover disabled",
{ type: "redis_failover_init" },
);
return;
}
logger.info(
`[Redis failover] Enabled: primary=${currentRegion}, failover=${failoverRegion}`,
{ type: "redis_failover_init", currentRegion, failoverRegion },
);
// Track when primary first connects so we don't failover during startup
primary.on("ready", () => {
primaryHasBeenReady = true;
});
// Clear any existing state from a previous init
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
primaryHasBeenReady = false;
blipTimestamps.length = 0;
// Start the single polling loop
pollTimer = setInterval(tick, POLL_INTERVAL_MS);
};
/** Get the currently active Redis instance. */
export const getActiveRedis = (): Redis => state.active;
/** Get current failover state (for debug/monitoring). */
export const getFailoverState = (): {
phase: FailoverPhase;
isUsingFailover: boolean;
failoverRegion: string | null;
primaryStatus: string;
failoverStatus: string | null;
msInPhase: number;
blipsLastHour: number;
} => {
pruneBlips();
return {
phase: state.phase,
isUsingFailover: state.phase === "FAILOVER" || state.phase === "RECOVERING",
failoverRegion: state.failoverRegion,
primaryStatus: state.primary.status,
failoverStatus: state.failover?.status ?? null,
msInPhase: msInPhase(),
blipsLastHour: blipTimestamps.length,
};
};
/** Force disconnect the primary (for testing). */
export const disconnectPrimary = (): void => {
state.primary.disconnect();
};
/** Force reconnect the primary (for testing). */
export const reconnectPrimary = (): void => {
state.primary.connect();
};
/** Stop the polling loop (for testing/cleanup). */
export const stopFailoverPolling = (): void => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};

View File

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

View File

@@ -1,16 +0,0 @@
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
export const getStripeCheckoutSession = async ({
ctx,
checkoutSessionId,
}: {
ctx: AutumnContext;
checkoutSessionId: string;
}): Promise<Stripe.Checkout.Session> => {
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
return stripeCli.checkout.sessions.retrieve(checkoutSessionId);
};

View File

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

View File

@@ -1,70 +0,0 @@
import {
InternalError,
isFixedPrice,
type Price,
type Product,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
type FindSubscriptionItemParams = {
stripeSubscriptionItems: Stripe.SubscriptionItem[];
price: Price;
product: Product;
};
// Overload: errorOnNotFound = true → guaranteed SubscriptionItem
export function findSubscriptionItemByAutumnPrice(
params: FindSubscriptionItemParams & { errorOnNotFound: true },
): Stripe.SubscriptionItem;
// Overload: errorOnNotFound = false/undefined → SubscriptionItem | undefined
export function findSubscriptionItemByAutumnPrice(
params: FindSubscriptionItemParams & { errorOnNotFound?: false },
): Stripe.SubscriptionItem | undefined;
// Implementation
export function findSubscriptionItemByAutumnPrice({
stripeSubscriptionItems,
price,
product,
errorOnNotFound,
}: FindSubscriptionItemParams & { errorOnNotFound?: boolean }):
| Stripe.SubscriptionItem
| undefined {
const stripeProductId = product.processor?.id;
let result: Stripe.SubscriptionItem | undefined;
if (isFixedPrice(price)) {
const config = price.config;
result = stripeSubscriptionItems.find((si) => {
return (
config.stripe_price_id === si.price?.id ||
(stripeProductId && si.price?.product === stripeProductId)
);
});
} else {
const config = price.config as UsagePriceConfig;
result = stripeSubscriptionItems.find(
(si: Stripe.SubscriptionItem | Stripe.LineItem) => {
return (
config.stripe_price_id === si.price?.id ||
config.stripe_product_id === si.price?.product ||
config.stripe_empty_price_id === si.price?.id ||
config.stripe_prepaid_price_v2_id === si.price?.id
);
},
);
}
if (errorOnNotFound && !result) {
throw new InternalError({
message: `Stripe subscription item not found for price: ${price.id}`,
});
}
return result;
}

View File

@@ -1,145 +0,0 @@
import { ACTIVE_STATUSES } from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import {
submitBillingDataToVercel,
submitInvoiceToVercel,
} from "@/external/vercel/misc/vercelInvoicing.js";
import { logVercelWebhook } from "@/external/vercel/misc/vercelMiddleware.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { stripeInvoiceToStripeSubscriptionId } from "../invoices/utils/convertStripeInvoice";
import {
getFullStripeInvoice,
getStripeExpandedInvoice,
} from "../stripeInvoiceUtils.js";
import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js";
/**
* Handles invoice.finalized webhook
*
* For regular invoices: Creates Autumn invoice records
* For Vercel custom payment method invoices: Submits invoice to Vercel marketplace for payment processing
*/
export const handleInvoiceFinalized = async ({
ctx,
}: {
ctx: StripeWebhookContext;
}) => {
const { db, org, env, logger, stripeEvent, stripeCli, fullCustomer } = ctx;
const invoiceData = stripeEvent.data.object as Stripe.Invoice;
const invoice = await getFullStripeInvoice({
stripeCli,
stripeId: invoiceData.id!,
});
const features = await FeatureService.list({
db,
orgId: org.id,
env,
});
const subId = stripeInvoiceToStripeSubscriptionId(invoice);
if (subId) {
const stripeCli = createStripeCli({ org, env });
// Handle Vercel custom payment method invoices
if (subId && invoice.amount_due > 0) {
const subscription = await stripeCli.subscriptions.retrieve(subId);
const vercelInstallationId =
subscription.metadata?.vercel_installation_id;
const vercelBillingPlanId = subscription.metadata?.vercel_billing_plan_id;
if (
vercelInstallationId &&
vercelBillingPlanId &&
subscription.default_payment_method
) {
const paymentMethod = await stripeCli.paymentMethods.retrieve(
subscription.default_payment_method as string,
);
// Only process if it's a custom payment method (Vercel)
if (paymentMethod.type === "custom" && fullCustomer) {
logVercelWebhook({
logger,
org,
event: {
type: "marketplace.invoice.finalized",
id: invoice.id,
},
});
try {
const product = await ProductService.getFull({
db,
orgId: org.id,
env,
idOrInternalId: vercelBillingPlanId,
});
if (!product) {
console.error("Product not found for Vercel billing plan", {
billingPlanId: vercelBillingPlanId,
});
return;
}
// Submit billing data to Vercel (detailed usage breakdown)
await submitBillingDataToVercel({
installationId: vercelInstallationId,
invoice,
customer: fullCustomer,
product,
});
// Submit invoice to Vercel
await submitInvoiceToVercel({
installationId: vercelInstallationId,
invoice,
customer: fullCustomer,
product,
org,
features,
});
// Do NOT report payment to Stripe here - we've only submitted the invoice to Vercel
// Vercel will process payment asynchronously and send marketplace.invoice.paid webhook
// handleMarketplaceInvoicePaid will then:
// 1. Create cus_product (user gets access)
// 2. Report payment as "guaranteed" to Stripe
// 3. Attach payment record to invoice (marks it as paid)
} catch (error) {
logger.error("Failed to process Vercel invoice", {
data: {
error: String(error),
invoiceId: invoice.id,
},
});
}
}
}
}
const expandedInvoice = await getStripeExpandedInvoice({
stripeCli,
stripeInvoiceId: invoice.id!,
});
const activeProducts = await CusProductService.getByStripeSubId({
db,
stripeSubId: subId,
orgId: org.id,
env,
inStatuses: ACTIVE_STATUSES,
});
if (activeProducts.length === 0) {
return;
}
}
};

View File

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

View File

@@ -1,38 +0,0 @@
import { type FullCusProduct, isPrepaidPrice } from "@autumn/shared";
/**
* Convert cusProduct.options to feature_quantities with actual quantities
* (multiplied by billingUnits for prepaid features)
*/
function cusProductToFeatureQuantities({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) {
return cusProduct.options.map((option) => {
const cusPrice = cusProduct.customer_prices.find((cp) => {
const cusEnt = cusProduct.customer_entitlements.find(
(ce) =>
ce.internal_feature_id === option.internal_feature_id ||
ce.entitlement.feature_id === option.feature_id,
);
return (
cusEnt &&
cp.price.config.internal_feature_id ===
cusEnt.entitlement.internal_feature_id
);
});
let quantity = option.quantity;
if (cusPrice && isPrepaidPrice(cusPrice.price)) {
const billingUnits = cusPrice.price.config.billing_units ?? 1;
quantity = option.quantity * billingUnits;
}
return {
feature_id: option.feature_id,
quantity,
};
});
}

View File

@@ -1,25 +0,0 @@
import type { AutumnBillingPlan } from "@autumn/shared";
import type { MaterializedScheduledPhase } from "../utils/materializeScheduledPhases";
/** Merge immediate billing changes with future scheduled rows for Autumn execution. */
export const buildCreateScheduleExecutionPlan = ({
immediateAutumnBillingPlan,
futureScheduledPhases,
}: {
immediateAutumnBillingPlan: AutumnBillingPlan;
futureScheduledPhases: MaterializedScheduledPhase[];
}): AutumnBillingPlan => ({
...immediateAutumnBillingPlan,
insertCustomerProducts: [
...immediateAutumnBillingPlan.insertCustomerProducts,
...futureScheduledPhases.flatMap((phase) => phase.customerProducts),
],
customPrices: [
...(immediateAutumnBillingPlan.customPrices ?? []),
...futureScheduledPhases.flatMap((phase) => phase.customPrices),
],
customEntitlements: [
...(immediateAutumnBillingPlan.customEntitlements ?? []),
...futureScheduledPhases.flatMap((phase) => phase.customEntitlements),
],
});

View File

@@ -1,113 +0,0 @@
import {
addDuration,
BillingVersion,
type CreateScheduleParamsV0,
CusProductStatus,
type FullCustomer,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
import { setupAttachProductContext } from "../../attach/setup/setupAttachProductContext";
import { validateCreateSchedulePhasePlans } from "../errors/validateCreateSchedulePhasePlans";
export type MaterializedScheduledPhase = {
starts_at: number;
customerProducts: Awaited<ReturnType<typeof initFullCustomerProduct>>[];
customPrices: NonNullable<
Awaited<ReturnType<typeof setupAttachProductContext>>["customPrices"]
>;
customEntitlements: NonNullable<
Awaited<ReturnType<typeof setupAttachProductContext>>["customEnts"]
>;
};
/** Build scheduled customer products for future phases. */
export const materializeScheduledPhases = async ({
ctx,
currentEpochMs,
fullCustomer,
phases,
}: {
ctx: AutumnContext;
currentEpochMs: number;
fullCustomer: FullCustomer;
phases: CreateScheduleParamsV0["phases"][number][];
}): Promise<MaterializedScheduledPhase[]> => {
return await Promise.all(
phases.map(async (phase, index) => {
const nextPhaseStartsAt = phases[index + 1]?.starts_at;
const materializedProducts = await Promise.all(
phase.plans.map(async (plan) => {
const {
fullProduct,
customPrices = [],
customEnts: customEntitlements = [],
} = await setupAttachProductContext({
ctx,
params: plan,
});
const trialEndsAt = fullProduct.free_trial
? addDuration({
now: phase.starts_at,
durationType: fullProduct.free_trial.duration,
durationLength: fullProduct.free_trial.length,
})
: undefined;
const featureQuantities = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: {
feature_quantities: plan.feature_quantities,
},
fullProduct,
initializeUndefinedQuantities: true,
});
return {
fullProduct,
customerProduct: initFullCustomerProduct({
ctx,
initContext: {
fullCustomer,
fullProduct,
featureQuantities,
resetCycleAnchor: phase.starts_at,
freeTrial: fullProduct.free_trial ?? null,
trialEndsAt,
now: currentEpochMs,
billingVersion: BillingVersion.V2,
},
initOptions: {
startsAt: phase.starts_at,
endedAt: nextPhaseStartsAt,
status: CusProductStatus.Scheduled,
isCustom:
customPrices.length > 0 || customEntitlements.length > 0,
},
}),
customPrices,
customEntitlements,
};
}),
);
validateCreateSchedulePhasePlans({
fullProducts: materializedProducts.map(
({ fullProduct }) => fullProduct,
),
});
return {
starts_at: phase.starts_at,
customerProducts: materializedProducts.map(
({ customerProduct }) => customerProduct,
),
customPrices: materializedProducts.flatMap(
({ customPrices }) => customPrices,
),
customEntitlements: materializedProducts.flatMap(
({ customEntitlements }) => customEntitlements,
),
};
}),
);
};

View File

@@ -1,24 +0,0 @@
import { getTestClockFrozenTimeMs } from "@/external/stripe/testClocks/utils/convertStripeTestClock";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusService } from "@/internal/customers/CusService";
/** Resolves "now" for schedule operations, respecting Stripe test clocks in sandbox. */
export const resolveCurrentEpochMs = async ({
ctx,
customerId,
}: {
ctx: AutumnContext;
customerId: string;
}): Promise<number> => {
const customer = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const testClockMs = await getTestClockFrozenTimeMs({
ctx,
stripeCustomerId: customer?.processor?.id,
});
return testClockMs ?? Date.now();
};

View File

@@ -1,59 +0,0 @@
import {
customerPriceToBillingUnits,
type FullCustomerEntitlement,
type FullCustomerPrice,
priceToProrationConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
/**
* Calculates the entitlement balance change resulting from a quantity update.
*
* Computes balance change as: quantity_difference × billing_units_per_quantity.
* Returns entitlement ID from price association, or undefined if price has no entitlement.
*
* @param quantityDifferenceForEntitlements - Change in quantity (can be negative)
* @param billingUnitsPerQuantity - Multiplier for converting quantities to usage units
* @param customerPrice - Customer's price configuration
* @param customerEntitlements - Array of all entitlements for this customer product
* @returns Entitlement ID and balance change to apply
*/
export const calculateUpdateQuantityEntitlementChange = ({
quantityDifferenceForEntitlements,
customerPrice,
customerEntitlement,
}: {
quantityDifferenceForEntitlements: number;
customerPrice: FullCustomerPrice;
customerEntitlement: FullCustomerEntitlement;
}): {
customerEntitlementId: string;
customerEntitlementBalanceChange: number;
} => {
const isUpgrade = quantityDifferenceForEntitlements > 0;
const { shouldApplyProration } = priceToProrationConfig({
price: customerPrice.price,
isUpgrade,
});
// If downgrade and no proration, don't change entitlement balance THIS cycle
if (!isUpgrade && !shouldApplyProration) {
return {
customerEntitlementId: customerEntitlement?.id,
customerEntitlementBalanceChange: 0,
};
}
const billingUnits = customerPriceToBillingUnits({ customerPrice });
const customerEntitlementBalanceChange = new Decimal(
quantityDifferenceForEntitlements,
)
.mul(billingUnits)
.toNumber();
return {
customerEntitlementId: customerEntitlement?.id,
customerEntitlementBalanceChange,
};
};

View File

@@ -1,55 +0,0 @@
import type { AutumnBillingPlan } from "@autumn/shared";
import {
cusProductToPrices,
ErrCode,
isOneOffPrice,
isPrepaidPrice,
RecaseError,
type UsagePriceConfig,
} from "@autumn/shared";
export const handleFeatureQuantityErrors = ({
autumnBillingPlan,
}: {
autumnBillingPlan: AutumnBillingPlan;
}) => {
const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0];
if (!newCustomerProduct) return;
const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct });
const prepaidPrices = newPrices.filter(
(p) => isPrepaidPrice(p) && !isOneOffPrice(p),
);
if (prepaidPrices.length === 0) return;
const options = newCustomerProduct.options || [];
const missingFeatures: string[] = [];
for (const price of prepaidPrices) {
const config = price.config as UsagePriceConfig;
const internalFeatureId = config.internal_feature_id;
// Check if there's an option for this prepaid price
const hasOption = options.some(
(opt) => opt.internal_feature_id === internalFeatureId,
);
if (!hasOption) {
// Try to find the feature_id from customer_entitlements
const cusEnt = newCustomerProduct.customer_entitlements?.find(
(ce) => ce.entitlement.internal_feature_id === internalFeatureId,
);
const featureId = cusEnt?.entitlement.feature_id || internalFeatureId;
missingFeatures.push(featureId);
}
}
if (missingFeatures.length > 0) {
throw new RecaseError({
message: `Missing quantity options for prepaid features: ${missingFeatures.join(", ")}`,
code: ErrCode.InvalidOptions,
statusCode: 400,
});
}
};

View File

@@ -1,23 +0,0 @@
import { BillingVersion, type FullCustomer } from "@autumn/shared";
import type Stripe from "stripe";
/** Build a minimal BillingContext with just the fields createInvoiceForBilling needs. */
export const buildMinimalBillingContext = ({
fullCustomer,
stripeCustomerId,
paymentMethod,
}: {
fullCustomer: FullCustomer;
stripeCustomerId: string;
paymentMethod: Stripe.PaymentMethod;
}) => ({
fullCustomer,
fullProducts: [],
featureQuantities: [],
currentEpochMs: Date.now(),
billingCycleAnchorMs: "now" as const,
resetCycleAnchorMs: "now" as const,
stripeCustomer: { id: stripeCustomerId } as Stripe.Customer,
paymentMethod,
billingVersion: BillingVersion.V2,
});

View File

@@ -1,125 +0,0 @@
import type { BillingContext, BillingPlan } from "@autumn/shared";
import {
type CheckoutLineV0,
type CheckoutResponseV0,
CheckoutResponseV0Schema,
orgToCurrency,
toProductItem,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils";
import {
getProductItemResponse,
getProductResponse,
} from "@/internal/products/productUtils/productResponseUtils/getProductResponse";
import { notNullish } from "@/utils/genUtils";
import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview";
export const billingContextToCheckoutResponse = async ({
ctx,
billingContext,
billingPlan,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
billingPlan: BillingPlan;
}): Promise<CheckoutResponseV0> => {
const { fullCustomer, fullProducts, featureQuantities } = billingContext;
const { features, org } = ctx;
const currency = orgToCurrency({ org });
// 1. Get primary product (first non-add-on or first product)
const mainProduct = fullProducts.find((p) => !p.is_add_on) ?? fullProducts[0];
const product = mainProduct
? await getProductResponse({
product: mainProduct,
features,
fullCus: fullCustomer,
currency,
db: ctx.db,
options: featureQuantities,
})
: null;
// 2. Build line items from billing plan
const planLineItems = billingPlan.autumn.lineItems ?? [];
// Collect all prices and entitlements from products for lookup
const allPrices = fullProducts.flatMap((p) => p.prices);
const allEnts = fullProducts.flatMap((p) => p.entitlements);
const lines: CheckoutLineV0[] = planLineItems
.filter((line) => line.chargeImmediately)
.map((line) => {
const { price } = line.context;
// Find entitlement for this price
const ent = getPriceEntitlement(price, allEnts);
// Build product item from price + entitlement
const productItem = toProductItem({ ent, price });
return {
description: line.description,
amount: line.amountAfterDiscounts,
item: getProductItemResponse({
item: productItem,
features,
currency,
withDisplay: true,
options: featureQuantities,
}),
};
})
.filter(notNullish);
// 3. Calculate total
const total = new Decimal(lines.reduce((acc, line) => acc + line.amount, 0))
.toDecimalPlaces(2)
.toNumber();
// 4. Get next cycle preview
const { nextCycle } = billingPlanToNextCyclePreview({
ctx,
billingContext,
billingPlan,
});
// 5. Build options from feature quantities
const options = featureQuantities
.map((fq) => {
const price = allPrices.find(
(p) =>
p.config &&
"feature_id" in p.config &&
(p.config.feature_id === fq.feature_id ||
p.config.internal_feature_id === fq.internal_feature_id),
);
if (!price) return undefined;
const billingUnits =
price.config && "billing_units" in price.config
? price.config.billing_units || 1
: 1;
return {
feature_id: fq.feature_id,
quantity: fq.quantity * billingUnits,
};
})
.filter(notNullish);
return CheckoutResponseV0Schema.parse({
customer_id: fullCustomer.id || fullCustomer.internal_id,
product,
current_product: null,
lines,
options,
total,
currency,
next_cycle: nextCycle,
});
};

View File

@@ -1,12 +0,0 @@
import { cp } from "@autumn/shared";
import type { AutumnBillingPlan } from "@autumn/shared";
export const billingPlanToNewActiveCustomerProduct = ({
autumnBillingPlan,
}: {
autumnBillingPlan: AutumnBillingPlan;
}) => {
return autumnBillingPlan.insertCustomerProducts?.find(
(customerProduct) => cp(customerProduct).hasActiveStatus().valid,
);
};

View File

@@ -1,70 +0,0 @@
import type {
InsertDbInvoiceLineItem,
InvoiceLineItemDiscount,
LineItem,
} from "@autumn/shared";
/**
* Helper for full match case - converts an Autumn LineItem to InsertInvoiceLineItem.
*/
export const billingLineItemToInsertDbLineItem = ({
lineItem,
invoiceId,
stripeInvoiceId,
stripeLineItemId,
}: {
lineItem: LineItem;
invoiceId: string;
stripeInvoiceId: string;
stripeLineItemId?: string;
}): InsertDbInvoiceLineItem => {
const { context } = lineItem;
return {
id: lineItem.id,
invoice_id: invoiceId,
stripe_id: stripeLineItemId ?? null,
stripe_invoice_id: stripeInvoiceId,
stripe_product_id: lineItem.stripeProductId ?? null,
stripe_price_id: lineItem.stripePriceId ?? null,
stripe_discountable: context.discountable ?? true,
amount: lineItem.amount,
amount_after_discounts: lineItem.amountAfterDiscounts,
currency: context.currency,
total_quantity: lineItem.totalQuantity ?? null,
paid_quantity: lineItem.paidQuantity ?? null,
description: lineItem.description,
direction: context.direction,
billing_timing: context.billingTiming,
prorated: lineItem.prorated,
price_id: context.price.id,
customer_product_ids: context.customerProduct?.id
? [context.customerProduct.id]
: [],
customer_price_ids: context.customerPrice?.id
? [context.customerPrice.id]
: [],
customer_entitlement_ids: context.customerEntitlement?.id
? [context.customerEntitlement.id]
: [],
internal_product_id: context.product.internal_id,
product_id: context.product.id,
internal_feature_id: context.feature?.internal_id ?? null,
feature_id: context.feature?.id ?? null,
effective_period_start: context.effectivePeriod?.start ?? null,
effective_period_end: context.effectivePeriod?.end ?? null,
discounts: lineItem.discounts.map(
(d): InvoiceLineItemDiscount => ({
amount_off: d.amountOff,
percent_off: d.percentOff,
stripe_coupon_id: d.stripeCouponId,
}),
),
};
};

View File

@@ -1,78 +0,0 @@
import type {
BillingPreviewResponse,
FullCusProduct,
LineItem,
} from "@autumn/shared";
import { formatMs } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
import type { NextCyclePreviewDebug } from "../billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview";
const formatCustomerProduct = (customerProduct: FullCusProduct) =>
`${customerProduct.product.name} (${customerProduct.product_id}) [${customerProduct.status}]`;
const formatLineItem = (item: LineItem) =>
`${item.description}: ${item.amountAfterDiscounts} (charge: ${item.chargeImmediately})`;
export const logBillingPreview = ({
ctx,
allLineItems,
immediateLineItems,
total,
currency,
nextCycleDebug,
nextCycle,
}: {
ctx: AutumnContext;
allLineItems: LineItem[];
immediateLineItems: LineItem[];
total: number;
currency: string;
nextCycleDebug: NextCyclePreviewDebug;
nextCycle: BillingPreviewResponse["next_cycle"];
}) => {
const {
allCustomerProducts,
currentCustomerProducts,
smallestInterval,
anchorMs,
nextCycleStart,
filteredCustomerProducts,
} = nextCycleDebug;
addToExtraLogs({
ctx,
extras: {
billingPreview: {
// Immediate charge breakdown
total: `${total} ${currency}`,
allLineItems:
allLineItems.length > 0 ? allLineItems.map(formatLineItem) : "none",
immediateLineItems:
immediateLineItems.length > 0
? immediateLineItems.map(formatLineItem)
: "none",
// Next cycle calculation
nextCycle: {
allCustomerProducts:
allCustomerProducts.map(formatCustomerProduct).join(", ") || "none",
currentCustomerProducts:
currentCustomerProducts.map(formatCustomerProduct).join(", ") ||
"none",
smallestInterval: smallestInterval
? `${smallestInterval.intervalCount} ${smallestInterval.interval}`
: "none (not a subscription)",
anchor: formatMs(anchorMs),
nextCycleStart: nextCycleStart ? formatMs(nextCycleStart) : "n/a",
filteredCustomerProducts:
filteredCustomerProducts.map(formatCustomerProduct).join(", ") ||
"none",
result: nextCycle
? `starts: ${formatMs(nextCycle.starts_at)} | total: ${nextCycle.total} | items: ${nextCycle.line_items.length}`
: "undefined",
},
},
},
});
};

View File

@@ -1,197 +0,0 @@
import {
type AttachConfig,
calculateProrationAmount,
cusProductToProduct,
customerPriceToCustomerEntitlement,
type Feature,
type FeatureOptions,
type FullCusProduct,
findCusPriceByFeature,
getFeatureInvoiceDescription,
OnDecrease,
priceToInvoiceAmount,
shouldBillNow,
shouldProrate,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { Stripe } from "stripe";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
export const handleQuantityDowngrade = async ({
ctx,
attachParams,
attachConfig,
cusProduct,
stripeSub,
oldOptions,
newOptions,
subItem,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
attachConfig: AttachConfig;
cusProduct: FullCusProduct;
stripeSub: Stripe.Subscription;
oldOptions: FeatureOptions;
newOptions: FeatureOptions;
subItem: Stripe.SubscriptionItem;
}) => {
const { db, logger, org, features } = ctx;
const { stripeCli, paymentMethod } = attachParams;
const cusPrice = findCusPriceByFeature({
internalFeatureId: newOptions.internal_feature_id!,
cusPrices: cusProduct.customer_prices,
})!;
const onDecrease =
cusPrice.price.proration_config?.on_decrease ||
OnDecrease.ProrateImmediately;
const subItemDifference = new Decimal(newOptions.quantity)
.minus(
notNullish(oldOptions.upcoming_quantity)
? oldOptions.upcoming_quantity!
: oldOptions.quantity,
)
.toNumber();
const billingUnits =
(cusPrice.price.config as UsagePriceConfig).billing_units || 1;
const newSubItemQuantity = new Decimal(subItem.quantity || 0)
.plus(subItemDifference)
.toNumber();
let invoice = null;
const createDowngradeInvoice = async () => {
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
const prevAmount = priceToInvoiceAmount({
price: cusPrice.price,
quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(),
});
const newAmount = priceToInvoiceAmount({
price: cusPrice.price,
quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(),
});
let amount = new Decimal(newAmount).minus(prevAmount).toNumber();
amount = calculateProrationAmount({
periodEnd: end * 1000,
periodStart: start * 1000,
now: attachParams.now || Date.now(),
amount,
allowNegative: true,
});
const product = cusProductToProduct({ cusProduct });
const feature = features.find(
(f: Feature) => f.internal_id === newOptions.internal_feature_id,
)!;
const invoiceItem = constructStripeInvoiceItem({
ctx,
product,
amount: amount,
price: cusPrice.price,
description: getFeatureInvoiceDescription({
feature: feature,
usage: newOptions.quantity,
billingUnits: (cusPrice.price.config as UsagePriceConfig).billing_units,
prodName: product.name,
isPrepaid: true,
fromUnix: attachParams.now,
}),
stripeSubId: stripeSub.id,
stripeCustomerId: stripeSub.customer as string,
periodStart: Math.floor(
attachParams.now ? attachParams.now / 1000 : Date.now(),
),
periodEnd: Math.floor(end * 1000),
});
logger.info(
`🔥 Creating downgrade prepaid invoice item: ${invoiceItem.description} - ${amount}`,
);
await stripeCli.invoiceItems.create(invoiceItem);
if (shouldBillNow(onDecrease)) {
const { invoice: finalInvoice } = await createAndFinalizeInvoice({
stripeCli,
stripeCusId: stripeSub.customer as string,
stripeSubId: stripeSub.id,
paymentMethod: paymentMethod || null,
chargeAutomatically: !attachConfig.invoiceOnly,
logger,
});
invoice = finalInvoice;
try {
const invoiceItems = await getInvoiceItems({
stripeInvoice: finalInvoice,
prices: [cusPrice.price],
logger,
});
await InvoiceService.createInvoiceFromStripe({
db,
stripeInvoice: finalInvoice,
internalCustomerId: cusProduct.internal_customer_id!,
internalEntityId: cusProduct.internal_entity_id,
productIds: [cusProduct.product_id],
internalProductIds: [cusProduct.internal_product_id],
org,
sendRevenueEvent: true,
items: invoiceItems,
});
} catch (error) {
logger.error(`Failed to create invoice from stripe: ${error}`);
}
}
};
await stripeCli.subscriptionItems.update(subItem.id, {
quantity: Math.max(newSubItemQuantity, 0),
// proration_behavior: stripeProration,
proration_behavior: "none",
});
if (!shouldProrate(onDecrease)) {
newOptions.upcoming_quantity = newOptions.quantity;
newOptions.quantity = oldOptions.quantity;
return;
}
await createDowngradeInvoice();
const cusEnt = customerPriceToCustomerEntitlement({
customerPrice: cusPrice,
customerEntitlements: cusProduct.customer_entitlements,
});
if (cusEnt) {
const decrementBy = new Decimal(oldOptions.quantity)
.minus(new Decimal(newOptions.quantity))
.mul(billingUnits)
.toNumber();
await CusEntService.decrement({
ctx,
id: cusEnt.id,
amount: decrementBy,
});
}
};

View File

@@ -1,206 +0,0 @@
import {
type AttachConfig,
calculateProrationAmount,
cusProductToProduct,
customerPriceToCustomerEntitlement,
type Feature,
type FeatureOptions,
type FullCusProduct,
type FullCustomerPrice,
getFeatureInvoiceDescription,
OnIncrease,
priceToInvoiceAmount,
shouldBillNow,
shouldProrate,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { Stripe } from "stripe";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
export const handleQuantityUpgrade = async ({
ctx,
attachParams,
cusProduct,
stripeSubs,
attachConfig,
oldOptions,
newOptions,
cusPrice,
stripeSub,
subItem,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
cusProduct: FullCusProduct;
attachConfig: AttachConfig;
stripeSubs: Stripe.Subscription[];
oldOptions: FeatureOptions;
newOptions: FeatureOptions;
cusPrice: FullCustomerPrice;
stripeSub: Stripe.Subscription;
subItem: Stripe.SubscriptionItem;
}) => {
// Manually calculate prorations...
const { features, org, logger, db } = ctx;
const { stripeCli, now, paymentMethod } = attachParams;
const difference = new Decimal(newOptions.quantity)
.minus(oldOptions.quantity)
.toNumber();
const subItemDifference = new Decimal(newOptions.quantity)
.minus(
notNullish(oldOptions.upcoming_quantity)
? oldOptions.upcoming_quantity!
: oldOptions.quantity,
)
.toNumber();
const onIncrease =
cusPrice.price.proration_config?.on_increase ||
OnIncrease.ProrateImmediately;
const prorate = shouldProrate(onIncrease);
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
let invoice = null;
if (prorate && stripeSub?.status !== "trialing") {
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
const prevAmount = priceToInvoiceAmount({
price: cusPrice.price,
quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(),
});
const newAmount = priceToInvoiceAmount({
price: cusPrice.price,
quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(),
});
let amount = new Decimal(newAmount).minus(prevAmount).toNumber();
if (prorate) {
amount = calculateProrationAmount({
periodEnd: end * 1000,
periodStart: start * 1000,
now: now || Date.now(),
amount,
});
}
const feature = features.find(
(f: Feature) => f.internal_id === newOptions.internal_feature_id,
)!;
const product = cusProductToProduct({ cusProduct });
const invoiceItem = constructStripeInvoiceItem({
ctx,
product,
amount: amount,
price: cusPrice.price,
description: getFeatureInvoiceDescription({
feature: feature,
usage: newOptions.quantity,
billingUnits,
prodName: product.name,
isPrepaid: true,
fromUnix: now,
}),
stripeSubId: stripeSub.id,
stripeCustomerId: stripeSub.customer as string,
periodStart: Math.floor((now || Date.now()) / 1000),
periodEnd: Math.floor(end * 1000),
});
logger.info(
`🔥 Creating prepaid invoice item: ${invoiceItem.description} - ${amount}`,
);
await stripeCli.invoiceItems.create(invoiceItem);
if (shouldBillNow(onIncrease)) {
const { invoice: finalInvoice } = await createAndFinalizeInvoice({
stripeCli,
stripeCusId: stripeSub.customer as string,
stripeSubId: stripeSub.id,
paymentMethod: paymentMethod || null,
chargeAutomatically: !attachConfig.invoiceOnly,
logger,
});
try {
const invoiceItems = await getInvoiceItems({
stripeInvoice: finalInvoice,
prices: [cusPrice.price],
logger,
});
await InvoiceService.createInvoiceFromStripe({
db,
stripeInvoice: finalInvoice,
internalCustomerId: cusProduct.internal_customer_id!,
internalEntityId: cusProduct.internal_entity_id,
productIds: [cusProduct.product_id],
internalProductIds: [cusProduct.internal_product_id],
org,
sendRevenueEvent: true,
items: invoiceItems,
});
} catch (error) {
logger.error(`Failed to create invoice from stripe: ${error}`);
}
invoice = finalInvoice;
}
}
// 1. If no sub item, add
if (!subItem) {
if (!cusPrice.price.config.stripe_price_id) {
throw new Error(
"Trying to add new sub item for upgrade quantity flow, but no Stripe price ID found",
);
}
await stripeCli.subscriptionItems.create({
subscription: stripeSub.id,
price: cusPrice.price.config.stripe_price_id,
quantity: subItemDifference,
proration_behavior: "none",
});
} else {
await stripeCli.subscriptionItems.update(subItem.id, {
// quantity: newOptions.quantity,
quantity: (subItem.quantity || 0) + subItemDifference,
proration_behavior: "none",
});
}
// Update cus ent
const cusEnt = customerPriceToCustomerEntitlement({
customerPrice: cusPrice,
customerEntitlements: cusProduct.customer_entitlements,
});
if (cusEnt) {
const incrementBy = new Decimal(difference).mul(billingUnits).toNumber();
logger.info(
`🔥 Incrementing feature ${cusEnt.entitlement.feature.id} balance by ${incrementBy}`,
);
await CusEntService.increment({
ctx,
id: cusEnt.id,
amount: incrementBy,
});
}
return { invoice };
};

View File

@@ -1,82 +0,0 @@
import {
type AttachConfig,
ErrCode,
type FeatureOptions,
type FullCusProduct,
findCusPriceByFeature,
} from "@autumn/shared";
import type { Stripe } from "stripe";
import { stripeSubscriptionItemUtils } from "@/external/stripe/subscriptions/subscriptionItems/index.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import RecaseError from "@/utils/errorUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import { handleQuantityDowngrade } from "./handleQuantityDowngrade.js";
import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js";
export const handleUpdateFeatureQuantity = async ({
ctx,
attachParams,
attachConfig,
cusProduct,
stripeSubs,
oldOptions,
newOptions,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
attachConfig: AttachConfig;
cusProduct: FullCusProduct;
stripeSubs: Stripe.Subscription[];
oldOptions: FeatureOptions;
newOptions: FeatureOptions;
}) => {
const subToUpdate = stripeSubs?.[0];
const cusPrice = findCusPriceByFeature({
internalFeatureId: newOptions.internal_feature_id!,
cusPrices: cusProduct.customer_prices,
})!;
const price = cusPrice.price;
if (!subToUpdate) {
throw new RecaseError({
message: `Failed to update prepaid quantity for ${newOptions.feature_id} because no subscription found`,
code: ErrCode.InternalError,
statusCode: 500,
});
}
const subItem = stripeSubscriptionItemUtils.find.byAutumnPrice({
stripeSubscriptionItems: subToUpdate.items.data,
price,
product: cusProduct.product,
errorOnNotFound: true,
});
if (newOptions.quantity < oldOptions.quantity) {
return await handleQuantityDowngrade({
ctx,
attachParams,
attachConfig,
cusProduct,
stripeSub: subToUpdate,
oldOptions,
newOptions,
subItem,
});
} else {
return await handleQuantityUpgrade({
ctx,
attachParams,
attachConfig,
cusProduct,
stripeSubs,
oldOptions,
newOptions,
cusPrice,
stripeSub: subToUpdate,
subItem,
});
}
};

View File

@@ -1,147 +0,0 @@
import type { AttachConfig, FullCusProduct } from "@autumn/shared";
import type Stripe from "stripe";
import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import { CusProductService } from "../../cusProducts/CusProductService.js";
import { paramsToScheduleItems } from "./paramsToScheduleItems.js";
import { getCusProductsToRemove } from "./paramsToSubItems.js";
export const subToNewSchedule = async ({
ctx,
sub,
attachParams,
config,
endOfBillingPeriod,
removeCusProducts,
}: {
ctx: AutumnContext;
sub: Stripe.Subscription;
attachParams: AttachParams;
config: AttachConfig;
endOfBillingPeriod: number;
removeCusProducts?: FullCusProduct[];
}) => {
const itemSet = await getStripeSubItems2({
attachParams,
config,
});
let cusProductsToRemove: FullCusProduct[] = [];
cusProductsToRemove = getCusProductsToRemove({
attachParams,
includeCanceled: true,
});
console.log(
`REMOVING CUS PRODUCTS: ${cusProductsToRemove.map((cp) => `${cp.product.id} (E: ${cp.entity_id})`).join(", ")}`,
);
const res = await paramsToScheduleItems({
ctx,
sub,
attachParams,
config,
removeCusProducts: removeCusProducts || cusProductsToRemove,
billingPeriodEnd: endOfBillingPeriod,
});
const { stripeCli } = attachParams;
let newSchedule: Stripe.SubscriptionSchedule | undefined;
// if (sub.cancel_at) {
// logger.info(`UNCANCELING SUB ${sub.id}`);
// await stripeCli.subscriptions.update(sub.id, {
// cancel_at: null,
// });
// }
// console.log("New phase");
// await logPhases({
// phases: res.phases,
// db: req.db,
// });
// throw new Error("test");
if (res.phases[0].items.length > 0) {
itemSet.subItems = res.phases[0].items;
// Create schedule from existing subscription
newSchedule = await stripeCli.subscriptionSchedules.create({
from_subscription: sub.id,
});
// console.log("SChedule ID: ", newSchedule.id);
// const newScheduleId = "sub_sched_1RxyM89mx3u0jkgOgbbsAbDS";
const newScheduleId = newSchedule.id;
await stripeCli.subscriptionSchedules.update(newScheduleId, {
phases: [
{
items: newSchedule.phases[0].items.map((item) => {
const priceId = item.price as string;
// Re-apply metadata from subscription items since
// Stripe's from_subscription doesn't copy item metadata
const subItem = sub.items.data.find(
(si) => si.price.id === priceId,
);
const metadata =
subItem?.metadata && Object.keys(subItem.metadata).length > 0
? subItem.metadata
: item.metadata && Object.keys(item.metadata).length > 0
? item.metadata
: undefined;
return {
price: priceId,
quantity: item.quantity,
...(metadata && { metadata }),
};
}),
start_date: newSchedule.phases[0].start_date,
end_date: endOfBillingPeriod,
trial_end: sub?.trial_end || undefined,
},
{
items: res.phases[0].items,
start_date: endOfBillingPeriod,
},
],
end_behavior: "release",
});
await CusProductService.updateByStripeSubId({
db: ctx.db,
stripeSubId: sub.id!,
updates: {
scheduled_ids: [newSchedule!.id],
},
});
}
return newSchedule as Stripe.SubscriptionSchedule;
};
// phases: ([
// {
// items: scheduleItems.items,
// // Set proration behavior for this phase transition
// proration_behavior: "create_prorations", // Options: 'create_prorations', 'none', 'always_invoice'
// // Optional: Set how long this phase should last
// iterations: 1, // Number of billing cycles for this phase
// // Optional: Add metadata for this phase
// metadata: {
// phase_type: "scheduled_update",
// created_by: "attach_flow",
// },
// },
// ],
// Option 2: Use your existing createSubSchedule function
// newSchedule = await createSubSchedule({
// db: req.db,
// attachParams,
// itemSet,
// endOfBillingPeriod,
// });

View File

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

View File

@@ -1,495 +0,0 @@
import { type SQL, sql } from "drizzle-orm";
import { getEntityOptionsAggregateFragments } from "./getEntityOptionsAggregateFragments.js";
/**
* Per-entity rollover rows sourced from the `rollovers` table, mirroring the
* four branches in `entity_balance_rows` (product-attached / loose, per-entity
* jsonb / top-level). Top-level rollovers are attributed to the owning entity
* via `cp.internal_entity_id` (product-attached) or `ce.internal_entity_id`
* (loose), matching main-balance behaviour. Also exposes per-entity and
* per-feature rollups used by the outer aggregate CTEs.
*/
const buildEntityRolloverCtes = ({
statusFilter,
}: {
statusFilter: SQL;
}) => sql`
entity_rollover_rows AS (
-- Product-attached cusEnt, per-entity rollover (rollovers.entities jsonb)
SELECT
ce.internal_feature_id,
ce.internal_customer_id,
kv.entity_key AS entity_key,
COALESCE((kv.entity_value->>'balance')::numeric, 0) AS rollover_balance,
COALESCE((kv.entity_value->>'usage')::numeric, 0) AS rollover_usage
FROM rollovers r
JOIN customer_entitlements ce ON r.cus_ent_id = ce.id
JOIN customer_products cp ON ce.customer_product_id = cp.id
CROSS JOIN LATERAL jsonb_each(r.entities) AS kv(entity_key, entity_value)
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND cp.internal_entity_id IS NOT NULL
AND jsonb_typeof(r.entities) = 'object'
AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
${statusFilter}
UNION ALL
-- Product-attached cusEnt, top-level rollover (attributed to cp.internal_entity_id)
SELECT
ce.internal_feature_id,
ce.internal_customer_id,
cp.internal_entity_id AS entity_key,
r.balance::numeric AS rollover_balance,
COALESCE(r.usage, 0)::numeric AS rollover_usage
FROM rollovers r
JOIN customer_entitlements ce ON r.cus_ent_id = ce.id
JOIN customer_products cp ON ce.customer_product_id = cp.id
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND cp.internal_entity_id IS NOT NULL
AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
${statusFilter}
UNION ALL
-- Loose cusEnt (no customer_product), per-entity rollover
SELECT
ce.internal_feature_id,
ce.internal_customer_id,
kv.entity_key AS entity_key,
COALESCE((kv.entity_value->>'balance')::numeric, 0) AS rollover_balance,
COALESCE((kv.entity_value->>'usage')::numeric, 0) AS rollover_usage
FROM rollovers r
JOIN customer_entitlements ce ON r.cus_ent_id = ce.id
CROSS JOIN LATERAL jsonb_each(r.entities) AS kv(entity_key, entity_value)
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND ce.customer_product_id IS NULL
AND ce.internal_entity_id IS NOT NULL
AND jsonb_typeof(r.entities) = 'object'
AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
UNION ALL
-- Loose cusEnt, top-level rollover (attributed to ce.internal_entity_id)
SELECT
ce.internal_feature_id,
ce.internal_customer_id,
ce.internal_entity_id AS entity_key,
r.balance::numeric AS rollover_balance,
COALESCE(r.usage, 0)::numeric AS rollover_usage
FROM rollovers r
JOIN customer_entitlements ce ON r.cus_ent_id = ce.id
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND ce.customer_product_id IS NULL
AND ce.internal_entity_id IS NOT NULL
AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
),
entity_rollover_keys AS (
SELECT
internal_feature_id,
internal_customer_id,
entity_key,
SUM(rollover_balance) AS rollover_balance,
SUM(rollover_usage) AS rollover_usage
FROM entity_rollover_rows
WHERE entity_key IS NOT NULL
GROUP BY internal_feature_id, internal_customer_id, entity_key
),
entity_rollover_feature AS (
SELECT
internal_feature_id,
internal_customer_id,
SUM(rollover_balance) AS rollover_balance,
SUM(rollover_usage) AS rollover_usage
FROM entity_rollover_rows
GROUP BY internal_feature_id, internal_customer_id
)
`;
/**
* Per-feature/per-entity aggregates sourced strictly from `ce.entities` JSON.
* This powers the `entities` map in customer-level aggregates and intentionally
* excludes top-level entity attribution paths.
*/
const buildEntityEntitiesCtes = ({
statusFilter,
}: {
statusFilter: SQL;
}) => sql`
entity_entities_rows AS (
-- Product-attached entitlements: aggregate directly from ce.entities keys
SELECT
ce.internal_feature_id,
ce.internal_customer_id,
kv.entity_key AS entity_key,
COALESCE((kv.entity_value->>'balance')::numeric, 0) AS entity_balance,
COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment,
COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance
FROM customer_entitlements ce
JOIN customer_products cp ON ce.customer_product_id = cp.id
CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value)
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND cp.internal_entity_id IS NOT NULL
AND jsonb_typeof(ce.entities) = 'object'
${statusFilter}
UNION ALL
-- Loose entitlements: aggregate directly from ce.entities keys
SELECT
ce.internal_feature_id,
ce.internal_customer_id,
kv.entity_key AS entity_key,
COALESCE((kv.entity_value->>'balance')::numeric, 0) AS entity_balance,
COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment,
COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance
FROM customer_entitlements ce
CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value)
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND ce.customer_product_id IS NULL
AND ce.internal_entity_id IS NOT NULL
AND jsonb_typeof(ce.entities) = 'object'
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
),
entity_entities_aggregate_keys AS (
SELECT
internal_feature_id,
internal_customer_id,
entity_key,
SUM(entity_balance) AS balance,
SUM(entity_adjustment) AS adjustment,
SUM(entity_additional_balance) AS additional_balance
FROM entity_entities_rows
WHERE entity_key IS NOT NULL
GROUP BY internal_feature_id, internal_customer_id, entity_key
)
`;
export const getEntityAggregateFragments = ({
entityId,
statusFilter,
}: {
entityId?: string;
statusFilter: SQL;
}) => {
if (entityId) {
return {
ctes: sql``,
productRefsUnion: sql``,
entitlementRefsUnion: sql``,
priceRefsUnion: sql``,
freeTrialRefsUnion: sql``,
selectColumns: sql``,
};
}
const entityOptionsAggregateFragments = getEntityOptionsAggregateFragments();
const ctes = sql`,
entity_distinct_product_ids AS (
SELECT DISTINCT cp.internal_product_id, cp.internal_customer_id
FROM customer_products cp
JOIN subject_customer_records scr
ON cp.internal_customer_id = scr.internal_id
WHERE cp.internal_entity_id IS NOT NULL
${statusFilter}
),
entity_distinct_cus_products AS (
SELECT sub.*
FROM entity_distinct_product_ids edpi
JOIN LATERAL (
SELECT cp.*
FROM customer_products cp
WHERE cp.internal_customer_id = edpi.internal_customer_id
AND cp.internal_product_id = edpi.internal_product_id
AND cp.internal_entity_id IS NOT NULL
${statusFilter}
ORDER BY cp.created_at DESC
LIMIT 1
) sub ON true
),
entity_cus_products_for_options AS (
SELECT cp.*
FROM customer_products cp
JOIN subject_customer_records scr
ON cp.internal_customer_id = scr.internal_id
WHERE cp.internal_entity_id IS NOT NULL
${statusFilter}
),
entity_cus_prices AS (
SELECT cpr.*
FROM customer_prices cpr
WHERE cpr.customer_product_id IN (SELECT id FROM entity_distinct_cus_products)
),
${entityOptionsAggregateFragments.ctes},
entity_balance_rows AS (
SELECT
COALESCE(ce.external_id, ce.id) AS api_id,
ce.internal_feature_id,
ce.internal_customer_id,
ce.feature_id,
COALESCE(ent.allowance, 0)::numeric AS allowance,
ce.balance::numeric AS balance,
ce.adjustment::numeric AS adjustment,
COALESCE(ce.additional_balance, 0)::numeric AS additional_balance,
ce.unlimited,
ce.usage_allowed,
cp.internal_entity_id AS entity_key,
ce.balance::numeric AS entity_balance,
COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment,
COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance
FROM customer_entitlements ce
JOIN customer_products cp ON ce.customer_product_id = cp.id
JOIN entitlements ent ON ce.entitlement_id = ent.id
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND cp.internal_entity_id IS NOT NULL
${statusFilter}
UNION ALL
SELECT
COALESCE(ce.external_id, ce.id) AS api_id,
ce.internal_feature_id,
ce.internal_customer_id,
ce.feature_id,
COALESCE(ent.allowance, 0)::numeric AS allowance,
0::numeric AS balance,
0::numeric AS adjustment,
0::numeric AS additional_balance,
ce.unlimited,
ce.usage_allowed,
kv.entity_key AS entity_key,
(kv.entity_value->>'balance')::numeric AS entity_balance,
COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment,
COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance
FROM customer_entitlements ce
JOIN customer_products cp ON ce.customer_product_id = cp.id
JOIN entitlements ent ON ce.entitlement_id = ent.id
CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value)
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND cp.internal_entity_id IS NOT NULL
AND jsonb_typeof(ce.entities) = 'object'
${statusFilter}
UNION ALL
SELECT
COALESCE(ce.external_id, ce.id) AS api_id,
ce.internal_feature_id,
ce.internal_customer_id,
ce.feature_id,
COALESCE(ent.allowance, 0)::numeric AS allowance,
0::numeric AS balance,
0::numeric AS adjustment,
0::numeric AS additional_balance,
ce.unlimited,
ce.usage_allowed,
kv.entity_key AS entity_key,
(kv.entity_value->>'balance')::numeric AS entity_balance,
COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment,
COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance
FROM customer_entitlements ce
JOIN entitlements ent ON ce.entitlement_id = ent.id
CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value)
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND ce.customer_product_id IS NULL
AND ce.internal_entity_id IS NOT NULL
AND jsonb_typeof(ce.entities) = 'object'
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
UNION ALL
SELECT
COALESCE(ce.external_id, ce.id) AS api_id,
ce.internal_feature_id,
ce.internal_customer_id,
ce.feature_id,
COALESCE(ent.allowance, 0)::numeric AS allowance,
ce.balance::numeric AS balance,
COALESCE(ce.adjustment, 0)::numeric AS adjustment,
COALESCE(ce.additional_balance, 0)::numeric AS additional_balance,
ce.unlimited,
ce.usage_allowed,
ce.internal_entity_id AS entity_key,
ce.balance::numeric AS entity_balance,
COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment,
COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance
FROM customer_entitlements ce
JOIN entitlements ent ON ce.entitlement_id = ent.id
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
AND ce.customer_product_id IS NULL
AND ce.internal_entity_id IS NOT NULL
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
),
${buildEntityRolloverCtes({ statusFilter })},
${buildEntityEntitiesCtes({ statusFilter })},
entity_aggregate_keys AS (
SELECT
COALESCE(ebk.internal_feature_id, erk.internal_feature_id) AS internal_feature_id,
COALESCE(ebk.internal_customer_id, erk.internal_customer_id) AS internal_customer_id,
COALESCE(ebk.entity_key, erk.entity_key) AS entity_key,
COALESCE(ebk.balance, 0) AS balance,
COALESCE(ebk.adjustment, 0) AS adjustment,
COALESCE(ebk.additional_balance, 0) AS additional_balance,
COALESCE(erk.rollover_balance, 0) AS rollover_balance,
COALESCE(erk.rollover_usage, 0) AS rollover_usage
FROM (
SELECT
internal_feature_id,
internal_customer_id,
entity_key,
SUM(entity_balance) AS balance,
SUM(entity_adjustment) AS adjustment,
SUM(entity_additional_balance) AS additional_balance
FROM entity_balance_rows
WHERE entity_key IS NOT NULL
GROUP BY internal_feature_id, internal_customer_id, entity_key
) ebk
FULL OUTER JOIN entity_rollover_keys erk
ON erk.internal_feature_id = ebk.internal_feature_id
AND erk.internal_customer_id = ebk.internal_customer_id
AND erk.entity_key = ebk.entity_key
),
entity_aggregate_map AS (
SELECT
ejk.internal_feature_id,
ejk.internal_customer_id,
jsonb_object_agg(
ejk.entity_key,
jsonb_build_object(
'id', ejk.entity_key,
'balance', ejk.balance,
'adjustment', ejk.adjustment,
'additional_balance', ejk.additional_balance,
'rollover_balance', COALESCE(erk.rollover_balance, 0),
'rollover_usage', COALESCE(erk.rollover_usage, 0)
)
) AS entities
FROM entity_entities_aggregate_keys ejk
LEFT JOIN entity_rollover_keys erk
ON erk.internal_feature_id = ejk.internal_feature_id
AND erk.internal_customer_id = ejk.internal_customer_id
AND erk.entity_key = ejk.entity_key
GROUP BY ejk.internal_feature_id, ejk.internal_customer_id
),
entity_aggregated_cus_entitlements AS (
SELECT
MIN(ebr.api_id) AS api_id,
ebr.internal_feature_id,
ebr.internal_customer_id,
MIN(ebr.feature_id) AS feature_id,
SUM(ebr.allowance) AS allowance_total,
COALESCE(MAX(epgo.prepaid_grant_from_options), 0) AS prepaid_grant_from_options,
SUM(ebr.balance) AS balance,
SUM(ebr.adjustment) AS adjustment,
SUM(ebr.additional_balance) AS additional_balance,
COALESCE(MAX(erf.rollover_balance), 0) AS rollover_balance,
COALESCE(MAX(erf.rollover_usage), 0) AS rollover_usage,
BOOL_OR(ebr.unlimited) AS unlimited,
BOOL_OR(ebr.usage_allowed) AS usage_allowed,
COUNT(DISTINCT ebr.entity_key) FILTER (WHERE ebr.entity_key IS NOT NULL) AS entity_count,
eam.entities
FROM entity_balance_rows ebr
LEFT JOIN entity_aggregate_map eam
ON eam.internal_feature_id = ebr.internal_feature_id
AND eam.internal_customer_id = ebr.internal_customer_id
LEFT JOIN entity_rollover_feature erf
ON erf.internal_feature_id = ebr.internal_feature_id
AND erf.internal_customer_id = ebr.internal_customer_id
LEFT JOIN entity_prepaid_grant_from_options epgo
ON epgo.internal_feature_id = ebr.internal_feature_id
AND epgo.internal_customer_id = ebr.internal_customer_id
GROUP BY
ebr.internal_feature_id,
ebr.internal_customer_id,
eam.entities
)
`;
const productRefsUnion = sql`
UNION ALL
SELECT ecp.internal_customer_id, ecp.internal_product_id
FROM entity_distinct_cus_products ecp
`;
const entitlementRefsUnion = sql`
UNION
SELECT DISTINCT
ce.internal_customer_id,
ce.entitlement_id
FROM customer_entitlements ce
JOIN customer_products cp ON ce.customer_product_id = cp.id
WHERE cp.internal_entity_id IS NOT NULL
${statusFilter}
UNION
SELECT DISTINCT
ce.internal_customer_id,
ce.entitlement_id
FROM customer_entitlements ce
WHERE ce.customer_product_id IS NULL
AND ce.internal_entity_id IS NOT NULL
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
`;
const priceRefsUnion = sql`
UNION ALL
SELECT ecpr.price_id, ecp.internal_customer_id
FROM entity_cus_prices ecpr
JOIN entity_distinct_cus_products ecp
ON ecp.id = ecpr.customer_product_id
`;
const freeTrialRefsUnion = sql`
UNION ALL
SELECT ecp.free_trial_id, ecp.internal_customer_id
FROM entity_distinct_cus_products ecp
WHERE ecp.free_trial_id IS NOT NULL
`;
const selectColumns = sql`,
json_build_object(
'aggregated_customer_products', COALESCE(
(
SELECT json_agg(row_to_json(ecp))
FROM entity_distinct_cus_products ecp
WHERE ecp.internal_customer_id = scr.internal_id
),
'[]'::json
),
'aggregated_customer_entitlements', COALESCE(
(
SELECT json_agg(row_to_json(eace))
FROM entity_aggregated_cus_entitlements eace
WHERE eace.internal_customer_id = scr.internal_id
),
'[]'::json
)
) AS entity_aggregations
`;
return {
ctes,
productRefsUnion,
entitlementRefsUnion,
priceRefsUnion,
freeTrialRefsUnion,
selectColumns,
};
};

View File

@@ -1,31 +0,0 @@
import type { Entity } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { EntityService } from "@/internal/api/entities/EntityService.js";
export const updateEntityDbAndCache = async ({
ctx,
entity,
updates,
}: {
ctx: AutumnContext;
entity: Entity;
updates: Partial<
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
>;
}) => {
const filteredUpdates = Object.fromEntries(
Object.entries(updates).filter(([, value]) => value !== undefined),
) as Partial<
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
>;
if (Object.keys(filteredUpdates).length === 0) {
return entity;
}
return EntityService.update({
db: ctx.db,
internalId: entity.internal_id,
update: filteredUpdates,
});
};

View File

@@ -1,249 +0,0 @@
import { sql } from "drizzle-orm";
import { Hono } from "hono";
import { dbCritical, dbGeneral } from "@/db/initDrizzle.js";
import {
forceDegraded,
forceHealthy,
getPgHealthState,
} from "@/db/pgHealthMonitor.js";
import { redis } from "@/external/redis/initRedis.js";
import {
disconnectPrimary,
getFailoverState,
reconnectPrimary,
} from "@/external/redis/redisFailover.js";
import { orgConfigMiddleware } from "@/honoMiddlewares/orgConfigMiddleware.js";
import { secretKeyMiddleware } from "@/honoMiddlewares/secretKeyMiddleware.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
const ALLOWED_ORG_IDS = new Set([
"org_2sWv2S8LJ9iaTjLI6UtNsfL88Kt",
"org_2rzkkRh7r5dBSaBC101QHG9KDgt",
"org_2vwdxwTdqxRrLEdUYddcynMv3n3",
]);
export const debugRouter = new Hono<HonoEnv>();
debugRouter.use("*", secretKeyMiddleware);
debugRouter.use("*", orgConfigMiddleware);
debugRouter.get("/memory", async (c) => {
const ctx = c.get("ctx");
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
return c.json({ error: "Forbidden" }, 403);
}
const mem = process.memoryUsage();
return c.json({
ok: true,
pid: process.pid,
timestamp: new Date().toISOString(),
memory: {
rssMB: +(mem.rss / 1024 / 1024).toFixed(1),
heapUsedMB: +(mem.heapUsed / 1024 / 1024).toFixed(1),
heapTotalMB: +(mem.heapTotal / 1024 / 1024).toFixed(1),
externalMB: +(mem.external / 1024 / 1024).toFixed(1),
arrayBuffersMB: +(mem.arrayBuffers / 1024 / 1024).toFixed(1),
},
});
});
/** Check what statement_timeout the DB connections actually see. */
debugRouter.get("/statement-timeout", async (c) => {
if (process.env.NODE_ENV === "production") {
return c.json({ error: "Not available in production" }, 403);
}
const ctx = c.get("ctx");
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
return c.json({ error: "Forbidden" }, 403);
}
const criticalResult = await dbCritical.execute(sql`SHOW statement_timeout`);
const generalResult = await dbGeneral.execute(sql`SHOW statement_timeout`);
return c.json({
ok: true,
critical: criticalResult[0],
general: generalResult[0],
});
});
/**
* Pool isolation test endpoint. Runs pg_sleep or SELECT 1 on a specific pool.
* Blocked in production.
*/
debugRouter.post("/pool-test", async (c) => {
if (process.env.NODE_ENV === "production") {
return c.json({ error: "Not available in production" }, 403);
}
const ctx = c.get("ctx");
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
return c.json({ error: "Forbidden" }, 403);
}
if (process.env.DATABASE_URL?.includes("us-east-2")) {
return c.json({ error: "Not available against production database" }, 403);
}
const body = await c.req.json<{
action: "sleep" | "ping" | "cpu";
pool: "general" | "critical";
seconds?: number;
/** Row count for CPU burn (default 5_000_000). Higher = more CPU time. */
rows?: number;
}>();
const { action, pool, seconds = 5, rows = 5_000_000 } = body;
const db = pool === "critical" ? dbCritical : dbGeneral;
const start = Date.now();
try {
if (action === "sleep") {
await db.execute(sql`SELECT pg_sleep(${seconds})`);
} else if (action === "cpu") {
// CPU-intensive: hash millions of rows. Burns real CPU on the DB.
await db.execute(
sql`SELECT count(*) FROM generate_series(1, ${rows}) AS s WHERE md5(s::text) IS NOT NULL`,
);
} else {
await db.execute(sql`SELECT 1`);
}
return c.json({
ok: true,
pool,
action,
durationMs: Date.now() - start,
});
} catch (error) {
return c.json({
ok: false,
pool,
action,
durationMs: Date.now() - start,
error: error instanceof Error ? error.message : String(error),
});
}
});
/**
* Redis failover test endpoints. Blocked in production.
*/
debugRouter.post("/redis-failover", async (c) => {
if (process.env.NODE_ENV === "production") {
return c.json({ error: "Not available in production" }, 403);
}
const ctx = c.get("ctx");
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
return c.json({ error: "Forbidden" }, 403);
}
const body = await c.req.json<{
action: "status" | "kill-primary" | "recover-primary" | "ping";
}>();
if (body.action === "status") {
return c.json({ ok: true, ...getFailoverState() });
}
if (body.action === "kill-primary") {
disconnectPrimary();
return c.json({ ok: true, message: "Primary disconnected" });
}
if (body.action === "recover-primary") {
reconnectPrimary();
return c.json({ ok: true, message: "Primary reconnect triggered" });
}
if (body.action === "ping") {
const start = Date.now();
try {
await redis.ping();
return c.json({
ok: true,
durationMs: Date.now() - start,
...getFailoverState(),
});
} catch (error) {
return c.json({
ok: false,
durationMs: Date.now() - start,
error: error instanceof Error ? error.message : String(error),
...getFailoverState(),
});
}
}
return c.json({ error: "Unknown action" }, 400);
});
/**
* PG health monitor test endpoints. Blocked in production.
*/
debugRouter.post("/pg-health", async (c) => {
if (process.env.NODE_ENV === "production") {
return c.json({ error: "Not available in production" }, 403);
}
const ctx = c.get("ctx");
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
return c.json({ error: "Forbidden" }, 403);
}
const body = await c.req.json<{
action: "status" | "force-degraded" | "force-healthy";
}>();
if (body.action === "status") {
return c.json({ ok: true, ...getPgHealthState() });
}
if (body.action === "force-degraded") {
forceDegraded();
return c.json({
ok: true,
message: "Forced DEGRADED",
...getPgHealthState(),
});
}
if (body.action === "force-healthy") {
forceHealthy();
return c.json({
ok: true,
message: "Forced HEALTHY",
...getPgHealthState(),
});
}
return c.json({ error: "Unknown action" }, 400);
});
/** Write a V8 heap snapshot to disk. Requires secret key auth + dev-only. */
// debugRouter.get("/heap-snapshot", async (c) => {
// if (process.env.NODE_ENV === "production") {
// return c.json({ error: "Not available in production" }, 403);
// }
// const ctx = c.get("ctx");
// if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
// return c.json({ error: "Forbidden" }, 403);
// }
// const snapshotDir = new URL("../../../perf/snapshots/", import.meta.url)
// .pathname;
// const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
// const filename = `heap-${timestamp}-pid${process.pid}.heapsnapshot`;
// const filepath = `${snapshotDir}${filename}`;
// writeHeapSnapshot(filepath);
// return c.json({ ok: true, file: filename, path: filepath });
// });

View File

@@ -1,56 +0,0 @@
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js";
import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js";
import { handleCreatePlan } from "./handlers/handleCreateProduct/handleCreatePlan.js";
import { handleCreatePlanV2 } from "./handlers/handleCreateProduct/handleCreatePlanV2.js";
import { handleDeletePlanV1 } from "./handlers/handleDeletePlan/handleDeletePlanV1.js";
import { handleDeletePlanV2 } from "./handlers/handleDeletePlan/handleDeletePlanV2.js";
import { handleGetPlanV1 } from "./handlers/handleGetPlan/handleGetPlanV1.js";
import { handleGetPlanV2 } from "./handlers/handleGetPlan/handleGetPlanV2.js";
import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js";
import { handleListPlansV2 } from "./handlers/handleListPlans/handleListPlansV2.js";
import { handleListPlans } from "./handlers/handleListPlans.js";
import { handleMigrateProductV2 } from "./handlers/handleMigrateProductV2.js";
import { handleUpdatePlanV1 } from "./handlers/handleUpdatePlan/handleUpdatePlanV1.js";
import { handleUpdatePlanV2 } from "./handlers/handleUpdatePlan/handleUpdatePlanV2.js";
export const honoProductBetaRouter = new Hono<HonoEnv>();
honoProductBetaRouter.get("", ...handleListPlans);
// Create a Hono app for products
export const honoProductRouter = new Hono<HonoEnv>();
export const migrationRouter = new Hono<HonoEnv>();
// Migrations
migrationRouter.post("/migrations", ...handleMigrateProductV2);
// CRUD
honoProductRouter.get("", ...handleListPlans);
honoProductRouter.post("", ...handleCreatePlan);
honoProductRouter.get("/:product_id", ...handleGetPlanV1);
honoProductRouter.post("/:product_id", ...handleUpdatePlanV1); // will be deprecated
honoProductRouter.patch("/:product_id", ...handleUpdatePlanV1); // will be deprecated
honoProductRouter.delete("/:product_id", ...handleDeletePlanV1);
// Others
honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2);
// Info before deleting plan
honoProductRouter.get(
"/:product_id/has_customers",
...handlePlanHasCustomersV2,
);
honoProductRouter.post(
"/:product_id/has_customers",
...handlePlanHasCustomersV2,
);
honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo);
// RPC
export const plansRpcRouter = new Hono<HonoEnv>();
plansRpcRouter.post("/plans.get", ...handleGetPlanV2);
plansRpcRouter.post("/plans.list", ...handleListPlansV2);
plansRpcRouter.post("/plans.create", ...handleCreatePlanV2);
plansRpcRouter.post("/plans.update", ...handleUpdatePlanV2);
plansRpcRouter.post("/plans.delete", ...handleDeletePlanV2);

View File

@@ -1,39 +0,0 @@
import {
type FullCustomer,
fullCustomerToCustomerEntitlements,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
export const updateUsages = async ({
ctx,
featureId,
usage,
fullCus,
}: {
ctx: AutumnContext;
featureId: string;
usage: number;
fullCus: FullCustomer;
}) => {
const cusEnts = fullCustomerToCustomerEntitlements({
fullCustomer: fullCus,
inStatuses: RELEVANT_STATUSES,
featureId,
});
if (cusEnts.length === 0) {
throw new Error(`No cus ent for ${featureId}`);
}
const cusEnt = cusEnts[0];
const newBalance = cusEnt.balance! - usage;
await CusEntService.update({
ctx,
id: cusEnt.id,
updates: {
balance: newBalance,
},
});
};

View File

@@ -1,77 +0,0 @@
import {
AppEnv,
customers,
features,
type Organization,
products,
} from "@autumn/shared";
import { and, eq, inArray } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
export const clearCustomersInBatches = async ({
db,
org,
batchSize = 450,
}: {
db: DrizzleCli;
org: Organization;
batchSize?: number;
}) => {
let deletedCount = 0;
while (true) {
// Get a batch of customer IDs to delete
const customerBatch = await db
.select({ internalId: customers.internal_id })
.from(customers)
.where(
and(eq(customers.org_id, org.id), eq(customers.env, AppEnv.Sandbox)),
)
.limit(batchSize);
if (customerBatch.length === 0) {
break; // No more customers to delete
}
// Delete the batch
const customerIds = customerBatch
.map((c) => c.internalId)
.filter((id) => id !== null);
console.log("Deleting customers:", customerIds);
await db
.delete(customers)
.where(inArray(customers.internal_id, customerIds));
deletedCount += customerBatch.length;
console.log(
`Deleted ${customerBatch.length} customers (total: ${deletedCount})`,
);
}
return deletedCount;
};
export const clearOrg = async ({
db,
org,
}: {
db: DrizzleCli;
org: Organization;
}) => {
const deletedCount = await clearCustomersInBatches({ db, org });
console.log(`Cleared ${deletedCount} customers`);
await db
.delete(products)
.where(and(eq(products.org_id, org.id), eq(products.env, AppEnv.Sandbox)));
console.log("Cleared products");
await db
.delete(features)
.where(and(eq(features.org_id, org.id), eq(features.env, AppEnv.Sandbox)));
console.log("Cleared features");
};

View File

@@ -1,28 +0,0 @@
import csv from "csv-parser";
import fs from "fs";
export const parseCsv = ({
path,
delimiter = ",",
}: {
path: string;
delimiter?: string;
}) => {
return new Promise((resolve, reject) => {
const stream = fs.createReadStream(path);
const results: any[] = [];
const headers: string[] = [];
stream
.pipe(csv({ separator: delimiter }))
.on("data", (data) => {
results.push(data);
// if (headers.length === 0) {
// headers = Object.keys(data);
// } else {
// results.push(data);
// }
})
.on("end", () => resolve(results))
.on("error", (error) => reject(error));
}) as Promise<any[]>;
};

View File

@@ -1,110 +0,0 @@
import { AppEnv, type FullCusProduct } from "@autumn/shared";
import { sql } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
const cusProductsQuery = ({
lastProductId,
internalProductId,
pageSize = 250,
}: {
lastProductId?: string;
internalProductId?: string;
pageSize?: number;
}) => {
// const withStatusFilter = () => {
// return inStatuses
// ? sql`AND cp.status = ANY(ARRAY[${sql.join(
// inStatuses.map((status) => sql`${status}`),
// sql`, `,
// )}])`
// : sql``;
// };
return sql`
SELECT
cp.*,
row_to_json(prod) AS product,
-- Spread customer_prices fields + add price field
COALESCE(
json_agg(DISTINCT (
to_jsonb(cpr.*) || jsonb_build_object('price', to_jsonb(p.*))
)) FILTER (WHERE cpr.id IS NOT NULL),
'[]'::json
) AS customer_prices,
-- Spread customer_entitlements fields + add entitlement and replaceables
COALESCE(
json_agg(DISTINCT (
to_jsonb(ce.*) || jsonb_build_object(
'entitlement', (
SELECT row_to_json(ent_with_feature)
FROM (
SELECT e.*, row_to_json(f) AS feature
FROM entitlements e
JOIN features f ON e.internal_feature_id = f.internal_id
WHERE e.id = ce.entitlement_id
) AS ent_with_feature
),
'replaceables', (
SELECT COALESCE(
json_agg(row_to_json(r)) FILTER (WHERE r.id IS NOT NULL),
'[]'::json
)
FROM replaceables r
WHERE r.cus_ent_id = ce.id
)
)
)) FILTER (WHERE ce.id IS NOT NULL),
'[]'::json
) AS customer_entitlements,
-- free_trial
(
SELECT row_to_json(ft)
FROM free_trials ft
WHERE ft.id = cp.free_trial_id
) AS free_trial
FROM customer_products cp
JOIN products prod ON cp.internal_product_id = prod.internal_id
LEFT JOIN customer_prices cpr ON cpr.customer_product_id = cp.id
LEFT JOIN prices p ON cpr.price_id = p.id
LEFT JOIN customer_entitlements ce ON ce.customer_product_id = cp.id
WHERE cp.internal_product_id = ${internalProductId}
${lastProductId ? sql`AND cp.id < ${lastProductId}` : sql``}
GROUP BY cp.id, prod.*
ORDER BY cp.id DESC
LIMIT ${pageSize}
`;
};
export const getAllFullCusProducts = async ({
db,
internalProductId,
}: {
db: DrizzleCli;
internalProductId: string;
}) => {
let lastProductId = "";
const allData: any[] = [];
const pageSize = 500;
while (true) {
const data = await db.execute(
cusProductsQuery({
lastProductId,
pageSize,
internalProductId,
}),
);
if (data.length === 0) break;
console.log(`Fetched ${data.length} customer products`);
allData.push(...data);
lastProductId = data[data.length - 1].id as string;
}
return allData as FullCusProduct[];
};

View File

@@ -1,99 +0,0 @@
import type { Stripe } from "stripe";
import { timeout } from "@/utils/genUtils.js";
export const getAllStripeSubscriptions = async ({
numPages,
limit = 100,
stripeCli,
waitForSeconds,
}: {
numPages?: number;
limit?: number;
stripeCli: Stripe;
waitForSeconds?: number;
}) => {
let hasMore = true;
let startingAfter: string | null = null;
const allSubscriptions: any[] = [];
let pageCount = 0;
while (hasMore) {
const response: any = await stripeCli.subscriptions.list({
limit,
starting_after: startingAfter || undefined,
expand: ["data.discounts.coupon"],
});
if (response.data.length === 0) {
break;
}
allSubscriptions.push(...response.data);
hasMore = response.has_more;
startingAfter = response.data[response.data.length - 1].id;
pageCount++;
if (numPages && pageCount >= numPages) {
break;
}
console.log("Fetched", allSubscriptions.length, "subscriptions");
if (waitForSeconds) {
await timeout(1000);
}
}
return {
subscriptions: allSubscriptions,
total: allSubscriptions.length,
};
};
export const getAllStripeSchedules = async ({
numPages,
limit = 100,
stripeCli,
waitForSeconds,
}: {
numPages?: number;
limit?: number;
stripeCli: Stripe;
waitForSeconds?: number;
}) => {
let hasMore = true;
let startingAfter: string | null = null;
const allSchedules: any[] = [];
let pageCount = 0;
while (hasMore) {
const response: any = await stripeCli.subscriptionSchedules.list({
limit,
starting_after: startingAfter || undefined,
expand: ["data.phases.items.price"],
});
if (response.data.length === 0) {
break;
}
allSchedules.push(...response.data);
hasMore = response.has_more;
startingAfter = response.data[response.data.length - 1].id;
pageCount++;
if (numPages && pageCount >= numPages) {
break;
}
console.log("Fetched", allSchedules.length, "schedules");
if (waitForSeconds) {
await timeout(1000);
}
}
return {
schedules: allSchedules,
total: allSchedules.length,
};
};

View File

@@ -1,27 +0,0 @@
import { user } from "@autumn/shared";
import { desc } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
export const getAllUsers = async (db: DrizzleCli) => {
const users = [];
let offset = 0;
const limit = 200;
while (true) {
const batch = await db
.select()
.from(user)
.limit(limit)
.offset(offset)
.orderBy(desc(user.createdAt));
users.push(...batch);
if (batch.length < limit) {
break;
}
offset += limit;
console.log(`Fetched ${users.length} users`);
}
return users;
};

View File

@@ -1,43 +0,0 @@
import { stripeToAtmnAmount } from "@autumn/shared";
import { subItemToAutumnInterval } from "@tests/utils/stripeUtils";
import type Stripe from "stripe";
import type { Logger } from "../../../external/logtail/logtailUtils";
export const logSubItems = ({
sub,
subItems,
withPriceId = false,
withItemId = false,
logger,
}: {
sub?: Stripe.Subscription;
subItems?: Stripe.SubscriptionItem[];
withPriceId?: boolean;
withItemId?: boolean;
logger?: Logger | Console;
}) => {
const finalSubItems = subItems || sub!.items.data;
if (!logger) {
logger = console;
}
for (const item of finalSubItems) {
const isMetered = item.price.recurring?.usage_type === "metered";
const atmnPrice = stripeToAtmnAmount({
amount: item.price.unit_amount || 0,
currency: item.price.currency,
});
if (isMetered) {
logger.info(`Usage price`);
} else {
const price = atmnPrice;
const subInterval = subItemToAutumnInterval(item);
logger.info(
`${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval} ${withPriceId ? `(${item.price.id})` : ""} ${withItemId ? `(${item.id})` : ""}`,
);
}
}
};

View File

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

View File

@@ -61,7 +61,10 @@ mock.module("@/internal/balances/track/v3/runRedisTrackV3.js", () => ({
},
}));
import { runTrackV3 } from "@/internal/balances/track/v3/runTrackV3.js";
const { runTrackV3 } = await import(
// @ts-expect-error - Bun test cache-busting import query isolates module mocks.
"@/internal/balances/track/v3/runTrackV3.js?runTrackV3Idempotency"
);
const ctx = {
apiVersion: new ApiVersionClass(ApiVersion.V2_1),

View File

@@ -1,9 +1,8 @@
DESCRIPTION >
Aggregate queries with grouping by a property key, customer_id, or entity_id.
Routes to the smallest viable MV for the query shape:
- events_hourly_no_properties_two_mv: customer_id/entity_id grouping with no filters
- events_hourly_promoted_mv: promoted property key (apiKeyId, endpoint, source) with no filters
- events_hourly_mv: fallback for arbitrary properties or filtered queries
- events_hourly_no_properties_two_mv: customer_id/entity_id grouping with no property filters
- events_hourly_mv: arbitrary properties or filtered queries
Uses PER-BIN TOP N groups + "AUTUMN_RESERVED" bucket ((N+1) max total per bin).
N is controlled by the max_groups parameter (default 9).
Returns unpivoted data: (period, event_name, group_value, total_value, _truncated)
@@ -15,10 +14,8 @@ DESCRIPTION >
Aggregate raw data by period, event_name, and group_value.
SQL >
%
{% set no_filters = (not defined(filter_key_0) or String(filter_key_0, '') == '') and (not defined(filter_key_1) or String(filter_key_1, '') == '') and (not defined(filter_key_2) or String(filter_key_2, '') == '') and (not defined(filter_key_3) or String(filter_key_3, '') == '') and (not defined(filter_key_4) or String(filter_key_4, '') == '') %}
{% set is_promoted_key = String(property_key, '') in ['apiKeyId', 'endpoint', 'source'] %}
{% set use_no_props = (String(group_column, 'property') in ['customer_id', 'entity_id']) and no_filters %}
{% set use_promoted = (String(group_column, 'property') == 'property') and is_promoted_key and no_filters %}
{% set no_property_filters = (not defined(filter_key_0) or String(filter_key_0, '') == '') and (not defined(filter_key_1) or String(filter_key_1, '') == '') and (not defined(filter_key_2) or String(filter_key_2, '') == '') and (not defined(filter_key_3) or String(filter_key_3, '') == '') and (not defined(filter_key_4) or String(filter_key_4, '') == '') %}
{% set use_no_props = (String(group_column, 'property') in ['customer_id', 'entity_id']) and no_property_filters %}
SELECT
{% if String(bin_size, 'day') == 'hour' %}
@@ -33,8 +30,6 @@ SQL >
customer_id as group_value,
{% elif String(group_column, 'property') == 'entity_id' %}
entity_id as group_value,
{% elif use_promoted %}
{{ column(String(property_key, '')) }} as group_value,
{% else %}
{{ column('properties.' + String(property_key, '')) }}::String as group_value,
{% end %}
@@ -42,8 +37,6 @@ SQL >
FROM
{% if use_no_props %}
events_hourly_no_properties_two_mv
{% elif use_promoted %}
events_hourly_promoted_mv
{% else %}
events_hourly_mv
{% end %}
@@ -78,8 +71,6 @@ SQL >
{# Filter out null/empty grouping values #}
{% if String(group_column, 'property') == 'entity_id' %}
AND entity_id IS NOT NULL AND entity_id != ''
{% elif use_promoted %}
AND {{ column(String(property_key, '')) }} != ''
{% elif String(group_column, 'property') == 'property' %}
AND {{ column('properties.' + String(property_key, '')) }}::String IS NOT NULL
AND {{ column('properties.' + String(property_key, '')) }}::String != ''

View File

@@ -1,61 +0,0 @@
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { z } from "zod/v4";
import {
TrackResponseV0Schema,
TrackResponseV1Schema,
} from "../prevVersions/trackResponseV1.js";
import {
type TrackLegacyData,
TrackLegacyDataSchema,
} from "../trackLegacyData.js";
/**
* V0_2_CheckChange: Transforms check response TO V0_2 format
*
* Applied when: targetVersion <= V0_2
*
* Breaking changes introduced in V0.2 (that we reverse here):
*
* 1. Structure: Single check result object → balances array format
* - V0.2+: Single object with allowed, feature_id, balance, unlimited, etc.
* - V0_2: { allowed, balances: [{ feature_id, required, balance, unlimited, usage_allowed }] }
*
* 2. Boolean features: No balance fields → balance: null
* 3. Unlimited features: Return unlimited: true, usage_allowed based on overage_allowed
* 4. Metered features: Return required_balance and balance
*
* Input: CheckResult (V0.2+ format)
* Output: CheckResponseV0 (V0_2 balances array format)
*/
export const V0_2_CheckChange = defineVersionChange({
name: "V0.2 Check Change",
newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1
oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2
description: [
"Check response transformed to balances array format",
"Single check result object → { allowed, balances: [...] }",
],
affectedResources: [AffectedResource.Check],
newSchema: TrackResponseV1Schema,
oldSchema: TrackResponseV0Schema,
legacyDataSchema: TrackLegacyDataSchema,
affectsResponse: true,
// Response: V1.1+ (CheckResult) → V0_2 (CheckResponseV0)
transformResponse: ({
input,
legacyData,
}: {
input: z.infer<typeof TrackResponseV1Schema>;
legacyData?: TrackLegacyData;
}): z.infer<typeof TrackResponseV0Schema> => {
return {
success: true,
};
},
});

View File

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

View File

@@ -1,25 +0,0 @@
import { planV1ToV0 } from "@api/products/mappers/planV1ToV0";
import type { ApiSubscription } from "../apiSubscription";
import type { ApiSubscriptionV1 } from "../apiSubscriptionV1";
export function transformApiSubscriptionV1ToV0({
input,
}: {
input: ApiSubscriptionV1;
}): ApiSubscription {
return {
plan: input.plan ? planV1ToV0(input.plan) : undefined,
plan_id: input.plan_id,
default: input.auto_enable,
add_on: input.add_on,
status: input.status,
past_due: input.past_due,
canceled_at: input.canceled_at,
expires_at: input.expires_at,
trial_ends_at: input.trial_ends_at,
started_at: input.started_at,
current_period_start: input.current_period_start,
current_period_end: input.current_period_end,
quantity: input.quantity,
};
}

View File

@@ -1,32 +0,0 @@
import { planV1ToV0 } from "@api/products/mappers/planV1ToV0";
import type { SharedContext } from "../../../../types/sharedContext";
import type { ApiSubscription } from "../apiSubscription";
import type { ApiPurchaseV0 } from "../apiSubscriptionV1";
/**
* Converts an ApiPurchaseV0 to an ApiSubscription (V0) for backwards compatibility.
* Purchases are represented as subscriptions with sensible defaults for missing fields.
*/
export function apiPurchaseV0ToSubscriptionV0({
ctx,
input,
}: {
ctx: SharedContext;
input: ApiPurchaseV0;
}): ApiSubscription {
return {
plan: input.plan ? planV1ToV0({ ctx, plan: input.plan }) : undefined,
plan_id: input.plan_id,
default: false,
add_on: true,
status: "active",
past_due: false,
canceled_at: null,
expires_at: input.expires_at,
trial_ends_at: null,
started_at: input.started_at,
current_period_start: null,
current_period_end: null,
quantity: input.quantity,
};
}

View File

@@ -1,15 +0,0 @@
import type { ApiFeatureV1 } from "@api/features/apiFeatureV1";
export const findCreditSystemsByFeatureId = ({
featureId,
creditSystems,
}: {
featureId: string;
creditSystems: ApiFeatureV1[];
}) => {
return creditSystems.filter((creditSystem) =>
creditSystem.credit_schema?.some(
(schema) => schema.metered_feature_id === featureId,
),
);
};

View File

@@ -1,73 +0,0 @@
import { FeatureNotFoundError } from "@api/errors/classes/featureErrClasses.js";
import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js";
import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1.js";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import { featureUtils } from "@utils/index";
import { subtractIncludedFromTiers } from "@utils/productV2Utils/productItemUtils/tierUtils.js";
import type { SharedContext } from "../../../../types/sharedContext.js";
/**
* Converts V1 plan item params (CreatePlanItemParamsV1) to V0 response format (ApiPlanItemV0)
*/
export function planItemParamsV1ToPlanItemV0({
ctx,
item,
}: {
ctx: SharedContext;
item: CreatePlanItemParamsV1;
}): ApiPlanItemV0 {
const { features } = ctx;
const feature = features.find((f) => f.id === item.feature_id);
if (!feature) {
throw new FeatureNotFoundError({ featureId: item.feature_id });
}
const isAllocatedFeature = featureUtils.isAllocated(feature);
const included = item.included ?? 0;
// V1 API: tier `to` values INCLUDE included usage.
// Internal: tier `to` values do NOT include included usage.
const internalTiers = item.price?.tiers
? subtractIncludedFromTiers({ tiers: item.price.tiers, included })
: undefined;
return {
feature_id: item.feature_id,
granted_balance: included,
unlimited: item.unlimited ?? false,
reset: item.reset
? {
interval: item.reset.interval,
interval_count: item.reset.interval_count,
reset_when_enabled: !isAllocatedFeature,
}
: null,
price: item.price
? {
amount: item.price.amount,
tiers: internalTiers,
tier_behavior: item.price.tier_behavior,
interval: item.price.interval,
interval_count: item.price.interval_count,
billing_units: item.price.billing_units ?? 1,
usage_model: billingMethodToUsageModel(item.price.billing_method),
max_purchase: item.price.max_purchase ?? null,
}
: null,
rollover: item.rollover
? {
max: item.rollover.max ?? null,
max_percentage: item.rollover.max_percentage ?? null,
expiry_duration_type: item.rollover.expiry_duration_type,
expiry_duration_length: item.rollover.expiry_duration_length,
}
: undefined,
proration: item.proration,
};
}

View File

@@ -1,44 +0,0 @@
import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems";
import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0";
import type { ProductV2 } from "@models/productV2Models/productV2Models";
import type { SharedContext } from "../../../types/sharedContext";
export function planV0ToProductV2({
ctx,
plan,
}: {
ctx: SharedContext;
plan: ApiPlan;
}): ProductV2 {
// Convert plan to items using shared utility
const items = planV0ToProductItems({ ctx, plan });
// Check if archived field exists on plan (it's on ApiPlan, not CreatePlanParams)
const archived =
"archived" in plan && plan.archived !== undefined
? plan.archived
: undefined;
return {
id: plan.id,
name: plan.name,
description: plan.description ?? null,
is_add_on: plan.add_on,
is_default: plan.default,
group: plan.group ?? "",
items,
free_trial: plan.free_trial
? {
duration: plan.free_trial.duration_type,
length: plan.free_trial.duration_length,
unique_fingerprint: false,
card_required: plan.free_trial.card_required,
}
: null,
...(archived !== undefined && { archived }),
version: plan.version,
env: plan.env,
created_at: plan.created_at,
};
}

View File

@@ -1,54 +0,0 @@
import { z } from "zod/v4";
import { FullAggregatedCustomerEntitlementSchema } from "../cusProductModels/cusEntModels/aggregatedCusEnt.js";
import {
type FullCustomerEntitlement,
FullCustomerEntitlementSchema,
} from "../cusProductModels/cusEntModels/cusEntModels.js";
import { CustomerPriceSchema } from "../cusProductModels/cusPriceModels/cusPriceModels.js";
import {
type FullCusProduct,
FullCusProductSchema,
} from "../cusProductModels/cusProductModels.js";
import { SubscriptionSchema } from "../subModels/subModels.js";
import { type Customer, CustomerSchema } from "./cusModels.js";
import { type Entity, EntitySchema } from "./entityModels/entityModels.js";
import { InvoiceSchema } from "./invoiceModels/invoiceModels.js";
export const SubjectType = {
Customer: "customer",
Entity: "entity",
} as const;
export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType];
export const FullSubjectSchema = z.object({
subjectType: z.enum(["customer", "entity"]),
customerId: z.string(),
internalCustomerId: z.string(),
entityId: z.string().optional(),
internalEntityId: z.string().optional(),
customer: CustomerSchema,
entity: EntitySchema.optional(),
customer_products: z.array(FullCusProductSchema),
extra_customer_entitlements: z.array(FullCustomerEntitlementSchema),
subscriptions: z.array(SubscriptionSchema).optional(),
invoices: z.array(InvoiceSchema),
aggregated_customer_products: z.array(FullCusProductSchema).optional(),
aggregated_customer_entitlements: z
.array(FullAggregatedCustomerEntitlementSchema)
.optional(),
aggregated_customer_prices: z.array(CustomerPriceSchema).optional(),
});
export type FullSubject = z.infer<typeof FullSubjectSchema>;
/** Backward-compat type for entity DB layer files. */
export type FullEntity = Entity & {
customer: Customer;
customer_products: FullCusProduct[];
extra_customer_entitlements: FullCustomerEntitlement[];
};

View File

@@ -1,141 +0,0 @@
import type { CheckoutResponseV0 } from "@autumn/shared";
import type { ReactNode } from "react";
import { useIsLatestVersion } from "@/hooks/stores/useProductStore";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
export const AttachConfirmationInfo = ({
previewData,
}: {
previewData?: CheckoutResponseV0 | null;
}) => {
const isLatestVersion = useIsLatestVersion(previewData?.product);
const renderInfoBoxes = (): ReactNode[] => {
const boxes: ReactNode[] = [];
if (!previewData) {
return boxes;
}
if (!isLatestVersion) {
boxes.push(
<InfoBox key="latest-version" variant="info">
You're enabling a previous version (v{previewData.product.version}) of
this plan.
</InfoBox>,
);
}
// Payment method required
if (previewData.url) {
let secondaryText = "";
if (previewData.product.free_trial?.card_required === true) {
secondaryText = "to start this trial";
} else {
secondaryText = "as this plan has prices";
}
boxes.push(
<InfoBox key="payment-required" variant="warning">
A payment method is required {secondaryText}
</InfoBox>,
);
}
if (previewData.product.free_trial) {
let secondaryText = "";
if (previewData.product.free_trial?.card_required === true) {
secondaryText = " and the customer will be charged";
} else {
secondaryText = " and this plan will expire";
}
boxes.push(
<InfoBox key="free-trial" variant="info">
Trial ends {formatUnixToDate(previewData.next_cycle?.starts_at)}
{secondaryText}
</InfoBox>,
);
}
// Show scenario-based info if switching from another plan and not attaching add-on
if (
previewData.current_product &&
previewData.product &&
!previewData.product.is_add_on
) {
const scenario = previewData.product.scenario as
| "upgrade"
| "downgrade"
| "cancel"
| "new"
| string;
switch (scenario) {
case "upgrade":
boxes.push(
<InfoBox key="product-upgrade" variant="note">
This upgrade will immediately replace the customer's current plan:{" "}
{previewData.current_product.name}
</InfoBox>,
);
break;
case "downgrade":
boxes.push(
<InfoBox key="product-downgrade" variant="warning">
This downgrade will replace the customer's current plan:{" "}
{previewData.current_product.name}
</InfoBox>,
);
break;
case "cancel":
boxes.push(
<InfoBox key="product-cancel" variant="error">
This will cancel the customer's current billing subscription:{" "}
{previewData.current_product.name}
</InfoBox>,
);
break;
case "new":
boxes.push(
<InfoBox key="new-product" variant="info">
This will be enabled alongside existing plans{" "}
</InfoBox>,
);
}
}
if (
previewData.next_cycle?.starts_at &&
previewData.product?.scenario === "downgrade"
) {
const startsAtString = formatUnixToDate(previewData.next_cycle.starts_at);
boxes.push(
<InfoBox key="downgrade" variant="warning">
Plan change will take effect next cycle, on{" "}
<span className="font-semibold">{startsAtString}</span>
</InfoBox>,
);
}
// If switching products, show info about current product
return boxes;
};
const infoBoxes = renderInfoBoxes();
if (infoBoxes.length === 0) {
return null;
}
return (
<div className="space-y-2 px-4">
{infoBoxes.map((box, index) => (
<div key={index}>{box}</div>
))}
</div>
);
};

View File

@@ -1,205 +0,0 @@
import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared";
import type { LucideIcon } from "lucide-react";
import { ArrowUpRightFromSquare, CircleCheck } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useAttachProductMutation } from "@/components/forms/attach-product/use-attach-product-mutation";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button";
import { useOrg } from "@/hooks/common/useOrg";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useEnv } from "@/utils/envUtils";
import { openInNewTab } from "@/utils/genUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import type { UseAttachProductForm } from "./use-attach-product-form";
function getAttachButtonConfig(isCheckout: boolean): {
text: string;
icon: LucideIcon;
} {
return isCheckout
? { text: "Checkout", icon: ArrowUpRightFromSquare }
: { text: "Confirm", icon: CircleCheck };
}
interface AttachProductActionsProps {
form: UseAttachProductForm;
product: ProductV2;
customerId: string;
onSuccess?: () => void;
previewData?: CheckoutResponseV0 | null;
isPreviewLoading?: boolean;
}
export function AttachProductActions({
form,
product,
customerId,
onSuccess,
previewData,
isPreviewLoading,
}: AttachProductActionsProps) {
const { stripeAccount } = useOrgStripeQuery();
const env = useEnv();
const org = useOrg();
const { entityId } = useEntity();
const [activeAction, setActiveAction] = useState<"invoice" | "attach" | null>(
null,
);
const { closeSheet } = useSheetStore();
const ownStripeAccount = org.org?.stripe_connection !== "default";
const attachMutation = useAttachProductMutation({
customerId,
onSuccess: () => {
form.reset();
setActiveAction(null);
onSuccess?.();
},
});
const handleAttach = async ({
useInvoice,
enableProductImmediately,
action,
}: {
useInvoice: boolean;
enableProductImmediately?: boolean;
action: "invoice" | "attach";
}) => {
const { prepaidOptions } = form.state.values;
setActiveAction(action);
if (previewData?.url && action === "attach") {
window.open(previewData.url, "_blank");
setActiveAction(null);
closeSheet();
return;
}
try {
const result = await attachMutation.mutateAsync({
product,
prepaidOptions: prepaidOptions || {},
useInvoice,
enableProductImmediately,
entityId: entityId ?? undefined,
});
// Handle checkout URLs and invoice links
if (result.data.checkout_url) {
openInNewTab({ url: result.data.checkout_url });
} else if (result.data.invoice) {
const stripeInvoiceUrl = getStripeInvoiceLink({
stripeInvoice: result.data.invoice,
env,
accountId: stripeAccount?.id,
});
openInNewTab({ url: stripeInvoiceUrl });
toast.success("Redirected to Stripe to finalize the invoice");
}
} catch (error) {
setActiveAction(null);
throw error;
}
};
const isLoading = attachMutation.isPending;
const isInvoiceLoading = isLoading && activeAction === "invoice";
const isAttachLoading = isLoading && activeAction === "attach";
// Don't show buttons if preview is loading
if (isPreviewLoading || !product) {
return null;
}
const isCheckout = !!previewData?.url;
const { text: attachText, icon: AttachIcon } =
getAttachButtonConfig(isCheckout);
return (
<div className="flex flex-col gap-2 px-4">
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary"
className="w-full"
isLoading={isInvoiceLoading}
disabled={isLoading || !ownStripeAccount}
type="button"
>
Send an Invoice
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 z-100 rounded-lg"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<div className="flex flex-col">
<button
type="button"
disabled={isLoading}
onClick={() =>
handleAttach({
useInvoice: true,
enableProductImmediately: true,
action: "invoice",
})
}
className="px-4 py-3 text-left text-sm hover:bg-accent"
>
<div className="font-medium">Enable plan immediately</div>
<div className="text-xs text-muted-foreground">
Enable the plan immediately and redirect to Stripe to finalize
the invoice. Customer can pay by the invoice due date.
</div>
</button>
<button
type="button"
disabled={isLoading}
onClick={() =>
handleAttach({
useInvoice: true,
enableProductImmediately: false,
action: "invoice",
})
}
className="px-4 py-3 text-left text-sm hover:bg-accent border-t"
>
<div className="font-medium">Enable plan after payment</div>
<div className="text-xs text-muted-foreground">
Generate an invoice link for the customer. The plan will be
enabled after they pay the invoice
</div>
</button>
</div>
</PopoverContent>
</Popover>
<Button
variant="primary"
className="w-full flex items-center gap-2"
isLoading={isAttachLoading}
disabled={isLoading}
onClick={() =>
handleAttach({
useInvoice: false,
action: "attach",
})
}
>
{attachText}
<AttachIcon className="size-3.5" />
</Button>
</div>
);
}

View File

@@ -1,8 +0,0 @@
import { z } from "zod/v4";
export const AttachProductFormSchema = z.object({
productId: z.string(),
prepaidOptions: z.record(z.string(), z.number().optional()),
});
export type AttachProductForm = z.infer<typeof AttachProductFormSchema>;

View File

@@ -1,161 +0,0 @@
import type {
Entity,
FrontendProduct,
FullCustomer,
ProductV2,
} from "@autumn/shared";
import { useStore } from "@tanstack/react-form";
import { FormWrapper } from "@/components/general/form/form-wrapper";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
import { AttachProductActions } from "./attach-product-actions";
import { AttachProductPrepaidOptions } from "./attach-product-prepaid-options";
import { AttachProductSelection } from "./attach-product-selection";
import { AttachProductSummary } from "./attach-product-summary";
import { useAttachPreview } from "./use-attach-preview";
import type { UseAttachProductForm } from "./use-attach-product-form";
import { useAttachProductForm } from "./use-attach-product-form";
interface FormContentProps {
products: ProductV2[];
customerId: string;
form: UseAttachProductForm;
onSuccess?: () => void;
}
function FormContent({
products,
customerId,
form,
onSuccess,
}: FormContentProps) {
const sheetData = useSheetStore((s) => s.data);
const productId = useStore(form.store, (state) => state.values.productId);
const prepaidOptions = useStore(
form.store,
(state) => state.values.prepaidOptions,
);
// Use customized product from sheet data if available, otherwise find from products list
const customizedProduct = sheetData?.customizedProduct as
| FrontendProduct
| undefined;
const product = customizedProduct?.id
? customizedProduct
: products.find((p) => p.id === productId && !p.archived);
const { prepaidItems } = usePrepaidItems({ product });
const { entityId } = useEntity();
// Call preview once here and pass data down to children
const previewQuery = useAttachPreview({
customerId,
product,
entityId: entityId ?? undefined,
prepaidOptions: prepaidOptions ?? undefined,
version: product?.version,
});
// Check if there are prepaid items and if any are not set (undefined/null)
// Note: 0 is a valid quantity value
if (prepaidItems.length > 0) {
const hasUnsetPrepaidQuantity = prepaidItems.some((item) => {
const quantity = prepaidOptions?.[item.feature_id as string];
return quantity === undefined || quantity === null;
});
if (hasUnsetPrepaidQuantity) {
return null;
}
}
if (!form.state.values.productId || !product) {
return null;
}
return (
<>
<AttachProductSummary
previewData={previewQuery.data}
isLoading={previewQuery.isLoading}
/>
<AttachProductActions
form={form}
product={product}
customerId={customerId}
onSuccess={onSuccess}
previewData={previewQuery.data}
isPreviewLoading={previewQuery.isLoading}
/>
</>
);
}
export function AttachProductForm({
customerId,
onSuccess,
}: {
customerId: string;
onSuccess?: () => void;
}) {
const itemId = useSheetStore((s) => s.itemId);
const form = useAttachProductForm({ initialProductId: itemId || undefined });
const { products, isLoading } = useProductsQuery();
const activeProducts = products.filter((p) => !p.archived);
const { entityId } = useEntity();
const { customer } = useCusQuery();
const entities = (customer as FullCustomer).entities || [];
const fullEntity = entities.find(
(e: Entity) => e.id === entityId || e.internal_id === entityId,
);
if (isLoading) {
return <div className="text-sm text-t3">Loading products...</div>;
}
return (
<FormWrapper form={form}>
<SheetSection withSeparator={false} className="pb-0">
<div className="space-y-2">
<AttachProductSelection form={form} customerId={customerId} />
<AttachProductPrepaidOptions form={form} />
{entityId ? (
<div className="pt-2">
<InfoBox variant="info">
Attaching plan to entity{" "}
<span className="font-semibold">
{fullEntity?.name || fullEntity?.id}
</span>
</InfoBox>
</div>
) : entities.length > 0 ? (
<div className="pt-2">
<InfoBox variant="info">
Attaching plan to customer - all entities will get access
</InfoBox>
</div>
) : null}
</div>
</SheetSection>
<FormContent
products={activeProducts}
customerId={customerId}
form={form}
onSuccess={onSuccess}
/>
</FormWrapper>
);
}

View File

@@ -1,46 +0,0 @@
import type { CheckoutResponseV0 } from "@autumn/shared";
import {
SheetAccordion,
SheetAccordionItem,
} from "@/components/v2/sheets/SheetAccordion";
export function AttachProductLineItems({
previewData,
}: {
previewData?: CheckoutResponseV0 | null;
}) {
const lineItems =
previewData?.lines?.map((line) => {
return {
name: line.description || "Unknown",
total: line.amount,
};
}) || [];
if (lineItems.length === 0) {
return null;
}
return (
<SheetAccordion type="single" withSeparator={false} collapsible={true}>
<SheetAccordionItem
value="line-items"
title="Line Items"
className="text-t3 "
>
<div className="space-y-2">
{lineItems.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm truncate max-w-75 text-t3">
{item.name}
</span>
<span className="text-sm text-t1 font-semibold truncate w-20 text-right">
${item.total.toFixed(2)}
</span>
</div>
))}
</div>
</SheetAccordionItem>
</SheetAccordion>
);
}

View File

@@ -1,78 +0,0 @@
import {
type FrontendProductItem,
getFeaturePriceItemDisplay,
} from "@autumn/shared";
import { useStore } from "@tanstack/react-form";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import {
usePrepaidItems,
useProductStore,
} from "@/hooks/stores/useProductStore";
import type { UseAttachProductForm } from "./use-attach-product-form";
interface PrepaidOptionsFieldProps {
form: UseAttachProductForm;
}
export function AttachProductPrepaidOptions({
form,
}: PrepaidOptionsFieldProps) {
const storeProduct = useProductStore((s) => s.product);
const { products = [] } = useProductsQuery();
const selectedProductId = useStore(
form.store,
(state) => state.values.productId,
);
const { org } = useOrg();
const product = storeProduct?.id
? storeProduct
: products.find((p) => p.id === selectedProductId && !p.archived);
const { prepaidItems } = usePrepaidItems({ product });
if (prepaidItems.length === 0 || !selectedProductId) {
return null;
}
return (
<div className="space-y-3 mt-4 mb-2">
<div className="space-y-2">
{prepaidItems.map((item) => {
const display = getFeaturePriceItemDisplay({
item: item as FrontendProductItem,
feature: item.feature,
currency: org?.default_currency || "USD",
fullDisplay: true,
amountFormatOptions: {
currencyDisplay: "narrowSymbol",
},
});
return (
<div
key={item.feature_id}
className="grid grid-cols-[1fr_auto] gap-2 items-center"
>
<span className="text-sm text-foreground truncate">
{display.primary_text}
{display.secondary_text && ` ${display.secondary_text}`}
</span>
<form.AppField name={`prepaidOptions.${item.feature_id}`}>
{(quantityField) => (
<quantityField.QuantityField
label=""
placeholder="0"
min={0}
step={item.billing_units ?? 1}
hideFieldInfo={true}
/>
)}
</form.AppField>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import {
isProductAlreadyEnabled,
isProductCurrentlyAttached,
} from "@autumn/shared";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { useNavigate } from "react-router";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useHasChanges } from "@/hooks/stores/useProductStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { pushPage } from "@/utils/genUtils";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import {
type UseAttachProductForm,
useResetPrepaidOnProductChange,
} from "./use-attach-product-form";
interface AttachProductSelectionProps {
form: UseAttachProductForm;
customerId: string;
}
export function AttachProductSelection({
form,
customerId,
}: AttachProductSelectionProps) {
const { products } = useProductsQuery();
const availableProducts = products.filter((p) => !p.archived);
const navigate = useNavigate();
const productId = form.state.values.productId;
const hasChanges = useHasChanges();
const { customer } = useCusQuery();
const { entityId } = useEntity();
useResetPrepaidOnProductChange({ form });
const handleCustomize = ({ productId }: { productId: string }) => {
if (!productId || !customerId) {
return;
}
pushPage({
path: `/customers/${customerId}/${productId}`,
navigate,
});
};
return (
<div className="space-y-4">
<div className="grid grid-cols-[1fr_auto] gap-2">
<form.AppField name="productId">
{(field) => (
<field.SelectField
label=""
options={availableProducts.map((p) => {
const entityIdVal = entityId ?? undefined;
const alreadyEnabled = isProductAlreadyEnabled({
productId: p.id,
customer,
entityId: entityIdVal,
});
const currentlyAttached =
!alreadyEnabled &&
isProductCurrentlyAttached({
productId: p.id,
customer,
entityId: entityIdVal,
});
return {
label: p.name,
value: p.id,
disabledValue: alreadyEnabled ? "Already Enabled" : undefined,
badgeValue: currentlyAttached ? "Already Enabled" : undefined,
};
})}
placeholder="Select Product"
hideFieldInfo
selectValueAfter={
hasChanges && productId ? (
<span className="text-xs bg-green-500/10 text-green-500 px-1 py-0 rounded-md">
Custom
</span>
) : undefined
}
/>
)}
</form.AppField>
<div className="flex items-center gap-2">
<form.Subscribe selector={(state) => state.values.productId}>
{(productId) => (
<IconButton
size="sm"
variant="secondary"
className="size-6 disabled:pointer-events-none disabled:opacity-50 w-fit"
icon={<PencilSimpleIcon />}
onClick={() => handleCustomize({ productId })}
disabled={!productId}
type="button"
>
Customize
</IconButton>
)}
</form.Subscribe>
</div>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
import type { CheckoutResponseV0 } from "@autumn/shared";
import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText";
import { AttachConfirmationInfo } from "./attach-confirmation-info";
import { AttachProductLineItems } from "./attach-product-line-items";
import { AttachProductTotals } from "./attach-product-totals";
export function AttachProductSummary({
previewData,
isLoading,
}: {
previewData?: CheckoutResponseV0 | null;
isLoading?: boolean;
}) {
if (isLoading) {
return (
<LoadingShimmerText text="Calculating totals" className="py-2 px-6" />
);
}
return (
<div className="text-sm">
<AttachConfirmationInfo previewData={previewData} />
{/* <AttachFeaturePreview previewData={previewData} /> */}
<div className="py-4">
<AttachProductLineItems previewData={previewData} />
<AttachProductTotals previewData={previewData} />
</div>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import type { CheckoutResponseV0 } from "@autumn/shared";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
export function AttachProductTotals({
previewData,
}: {
previewData?: CheckoutResponseV0 | null;
}) {
const total = previewData?.total || 0;
const nextCycleTotal = previewData?.next_cycle?.total || 0;
const nextCycleStartsAt = formatUnixToDate(
previewData?.next_cycle?.starts_at || 0,
);
return (
<div className="px-4 space-y-1 text-sm">
<div className="flex items-center justify-between ">
<div className="font-medium text-foreground">Total</div>
<div className="font-semibold text-foreground">${total.toFixed(2)}</div>
</div>
{nextCycleStartsAt && (
<div className="flex items-center justify-between">
<div className="font-medium text-t4">
Next Cycle ({nextCycleStartsAt})
</div>
<div className="font-semibold text-t4">
${nextCycleTotal.toFixed(2)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,176 +0,0 @@
import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared";
import type { LucideIcon } from "lucide-react";
import { ArrowUpRightFromSquare, CircleCheck } from "lucide-react";
import { useAttachProductMutation } from "@/components/forms/attach-product/use-attach-product-mutation";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button";
import { useOrg } from "@/hooks/common/useOrg";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { useEnv } from "@/utils/envUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import type { UseAttachProductForm } from "./use-attach-product-form";
function getUpdateButtonConfig(isCheckout: boolean): {
text: string;
icon: LucideIcon;
} {
return isCheckout
? { text: "Checkout", icon: ArrowUpRightFromSquare }
: { text: "Confirm Update", icon: CircleCheck };
}
interface UpdateProductActionsProps {
product?: ProductV2;
customerId?: string;
entityId?: string;
onSuccess?: () => void;
previewData?: CheckoutResponseV0 | null;
isPreviewLoading?: boolean;
version?: number;
form: UseAttachProductForm;
}
export function UpdateProductActions({
form,
product,
customerId,
entityId,
onSuccess,
previewData,
isPreviewLoading,
version,
}: UpdateProductActionsProps) {
const { stripeAccount } = useOrgStripeQuery();
const env = useEnv();
const org = useOrg();
const isOwnStripeAccount = stripeAccount?.id === org.org?.stripe_connection;
const attachMutation = useAttachProductMutation({
customerId: customerId ?? "",
successMessage: "Plan updated successfully",
onSuccess: () => {
onSuccess?.();
},
});
const handleUpdate = async ({
useInvoice,
enableProductImmediately,
}: {
useInvoice: boolean;
enableProductImmediately?: boolean;
}) => {
// Only redirect to checkout URL for the "Checkout" button flow (useInvoice: false)
// When useInvoice is true, we always call the attach mutation to generate an invoice
if (previewData?.url && !useInvoice) {
window.open(previewData.url, "_blank");
return;
}
// Does the update
const result = await attachMutation.mutateAsync({
product,
entityId,
useInvoice,
enableProductImmediately,
prepaidOptions: form.state.values.prepaidOptions ?? undefined,
version,
});
// Handle checkout URLs and invoice links
if (result.data.invoice) {
window.open(
getStripeInvoiceLink({
stripeInvoice: result.data.invoice,
env,
accountId: stripeAccount?.id,
}),
"_blank",
);
}
};
const isLoading = attachMutation.isPending;
// Don't show buttons if preview is loading
if (isPreviewLoading || !product) {
return null;
}
const isCheckout = !!previewData?.url;
const { text: updateText, icon: UpdateIcon } =
getUpdateButtonConfig(isCheckout);
return (
<div className="flex flex-col px-4 mt-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary"
className="w-full"
isLoading={isLoading}
disabled={isLoading}
type="button"
>
Send an Invoice
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<div className="flex flex-col">
<button
type="button"
onClick={() =>
handleUpdate({
useInvoice: true,
enableProductImmediately: true,
})
}
className="px-4 py-3 text-left text-sm hover:bg-accent"
>
<div className="font-medium">Enable plan immediately</div>
<div className="text-xs text-muted-foreground">
Enable the plan immediately and redirect to Stripe to finalize
the invoice
</div>
</button>
<button
type="button"
onClick={() =>
handleUpdate({
useInvoice: true,
enableProductImmediately: false,
})
}
className="px-4 py-3 text-left text-sm hover:bg-accent border-t"
>
<div className="font-medium">Enable plan after payment</div>
<div className="text-xs text-muted-foreground">
Generate an invoice link for the customer. The plan will be
enabled after they pay the invoice
</div>
</button>
</div>
</PopoverContent>
</Popover>
<Button
variant="primary"
className="w-full flex items-center gap-2 mt-2"
isLoading={isLoading}
disabled={isLoading}
onClick={() =>
handleUpdate({
useInvoice: false,
})
}
>
{updateText}
<UpdateIcon className="size-3.5" />
</Button>
</div>
);
}

View File

@@ -1,77 +0,0 @@
import {
type FrontendProductItem,
getFeaturePriceItemDisplay,
} from "@autumn/shared";
import { useOrg } from "@/hooks/common/useOrg";
import {
usePrepaidItems,
useProductStore,
} from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
import type { UseAttachProductForm } from "./use-attach-product-form";
export function UpdateProductPrepaidOptions({
form,
}: {
form: UseAttachProductForm;
}) {
const storeProduct = useProductStore((s) => s.product);
const itemId = useSheetStore((s) => s.itemId);
const { org } = useOrg();
const { productV2 } = useSubscriptionById({ itemId });
// Use store product if it has a real ID, otherwise use productV2 from subscription
const product = storeProduct?.id ? storeProduct : (productV2 ?? undefined);
const { prepaidItems } = usePrepaidItems({ product });
if (prepaidItems.length === 0) {
return null;
}
console.log("prepaidItems", prepaidItems);
return (
<div className="space-y-3 p-4 pb-0">
<div className="space-y-2">
{prepaidItems.map((item) => {
const display = getFeaturePriceItemDisplay({
item: item as FrontendProductItem,
feature: item.feature,
currency: org?.default_currency || "USD",
fullDisplay: true,
amountFormatOptions: {
currencyDisplay: "narrowSymbol",
},
});
return (
<div
key={item.feature_id}
className="grid grid-cols-[1fr_auto] gap-2 items-center"
>
<span className="text-sm text-foreground truncate">
{display.primary_text}
{display.secondary_text && ` ${display.secondary_text}`}
</span>
<form.AppField name={`prepaidOptions.${item.feature_id}`}>
{(quantityField) => (
<quantityField.QuantityField
label=""
placeholder="0"
min={0}
step={item.billing_units ?? 1}
hideFieldInfo={true}
/>
)}
</form.AppField>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,42 +0,0 @@
import type {
PreviewUpdateSubscriptionResponse,
ProductV2,
} from "@autumn/shared";
import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText";
import { UpdateConfirmationInfo } from "../update-subscription/update-confirmation-info";
import { AttachProductLineItems } from "./attach-product-line-items";
import { AttachProductTotals } from "./attach-product-totals";
import type { UseAttachProductForm } from "./use-attach-product-form";
export function UpdateProductSummary({
product,
previewData,
isLoading,
form,
}: {
product?: ProductV2;
previewData?: PreviewUpdateSubscriptionResponse | null;
isLoading?: boolean;
form: UseAttachProductForm;
}) {
if (isLoading) {
return (
<LoadingShimmerText text="Calculating totals" className="py-4 px-6" />
);
}
return (
<>
<UpdateConfirmationInfo
previewData={previewData}
product={product}
form={form}
/>
<div className="py-4">
<AttachProductLineItems previewData={previewData} />
<AttachProductTotals previewData={previewData} />
</div>
</>
);
}

View File

@@ -1,107 +0,0 @@
import { AppEnv, type ProductV2 } from "@autumn/shared";
import { useMemo } from "react";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { convertPrepaidOptionsToFeatureOptions } from "@/utils/billing/prepaidQuantityUtils";
import { useEnv } from "@/utils/envUtils";
import { getRedirectUrl } from "@/utils/genUtils";
import { getAttachBody } from "@/views/customers/customer/product/components/attachProductUtils";
interface AttachBodyBuilderParams {
customerId?: string;
productId?: string;
product?: ProductV2;
entityId?: string;
prepaidOptions?: Record<string, number | undefined>;
version?: number;
useInvoice?: boolean;
enableProductImmediately?: boolean;
successUrl?: string;
}
/**
* Shared hook to build attach body from explicit params
* Used by both useAttachPreview and useAttachProductMutation to keep logic DRY
*/
export function useAttachBodyBuilder(params: AttachBodyBuilderParams = {}) {
const { products } = useProductsQuery();
const hasChanges = useHasChanges();
const storeProduct = useProductStore((s) => s.product);
const { entityId: storeEntityId } = useEntity();
const env = useEnv();
const { org, isLoading: isOrgLoading, error: orgError } = useOrg();
// Memoized builder function that can be called with runtime params
const buildAttachBody = useMemo(
() => (runtimeParams?: AttachBodyBuilderParams) => {
const mergedParams = { ...params, ...runtimeParams };
const redirectUrl = getRedirectUrl(
`/customers/${mergedParams.customerId}`,
env,
);
// Resolve the product: use provided product or find by ID
const product =
mergedParams.product ||
products.find((p) => p.id === mergedParams.productId);
if (!product || !mergedParams.customerId) {
return null;
}
// Determine if this is a custom product (from store with changes)
const isCustom =
hasChanges && !!storeProduct?.id && product === storeProduct
? true
: undefined;
const version = storeProduct?.id ? storeProduct.version : undefined;
// Convert prepaidOptions to options array
const options = mergedParams.prepaidOptions
? convertPrepaidOptionsToFeatureOptions({
prepaidOptions: mergedParams.prepaidOptions,
product,
})
: undefined;
// Build the attach body
return getAttachBody({
customerId: mergedParams.customerId,
product,
entityId: mergedParams.entityId ?? storeEntityId ?? undefined,
optionsInput: options,
isCustom,
version,
useInvoice: mergedParams.useInvoice,
enableProductImmediately: mergedParams.enableProductImmediately,
successUrl:
// env === AppEnv.Sandbox
// ? `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}`
// : undefined,
org?.success_url && !isOrgLoading && !orgError
? org.success_url
: env === AppEnv.Sandbox
? `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}`
: undefined,
});
},
[
products,
hasChanges,
storeProduct,
storeEntityId,
params,
org,
isOrgLoading,
orgError,
],
);
// For simple usage, return the built body with current params
const attachBody = useMemo(() => buildAttachBody(), [buildAttachBody]);
return { attachBody, buildAttachBody };
}

View File

@@ -1,79 +0,0 @@
import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useAttachBodyBuilder } from "./use-attach-body-builder";
interface AttachPreviewParams {
// Required params - no fallbacks
customerId?: string;
product?: ProductV2;
entityId?: string;
prepaidOptions?: Record<string, number>;
version?: number;
// Control behavior
enabled?: boolean;
}
export function useAttachPreview(params: AttachPreviewParams = {}) {
const axiosInstance = useAxiosInstance();
const buildKey = useQueryKeyFactory();
// Build attach body using shared hook with explicit params
const { attachBody } = useAttachBodyBuilder({
customerId: params.customerId,
product: params.product,
entityId: params.entityId,
prepaidOptions: params.prepaidOptions,
version: params.version,
});
// Auto-enable if not explicitly set and all required data is present
const shouldEnable =
params.enabled !== undefined
? params.enabled
: !!(params.customerId && params.product && attachBody);
// Create a stable serialized key from attachBody (which already captures all dependencies)
const queryKeyDeps = useMemo(() => JSON.stringify(attachBody), [attachBody]);
// Debounce the query key to delay API calls by 150ms
const [debouncedQueryKey, setDebouncedQueryKey] = useState(queryKeyDeps);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQueryKey(queryKeyDeps);
}, 300);
return () => clearTimeout(timer);
}, [queryKeyDeps]);
// Track if we're in a debouncing state (query key has changed but debounce hasn't completed)
const isDebouncing = queryKeyDeps !== debouncedQueryKey;
const query = useQuery({
queryKey: buildKey(["attach-checkout", debouncedQueryKey]),
queryFn: async () => {
if (!attachBody || !params.customerId) {
return null;
}
const response = await axiosInstance.post<CheckoutResponseV0>(
"/v1/checkout",
attachBody,
);
return response.data;
},
enabled: shouldEnable,
staleTime: 0, // Always fetch fresh pricing
});
// Override isLoading to include debouncing state
// This prevents showing stale data during the transition between diff plans in the selector
return {
...query,
isLoading: query.isLoading || isDebouncing,
};
}

View File

@@ -1,53 +0,0 @@
import { useEffect, useRef } from "react";
import { useAppForm } from "@/hooks/form/form";
import {
type AttachProductForm,
AttachProductFormSchema,
} from "./attach-product-form-schema";
export function useAttachProductForm({
initialProductId,
initialPrepaidOptions,
}: {
initialProductId?: string;
initialPrepaidOptions?: Record<string, number>;
} = {}) {
return useAppForm({
defaultValues: {
productId: initialProductId || "",
prepaidOptions: initialPrepaidOptions ?? {},
} as AttachProductForm,
validators: {
onChange: AttachProductFormSchema,
onSubmit: AttachProductFormSchema,
},
});
}
// Subscribe to form changes and clear prepaid options when productId changes
// Prevents stale prepaid options from causing "no prepaid price found" in the `checkout` call
export function useResetPrepaidOnProductChange({
form,
}: {
form: UseAttachProductForm;
}) {
const previousProductIdRef = useRef<string | undefined>();
useEffect(() => {
const subscription = form.store.subscribe(() => {
const currentProductId = form.store.state.values.productId;
if (
previousProductIdRef.current !== undefined &&
previousProductIdRef.current !== currentProductId
) {
form.setFieldValue("prepaidOptions", {});
}
previousProductIdRef.current = currentProductId;
});
return () => subscription();
}, [form.store, form.setFieldValue]);
}
export type UseAttachProductForm = ReturnType<typeof useAttachProductForm>;

View File

@@ -1,92 +0,0 @@
import type { ProductV2 } from "@autumn/shared";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { toast } from "sonner";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { CusService } from "@/services/customers/CusService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useAttachBodyBuilder } from "./use-attach-body-builder";
interface AttachProductParams {
// Product selection (provide one of these)
productId?: string;
product?: ProductV2;
// Optional overrides
entityId?: string;
prepaidOptions?: Record<string, number>;
version?: number;
// Invoice options
useInvoice?: boolean;
enableProductImmediately?: boolean;
}
export function useAttachProductMutation({
customerId,
onSuccess,
onError,
successMessage = "Successfully attached product",
}: {
customerId: string;
onSuccess?: (data: unknown) => void | Promise<void>;
onError?: (error: unknown) => void;
successMessage?: string;
}) {
const axiosInstance = useAxiosInstance();
const queryClient = useQueryClient();
const { closeSheet } = useSheetStore();
// Get builder function from shared hook
const { buildAttachBody } = useAttachBodyBuilder({ customerId });
return useMutation({
mutationFn: async (params: AttachProductParams) => {
// Build attach body using shared builder function
const attachBody = buildAttachBody({
productId: params.productId,
product: params.product,
entityId: params.entityId,
prepaidOptions: params.prepaidOptions,
version: params.version,
useInvoice: params.useInvoice,
enableProductImmediately: params.enableProductImmediately,
});
if (!attachBody) {
throw new Error(
"Failed to build attach body - product not found or missing data",
);
}
return await CusService.attach(axiosInstance, attachBody);
},
onSuccess: async (response) => {
// Don't show success toast if checkout_url is returned - product not attached yet
if (response.data.checkout_url) {
toast.success("Redirecting to checkout URL");
closeSheet();
return;
}
toast.success(successMessage);
closeSheet();
queryClient.invalidateQueries({ queryKey: ["customer", customerId] });
if (onSuccess) {
await onSuccess(response.data);
}
},
onError: (error) => {
if (onError) {
onError(error);
} else {
toast.error(
(error as AxiosError<{ message: string }>)?.response?.data?.message ??
"Failed to attach product",
);
console.error(error);
}
},
});
}

View File

@@ -1,73 +0,0 @@
import { TimerIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import {
STAGGER_CONTAINER,
STAGGER_ITEM,
} from "@/components/forms/update-subscription-v2/constants/animationConstants";
import { Skeleton } from "@/components/ui/skeleton";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
export function AttachPlanSkeleton() {
return (
<SheetSection withSeparator>
<motion.div
className="space-y-2"
initial="hidden"
animate="visible"
variants={STAGGER_CONTAINER}
>
{/* Section title - static content with disabled buttons */}
<motion.div variants={STAGGER_ITEM}>
<h3 className="text-sub select-none w-full">
<span className="flex items-center justify-between w-full gap-2">
<span className="flex items-center gap-1.5">
Plan Configuration
</span>
<IconButton
icon={<TimerIcon size={14} />}
variant="secondary"
className="h-7 whitespace-nowrap"
disabled
>
Free Trial
</IconButton>
</span>
</h3>
</motion.div>
{/* Price display skeleton */}
<motion.div
variants={STAGGER_ITEM}
className="flex gap-2 justify-between items-center"
>
<span className="flex items-center gap-1">
<Skeleton className="h-5 w-10" />
<Skeleton className="h-4 w-16" />
</span>
</motion.div>
{/* Item rows skeleton */}
{[0, 1].map((i) => (
<motion.div key={`skeleton-row-${i}`} variants={STAGGER_ITEM}>
<div className="flex items-center flex-1 min-w-0 h-10 px-3 rounded-xl input-base">
<div className="flex flex-row items-center flex-1 gap-2 min-w-0">
<div className="flex flex-row items-center gap-1 shrink-0">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-1 w-1 rounded-full" />
<Skeleton className="h-4 w-4 rounded" />
</div>
<Skeleton className="h-4 w-32" />
</div>
</div>
</motion.div>
))}
{/* Edit button skeleton */}
<motion.div variants={STAGGER_ITEM}>
<Skeleton className="h-9 w-full rounded-lg" />
</motion.div>
</motion.div>
</SheetSection>
);
}

View File

@@ -1,119 +0,0 @@
import { CalendarIcon, GearIcon, LightningIcon } from "@phosphor-icons/react";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { cn } from "@/lib/utils";
import { usePlanScheduleField } from "../hooks/usePlanScheduleField";
export function AttachSettingsPopover() {
const [open, setOpen] = useState(false);
const {
hasActiveSubscription,
hasOutgoing,
hasCustomSchedule,
isImmediateSelected,
isEndOfCycleSelected,
handleScheduleChange,
} = usePlanScheduleField();
if (!hasActiveSubscription) return null;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<IconButton
icon={
<GearIcon
size={14}
weight={hasCustomSchedule ? "fill" : "regular"}
/>
}
variant="secondary"
className={cn(
"h-7 whitespace-nowrap",
hasCustomSchedule &&
"text-blue-400! border-blue-500/50 bg-blue-500/10",
)}
>
Settings
</IconButton>
</PopoverTrigger>
<PopoverContent
align="end"
className="p-3 w-[380px] z-101 bg-muted"
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-3">
<div className="flex flex-col gap-1">
<p className="text-t2 font-medium text-base">
Advanced Configuration
</p>
<p className="text-t3 text-xs">Override default billing behavior</p>
</div>
<Separator />
<div className="flex items-center justify-between gap-3">
<span className="text-t1 text-sm">Plan Schedule</span>
<div className="flex">
<IconCheckbox
icon={<LightningIcon />}
iconOrientation="left"
variant="secondary"
size="sm"
checked={isImmediateSelected}
onCheckedChange={() => handleScheduleChange("immediate")}
className={cn(
"rounded-r-none",
!isImmediateSelected && "border-r-0",
)}
>
Immediately
</IconCheckbox>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<IconCheckbox
icon={<CalendarIcon />}
iconOrientation="left"
variant="secondary"
size="sm"
checked={isEndOfCycleSelected}
disabled={!hasOutgoing}
onCheckedChange={() =>
handleScheduleChange("end_of_cycle")
}
className={cn(
"rounded-l-none",
!isEndOfCycleSelected && "border-l-0",
)}
>
End of cycle
</IconCheckbox>
</span>
</TooltipTrigger>
{!hasOutgoing && (
<TooltipContent>
Only available when transitioning from an existing plan
</TooltipContent>
)}
</Tooltip>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,8 +0,0 @@
export * from "./components/CreateScheduleSheetContent";
export * from "./components/SchedulePhaseCard";
export * from "./components/SchedulePlanRow";
export * from "./context/CreateScheduleFormProvider";
export * from "./createScheduleFormSchema";
export * from "./hooks/useCreateScheduleForm";
export * from "./hooks/useCreateScheduleMutation";
export * from "./hooks/useCreateScheduleRequestBody";

View File

@@ -1,25 +0,0 @@
import { cn } from "@/lib/utils";
interface CompactValueChangeProps {
oldValue: string | number | null;
newValue: string | number | null;
isUpgrade?: boolean;
}
export function CompactValueChange({
oldValue,
newValue,
isUpgrade = true,
}: CompactValueChangeProps) {
return (
<span className="text-xs flex items-center gap-1">
<span className={cn(isUpgrade ? "text-red-500" : "text-green-500")}>
{oldValue}
</span>
<span className="text-t3"></span>
<span className={cn(isUpgrade ? "text-green-500" : "text-red-500")}>
{newValue}
</span>
</span>
);
}

View File

@@ -1,30 +0,0 @@
import type { EditIconType } from "@autumn/shared";
import {
CurrencyDollarIcon,
HashIcon,
PackageIcon,
StackIcon,
TagIcon,
} from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
export function getEditIcon(iconType: EditIconType, isUpgrade: boolean) {
const iconProps = {
size: 14,
className: cn("shrink-0", isUpgrade ? "text-green-500" : "text-red-500"),
};
switch (iconType) {
case "price":
return <CurrencyDollarIcon {...iconProps} />;
case "tier":
return <StackIcon {...iconProps} />;
case "usage":
return <HashIcon {...iconProps} />;
case "units":
return <TagIcon {...iconProps} />;
case "prepaid":
return <PackageIcon {...iconProps} />;
default:
return null;
}
}

View File

@@ -1,129 +0,0 @@
import type {
PreviewUpdateSubscriptionResponse,
ProductV2,
} from "@autumn/shared";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useHasChanges, usePrepaidItems } from "@/hooks/stores/useProductStore";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
import type { UseUpdateSubscriptionForm } from "./use-update-subscription-form";
export const UpdateConfirmationInfo = ({
previewData,
product,
form,
}: {
previewData?: PreviewUpdateSubscriptionResponse | null;
product?: ProductV2;
form: UseUpdateSubscriptionForm;
}) => {
const hasChanges = useHasChanges();
// const hasBillingChanges = useHasBillingChanges({
// baseProduct: previewData?.current_product,
// newProduct: previewData?.product,
// });
const hasPrepaidQuantityChanges = useHasPrepaidQuantityChanges(product, form);
const renderInfoBoxes = (): ReactNode[] => {
const boxes: ReactNode[] = [];
if (!previewData) {
return boxes;
}
// Plan customization notice
if (hasChanges) {
boxes.push(
<InfoBox key="customized" variant="success">
This plan has been customized for this customer
</InfoBox>,
);
}
// Version change notice
// if (previewData.current_product?.version !== previewData.product.version) {
// boxes.push(
// <InfoBox key="version-change" variant="info">
// You're switching from v{previewData.current_product?.version} to v
// {previewData.product.version} of this plan
// </InfoBox>,
// );
// }
// Prepaid quantity changes notice
if (hasPrepaidQuantityChanges) {
boxes.push(
<InfoBox key="prepaid-quantity-changes" variant="info">
Prepaid quantities have been updated
</InfoBox>,
);
}
// No billing changes notice
// if (!hasBillingChanges && !hasPrepaidQuantityChanges) {
// boxes.push(
// <InfoBox key="no-billing-changes" variant="success">
// No changes to billing will be made
// </InfoBox>,
// );
// }
// Free trial updated
// if (previewData.product.free_trial) {
// const trialEndDate = previewData.next_cycle?.starts_at
// ? formatUnixToDate(previewData.next_cycle.starts_at)
// : null;
// boxes.push(
// <InfoBox key="free-trial-updated" variant="info">
// Free trial updated
// {trialEndDate && (
// <>
// {" "}
// - trial ends <span className="font-semibold">{trialEndDate}</span>
// </>
// )}
// </InfoBox>,
// );
// }
return boxes;
};
const infoBoxes = renderInfoBoxes();
if (infoBoxes.length === 0) {
return null;
}
return (
<div className="space-y-2 px-4 mt-4">
{infoBoxes.map((box, index) => (
<div key={index}>{box}</div>
))}
</div>
);
};
const useHasPrepaidQuantityChanges = (
product: ProductV2 | undefined,
form: UseUpdateSubscriptionForm,
) => {
const { prepaidItems } = usePrepaidItems({ product });
const currentPrepaidOptions = form.state.values.prepaidOptions;
const defaultPrepaidOptions = form.options.defaultValues?.prepaidOptions;
return useMemo(() => {
if (prepaidItems.length === 0 || !currentPrepaidOptions) {
return false;
}
return prepaidItems.some((item) => {
const currentQuantity = currentPrepaidOptions[item.feature_id as string];
const defaultQuantity =
defaultPrepaidOptions?.[item.feature_id as string];
return currentQuantity !== defaultQuantity;
});
}, [prepaidItems, currentPrepaidOptions, defaultPrepaidOptions]);
};

View File

@@ -1,55 +0,0 @@
import { useEffect, useRef } from "react";
import { useAppForm } from "@/hooks/form/form";
import {
type AttachProductForm,
AttachProductFormSchema,
} from "../attach-product/attach-product-form-schema";
export function useUpdateSubscriptionForm({
initialProductId,
initialPrepaidOptions,
}: {
initialProductId?: string;
initialPrepaidOptions?: Record<string, number>;
} = {}) {
return useAppForm({
defaultValues: {
productId: initialProductId || "",
prepaidOptions: initialPrepaidOptions ?? {},
} as AttachProductForm,
validators: {
onChange: AttachProductFormSchema,
onSubmit: AttachProductFormSchema,
},
});
}
// Subscribe to form changes and clear prepaid options when productId changes
// Prevents stale prepaid options from causing "no prepaid price found" in the `checkout` call
function useResetPrepaidOnProductChange({
form,
}: {
form: UseUpdateSubscriptionForm;
}) {
const previousProductIdRef = useRef<string | undefined>();
useEffect(() => {
const subscription = form.store.subscribe(() => {
const currentProductId = form.store.state.values.productId;
if (
previousProductIdRef.current !== undefined &&
previousProductIdRef.current !== currentProductId
) {
form.setFieldValue("prepaidOptions", {});
}
previousProductIdRef.current = currentProductId;
});
return () => subscription();
}, [form.store, form.setFieldValue]);
}
export type UseUpdateSubscriptionForm = ReturnType<
typeof useUpdateSubscriptionForm
>;

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