Merge branch 'dev' of https://github.com/useautumn/autumn into dev
This commit is contained in:
2
.github/workflows/knip.yml
vendored
2
.github/workflows/knip.yml
vendored
@@ -25,4 +25,4 @@ jobs:
|
||||
run: bun install
|
||||
|
||||
- name: Run Knip
|
||||
run: bun knip --no-exit-code
|
||||
run: bun knip
|
||||
|
||||
24
.github/workflows/server-typecheck.yml
vendored
24
.github/workflows/server-typecheck.yml
vendored
@@ -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:
|
||||
|
||||
26
.github/workflows/server-unit-tests.yml
vendored
26
.github/workflows/server-unit-tests.yml
vendored
@@ -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
32
.github/workflows/validate-schema.yml
vendored
Normal 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
|
||||
28
.github/workflows/vite-build.yml
vendored
28
.github/workflows/vite-build.yml
vendored
@@ -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:
|
||||
|
||||
28
.github/workflows/vite-unit-tests.yml
vendored
28
.github/workflows/vite-unit-tests.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
/**
|
||||
* Full-screen background wrapper with subtle diagonal gradients from primary color.
|
||||
* Includes entrance animation for the content container.
|
||||
*/
|
||||
export function CardBackground({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="bg-card relative overflow-hidden">
|
||||
{/* Top-right diagonal gradient */}
|
||||
<motion.div
|
||||
className="absolute inset-0 pointer-events-none bg-[linear-gradient(135deg,color-mix(in_oklch,var(--foreground)_4%,var(--background))_0%,transparent_50%)]"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.4 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
/>
|
||||
{/* Bottom-left diagonal gradient (lighter) */}
|
||||
<motion.div
|
||||
className="absolute inset-0 pointer-events-none bg-[linear-gradient(315deg,color-mix(in_oklch,var(--foreground)_2%,var(--background))_0%,transparent_45%)]"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.2 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const cardVariants = {
|
||||
default: "ring-foreground/10 bg-card ring-1 shadow-[0_0_10px_2px_rgba(0,0,0,0.02)]",
|
||||
muted: "bg-muted/50",
|
||||
}
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
size?: "default" | "sm"
|
||||
variant?: "default" | "muted"
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
|
||||
cardVariants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()`
|
||||
|
||||
|
||||
@@ -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
131
bun.lock
@@ -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=="],
|
||||
|
||||
69
knip.json
69
knip.json
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
542
server/src/external/autumn/autumnCliV2.ts
vendored
542
server/src/external/autumn/autumnCliV2.ts
vendored
@@ -1,542 +0,0 @@
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: AutumnCliV2 is used for internal testing & scripts */
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
type ApiEntityBillingControlsParams,
|
||||
type AttachBodyV0,
|
||||
type CancelBody,
|
||||
type CheckoutParams,
|
||||
type CheckoutResponseV0,
|
||||
type CheckParams,
|
||||
type CheckQuery,
|
||||
type CheckResponseV1,
|
||||
type CreateEntityParams,
|
||||
type CreateRewardProgram,
|
||||
CustomerExpand,
|
||||
EntityExpand,
|
||||
ErrCode,
|
||||
type OrgConfig,
|
||||
type RewardRedemption,
|
||||
type SetUsageParams,
|
||||
type TrackParams,
|
||||
} from "@autumn/shared";
|
||||
|
||||
class AutumnError extends Error {
|
||||
message: string;
|
||||
code: string;
|
||||
|
||||
constructor({ message, code }: { message: string; code: string }) {
|
||||
super(message);
|
||||
this.message = message;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.message} (code: ${this.code})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Robust Autumn API client (V2) with proper version handling
|
||||
*
|
||||
* Key improvements over V1:
|
||||
* - Properly respects x-api-version header for ALL requests
|
||||
* - No legacy v1Schema params
|
||||
* - Cleaner error handling
|
||||
* - Type-safe version parameter
|
||||
*/
|
||||
export class AutumnCliV2 {
|
||||
private apiKey: string;
|
||||
public headers: Record<string, string>;
|
||||
public baseUrl: string;
|
||||
public version?: string;
|
||||
|
||||
constructor({
|
||||
apiKey,
|
||||
secretKey,
|
||||
baseUrl,
|
||||
version,
|
||||
orgConfig,
|
||||
liveUrl = false,
|
||||
}: {
|
||||
apiKey?: string;
|
||||
secretKey?: string;
|
||||
baseUrl?: string;
|
||||
version?: string;
|
||||
orgConfig?: Partial<OrgConfig>;
|
||||
liveUrl?: boolean;
|
||||
} = {}) {
|
||||
this.apiKey =
|
||||
apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
|
||||
|
||||
this.headers = {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
this.version = version;
|
||||
|
||||
if (version) {
|
||||
this.headers["x-api-version"] = version;
|
||||
}
|
||||
|
||||
if (orgConfig) {
|
||||
this.headers["org-config"] = JSON.stringify(orgConfig);
|
||||
}
|
||||
|
||||
this.baseUrl =
|
||||
baseUrl ||
|
||||
(liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1");
|
||||
}
|
||||
|
||||
async get(path: string) {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
let error: any;
|
||||
try {
|
||||
error = await response.json();
|
||||
} catch (_e) {
|
||||
throw new AutumnError({
|
||||
message: `GET ${path} failed with status ${response.status}`,
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
throw new AutumnError({
|
||||
message: error.message || `GET ${path} failed`,
|
||||
code: error.code || ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async post(path: string, body: any) {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
let error: any;
|
||||
try {
|
||||
error = await response.json();
|
||||
} catch (_e) {
|
||||
throw new AutumnError({
|
||||
message: `POST ${path} failed with status ${response.status}`,
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
throw new AutumnError({
|
||||
message: error.message || `POST ${path} failed`,
|
||||
code: error.code || ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async patch(path: string, body: any) {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "PATCH",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
let error: any;
|
||||
try {
|
||||
error = await response.json();
|
||||
} catch (_e) {
|
||||
throw new AutumnError({
|
||||
message: `PATCH ${path} failed with status ${response.status}`,
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
throw new AutumnError({
|
||||
message: error.message || `PATCH ${path} failed`,
|
||||
code: error.code || ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async delete(
|
||||
path: string,
|
||||
{
|
||||
deleteInStripe = false,
|
||||
}: {
|
||||
deleteInStripe?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const queryParams = deleteInStripe ? "?delete_in_stripe=true" : "";
|
||||
const response = await fetch(`${this.baseUrl}${path}${queryParams}`, {
|
||||
method: "DELETE",
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
let error: any;
|
||||
try {
|
||||
error = await response.json();
|
||||
} catch (_e) {
|
||||
throw new AutumnError({
|
||||
message: `DELETE ${path} failed with status ${response.status}`,
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
throw new AutumnError({
|
||||
message: error.message || `DELETE ${path} failed`,
|
||||
code: error.code || ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async createCustomer({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
fingerprint,
|
||||
}: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
fingerprint?: string;
|
||||
}) {
|
||||
return await this.post("/customers", {
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
async attach(params: AttachBodyV0) {
|
||||
return await this.post(`/attach`, params);
|
||||
}
|
||||
|
||||
async checkout(
|
||||
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean },
|
||||
) {
|
||||
const data = await this.post(`/checkout`, params);
|
||||
return data as CheckoutResponseV0;
|
||||
}
|
||||
|
||||
async transfer(
|
||||
customerId: string,
|
||||
params: {
|
||||
from_entity_id?: string;
|
||||
to_entity_id: string;
|
||||
product_id: string;
|
||||
},
|
||||
) {
|
||||
const data = await this.post(`/customers/${customerId}/transfer`, params);
|
||||
return data;
|
||||
}
|
||||
|
||||
async sendEvent({
|
||||
customerId,
|
||||
eventName,
|
||||
properties,
|
||||
customer_data,
|
||||
idempotency_key,
|
||||
}: {
|
||||
customerId: string;
|
||||
eventName: string;
|
||||
properties?: any;
|
||||
customer_data?: any;
|
||||
idempotency_key?: string;
|
||||
}) {
|
||||
return await this.post(`/events`, {
|
||||
customer_id: customerId,
|
||||
event_name: eventName,
|
||||
properties,
|
||||
customer_data,
|
||||
idempotency_key,
|
||||
});
|
||||
}
|
||||
|
||||
async entitled({
|
||||
customerId,
|
||||
featureId,
|
||||
quantity,
|
||||
customer_data,
|
||||
}: {
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
quantity?: number;
|
||||
customer_data?: any;
|
||||
}) {
|
||||
return await this.post(`/entitled`, {
|
||||
customer_id: customerId,
|
||||
feature_id: featureId,
|
||||
quantity,
|
||||
customer_data,
|
||||
});
|
||||
}
|
||||
|
||||
customers = {
|
||||
list: async (params?: { limit?: number; offset?: number }) => {
|
||||
const queryString = params
|
||||
? `?${new URLSearchParams(params as Record<string, string>).toString()}`
|
||||
: "";
|
||||
return await this.get(`/customers${queryString}`);
|
||||
},
|
||||
|
||||
get: async (
|
||||
customerId: string,
|
||||
params?: {
|
||||
expand?: CustomerExpand[];
|
||||
},
|
||||
): Promise<
|
||||
ApiCustomerV3 & {
|
||||
invoices: any[];
|
||||
}
|
||||
> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
const defaultParams = {
|
||||
expand: [CustomerExpand.Invoices],
|
||||
};
|
||||
|
||||
const finalParams = { ...defaultParams, ...params };
|
||||
if (finalParams.expand) {
|
||||
queryParams.append("expand", finalParams.expand.join(","));
|
||||
}
|
||||
|
||||
return await this.get(
|
||||
`/customers/${customerId}?${queryParams.toString()}`,
|
||||
);
|
||||
},
|
||||
|
||||
create: async (customer: { id: string; email?: string; name?: string }) => {
|
||||
return await this.post(`/customers?with_autumn_id=true`, customer);
|
||||
},
|
||||
|
||||
delete: async (
|
||||
customerId: string,
|
||||
{
|
||||
deleteInStripe = false,
|
||||
}: {
|
||||
deleteInStripe?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
return await this.delete(`/customers/${customerId}`, {
|
||||
deleteInStripe,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
entities = {
|
||||
get: async (customerId: string, entityId: string) => {
|
||||
return await this.get(
|
||||
`/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`,
|
||||
);
|
||||
},
|
||||
|
||||
create: async (
|
||||
customerId: string,
|
||||
entity: CreateEntityParams | CreateEntityParams[],
|
||||
) => {
|
||||
return await this.post(
|
||||
`/customers/${customerId}/entities?with_autumn_id=true`,
|
||||
entity,
|
||||
);
|
||||
},
|
||||
|
||||
list: async (customerId: string) => {
|
||||
return await this.get(`/customers/${customerId}/entities`);
|
||||
},
|
||||
|
||||
delete: async (customerId: string, entityId: string) => {
|
||||
return await this.delete(`/customers/${customerId}/entities/${entityId}`);
|
||||
},
|
||||
|
||||
update: async (
|
||||
customerId: string,
|
||||
entityId: string,
|
||||
updates: {
|
||||
billing_controls?: ApiEntityBillingControlsParams;
|
||||
},
|
||||
) => {
|
||||
return await this.post(`/entities.update`, {
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
...updates,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
products = {
|
||||
/**
|
||||
* Get product - respects x-api-version header set in constructor
|
||||
*/
|
||||
get: async (productId: string) => {
|
||||
return await this.get(`/products/${productId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create product - respects x-api-version header
|
||||
*/
|
||||
create: async (product: any) => {
|
||||
return await this.post(`/products`, product);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update product - respects x-api-version header
|
||||
*/
|
||||
update: async (productId: string, product: any) => {
|
||||
return await this.post(`/products/${productId}`, product);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete product
|
||||
*/
|
||||
delete: async (productId: string) => {
|
||||
return await this.delete(`/products/${productId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* List products - respects x-api-version header
|
||||
*/
|
||||
list: async (params?: { limit?: number; offset?: number }) => {
|
||||
const queryString = params
|
||||
? `?${new URLSearchParams(params as Record<string, string>).toString()}`
|
||||
: "";
|
||||
return await this.get(`/products${queryString}`);
|
||||
},
|
||||
};
|
||||
|
||||
rewards = {
|
||||
get: async (rewardId: string) => {
|
||||
return await this.get(`/rewards/${rewardId}`);
|
||||
},
|
||||
|
||||
create: async (reward: any) => {
|
||||
return await this.post(`/rewards?legacyStripe=true`, reward);
|
||||
},
|
||||
|
||||
delete: async (rewardId: string) => {
|
||||
return await this.delete(`/rewards/${rewardId}`);
|
||||
},
|
||||
};
|
||||
|
||||
rewardPrograms = {
|
||||
create: async (rewardProgram: CreateRewardProgram) => {
|
||||
return await this.post(`/reward_programs`, rewardProgram);
|
||||
},
|
||||
};
|
||||
|
||||
referrals = {
|
||||
createCode: async ({
|
||||
customerId,
|
||||
referralId,
|
||||
}: {
|
||||
customerId: string;
|
||||
referralId: string;
|
||||
}) => {
|
||||
return await this.post(`/referrals/code`, {
|
||||
customer_id: customerId,
|
||||
program_id: referralId,
|
||||
});
|
||||
},
|
||||
|
||||
redeem: async ({
|
||||
customerId,
|
||||
code,
|
||||
}: {
|
||||
customerId: string;
|
||||
code: string;
|
||||
}) => {
|
||||
return await this.post(`/referrals/redeem`, {
|
||||
customer_id: customerId,
|
||||
code,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
redemptions = {
|
||||
get: async (redemptionId: string) => {
|
||||
const data = await this.get(`/redemptions/${redemptionId}`);
|
||||
return data as RewardRedemption;
|
||||
},
|
||||
};
|
||||
|
||||
events = {
|
||||
send: async ({
|
||||
customerId,
|
||||
featureId,
|
||||
value,
|
||||
properties,
|
||||
}: {
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
value: number;
|
||||
properties?: any;
|
||||
}) => {
|
||||
return await this.post(`/events`, {
|
||||
customer_id: customerId,
|
||||
feature_id: featureId,
|
||||
value,
|
||||
properties,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
stripe = {
|
||||
connect: async (params: {
|
||||
secret_key: string;
|
||||
success_url: string;
|
||||
default_currency: string;
|
||||
}) => {
|
||||
return await this.post(`/organization/stripe`, params);
|
||||
},
|
||||
|
||||
delete: async () => {
|
||||
return await this.delete(`/organization/stripe`);
|
||||
},
|
||||
};
|
||||
|
||||
track = async (params: TrackParams) => {
|
||||
return await this.post(`/track`, params);
|
||||
};
|
||||
|
||||
usage = async (params: SetUsageParams) => {
|
||||
return await this.post(`/usage`, params);
|
||||
};
|
||||
|
||||
check = async <T = CheckResponseV1>(
|
||||
params: CheckParams & CheckQuery,
|
||||
): Promise<T> => {
|
||||
return await this.post(`/check`, params);
|
||||
};
|
||||
|
||||
attachPreview = async (params: AttachBodyV0) => {
|
||||
return await this.post(`/attach/preview`, params);
|
||||
};
|
||||
|
||||
cancel = async (params: CancelBody) => {
|
||||
return await this.post(`/cancel`, params);
|
||||
};
|
||||
|
||||
migrate = async (params: {
|
||||
from_product_id: string;
|
||||
to_product_id: string;
|
||||
from_version: number;
|
||||
to_version: number;
|
||||
}) => {
|
||||
return await this.post(`/migrations`, params);
|
||||
};
|
||||
}
|
||||
26
server/src/external/redis/loadCaCert.ts
vendored
26
server/src/external/redis/loadCaCert.ts
vendored
@@ -1,26 +0,0 @@
|
||||
export const loadCaCert = async ({
|
||||
caPath,
|
||||
type,
|
||||
caValue,
|
||||
}: {
|
||||
caPath?: string;
|
||||
type: "queue" | "cache";
|
||||
caValue?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (caValue) {
|
||||
if (caValue.startsWith("-----BEGIN CERTIFICATE-----")) {
|
||||
return caValue;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ca = Bun.file(caPath || `/etc/secrets/${type}-cert.pem`);
|
||||
const caText = await ca.text();
|
||||
|
||||
return caText;
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
283
server/src/external/redis/redisFailover.ts
vendored
283
server/src/external/redis/redisFailover.ts
vendored
@@ -1,283 +0,0 @@
|
||||
import type { Redis } from "ioredis";
|
||||
import { logger } from "@/external/logtail/logtailUtils.js";
|
||||
|
||||
// ── Config ──────────────────────────────────────────────────────────
|
||||
/** How long primary must stay down before we switch to failover. */
|
||||
const FAILOVER_THRESHOLD_MS = 60_000;
|
||||
|
||||
/** How long primary must stay healthy before we switch back. */
|
||||
const RECOVERY_THRESHOLD_MS = 5_000;
|
||||
|
||||
/** Health-check polling interval. */
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
/** Log a warning if blip count exceeds this in the trailing window. */
|
||||
const BLIP_WARN_THRESHOLD = 10;
|
||||
|
||||
/** Trailing window for blip counting. */
|
||||
const BLIP_WINDOW_MS = 60 * 60 * 1_000; // 1 hour
|
||||
|
||||
// ── State machine ───────────────────────────────────────────────────
|
||||
type FailoverPhase = "NORMAL" | "DEGRADED" | "FAILOVER" | "RECOVERING";
|
||||
|
||||
type FailoverState = {
|
||||
phase: FailoverPhase;
|
||||
active: Redis;
|
||||
primary: Redis;
|
||||
failover: Redis | null;
|
||||
failoverRegion: string | null;
|
||||
/** Timestamp when the current phase was entered. */
|
||||
phaseEnteredAt: number;
|
||||
};
|
||||
|
||||
let state: FailoverState;
|
||||
let primaryHasBeenReady = false;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Tracks timestamps of recent transient blips (DEGRADED → NORMAL). */
|
||||
const blipTimestamps: number[] = [];
|
||||
|
||||
// ── Callbacks ───────────────────────────────────────────────────────
|
||||
type StateChangeCallback = () => void;
|
||||
const onChangeCallbacks: StateChangeCallback[] = [];
|
||||
|
||||
/** Register a callback invoked whenever `active` changes. */
|
||||
export const onActiveChange = (cb: StateChangeCallback): void => {
|
||||
onChangeCallbacks.push(cb);
|
||||
};
|
||||
|
||||
const notifyChange = (): void => {
|
||||
for (const cb of onChangeCallbacks) {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
logger.error("[Redis failover] onActiveChange callback threw", {
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
const isPrimaryReady = (): boolean => state.primary.status === "ready";
|
||||
const isFailoverReady = (): boolean => state.failover?.status === "ready";
|
||||
|
||||
const setPhase = (phase: FailoverPhase): void => {
|
||||
state.phase = phase;
|
||||
state.phaseEnteredAt = Date.now();
|
||||
};
|
||||
|
||||
const msInPhase = (): number => Date.now() - state.phaseEnteredAt;
|
||||
|
||||
const pruneBlips = (): void => {
|
||||
const cutoff = Date.now() - BLIP_WINDOW_MS;
|
||||
while (blipTimestamps.length > 0 && blipTimestamps[0] < cutoff) {
|
||||
blipTimestamps.shift();
|
||||
}
|
||||
};
|
||||
|
||||
const recordBlip = ({ durationMs }: { durationMs: number }): void => {
|
||||
blipTimestamps.push(Date.now());
|
||||
pruneBlips();
|
||||
|
||||
logger.warn(
|
||||
`[Redis failover] Primary blip #${blipTimestamps.length} (recovered in ${durationMs}ms)`,
|
||||
{
|
||||
type: "redis_failover_blip",
|
||||
blipCount: blipTimestamps.length,
|
||||
durationMs,
|
||||
},
|
||||
);
|
||||
|
||||
if (blipTimestamps.length >= BLIP_WARN_THRESHOLD) {
|
||||
logger.error(
|
||||
`[Redis failover] ${blipTimestamps.length} blips in the last hour — check Redis health`,
|
||||
{
|
||||
type: "redis_failover_blip_alert",
|
||||
blipCount: blipTimestamps.length,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Core poll tick ──────────────────────────────────────────────────
|
||||
const tick = (): void => {
|
||||
const ready = isPrimaryReady();
|
||||
|
||||
switch (state.phase) {
|
||||
case "NORMAL": {
|
||||
if (!ready && primaryHasBeenReady) {
|
||||
setPhase("DEGRADED");
|
||||
logger.warn("[Redis failover] Primary unhealthy — entering DEGRADED", {
|
||||
type: "redis_failover_degraded",
|
||||
primaryStatus: state.primary.status,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "DEGRADED": {
|
||||
if (ready) {
|
||||
// Blip — primary recovered before we had to failover
|
||||
recordBlip({ durationMs: msInPhase() });
|
||||
setPhase("NORMAL");
|
||||
break;
|
||||
}
|
||||
|
||||
if (msInPhase() >= FAILOVER_THRESHOLD_MS) {
|
||||
if (!state.failover || !isFailoverReady()) {
|
||||
logger.error(
|
||||
"[Redis failover] Threshold reached but failover instance not ready",
|
||||
{
|
||||
type: "redis_failover_switch",
|
||||
failoverStatus: state.failover?.status ?? "none",
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
state.active = state.failover;
|
||||
setPhase("FAILOVER");
|
||||
notifyChange();
|
||||
|
||||
logger.error(
|
||||
`[Redis failover] SWITCHED to failover region (${state.failoverRegion})`,
|
||||
{
|
||||
type: "redis_failover_switch",
|
||||
failoverRegion: state.failoverRegion,
|
||||
},
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "FAILOVER": {
|
||||
if (ready) {
|
||||
setPhase("RECOVERING");
|
||||
logger.info("[Redis failover] Primary back — entering RECOVERING", {
|
||||
type: "redis_failover_recovering",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "RECOVERING": {
|
||||
if (!ready) {
|
||||
// Primary dropped again — go back to failover
|
||||
setPhase("FAILOVER");
|
||||
logger.warn(
|
||||
"[Redis failover] Primary dropped during recovery — back to FAILOVER",
|
||||
{ type: "redis_failover_recovery_failed" },
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (msInPhase() >= RECOVERY_THRESHOLD_MS) {
|
||||
state.active = state.primary;
|
||||
setPhase("NORMAL");
|
||||
notifyChange();
|
||||
|
||||
logger.info("[Redis failover] RECOVERED to primary region", {
|
||||
type: "redis_failover_recovered",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
/** Initialize failover. Call once after creating both Redis instances. */
|
||||
export const initFailover = ({
|
||||
primary,
|
||||
failover,
|
||||
failoverRegion,
|
||||
currentRegion,
|
||||
}: {
|
||||
primary: Redis;
|
||||
failover: Redis | null;
|
||||
failoverRegion: string | null;
|
||||
currentRegion: string;
|
||||
}): void => {
|
||||
state = {
|
||||
phase: "NORMAL",
|
||||
active: primary,
|
||||
primary,
|
||||
failover,
|
||||
failoverRegion,
|
||||
phaseEnteredAt: Date.now(),
|
||||
};
|
||||
|
||||
if (!failover) {
|
||||
logger.info(
|
||||
"[Redis failover] No failover region configured — failover disabled",
|
||||
{ type: "redis_failover_init" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[Redis failover] Enabled: primary=${currentRegion}, failover=${failoverRegion}`,
|
||||
{ type: "redis_failover_init", currentRegion, failoverRegion },
|
||||
);
|
||||
|
||||
// Track when primary first connects so we don't failover during startup
|
||||
primary.on("ready", () => {
|
||||
primaryHasBeenReady = true;
|
||||
});
|
||||
|
||||
// Clear any existing state from a previous init
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
primaryHasBeenReady = false;
|
||||
blipTimestamps.length = 0;
|
||||
|
||||
// Start the single polling loop
|
||||
pollTimer = setInterval(tick, POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
/** Get the currently active Redis instance. */
|
||||
export const getActiveRedis = (): Redis => state.active;
|
||||
|
||||
/** Get current failover state (for debug/monitoring). */
|
||||
export const getFailoverState = (): {
|
||||
phase: FailoverPhase;
|
||||
isUsingFailover: boolean;
|
||||
failoverRegion: string | null;
|
||||
primaryStatus: string;
|
||||
failoverStatus: string | null;
|
||||
msInPhase: number;
|
||||
blipsLastHour: number;
|
||||
} => {
|
||||
pruneBlips();
|
||||
return {
|
||||
phase: state.phase,
|
||||
isUsingFailover: state.phase === "FAILOVER" || state.phase === "RECOVERING",
|
||||
failoverRegion: state.failoverRegion,
|
||||
primaryStatus: state.primary.status,
|
||||
failoverStatus: state.failover?.status ?? null,
|
||||
msInPhase: msInPhase(),
|
||||
blipsLastHour: blipTimestamps.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Force disconnect the primary (for testing). */
|
||||
export const disconnectPrimary = (): void => {
|
||||
state.primary.disconnect();
|
||||
};
|
||||
|
||||
/** Force reconnect the primary (for testing). */
|
||||
export const reconnectPrimary = (): void => {
|
||||
state.primary.connect();
|
||||
};
|
||||
|
||||
/** Stop the polling loop (for testing/cleanup). */
|
||||
export const stopFailoverPolling = (): void => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
7
server/src/external/redis/utils/index.ts
vendored
7
server/src/external/redis/utils/index.ts
vendored
@@ -1,7 +0,0 @@
|
||||
export { RedisUnavailableError } from "./errors.js";
|
||||
export {
|
||||
runRedisOp,
|
||||
tryRedisOp,
|
||||
type UnavailableReason,
|
||||
} from "./runRedisOp.js";
|
||||
export { withRedisFailOpen } from "./withRedisFailOpen.js";
|
||||
@@ -1,16 +0,0 @@
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
export const getStripeCheckoutSession = async ({
|
||||
ctx,
|
||||
checkoutSessionId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
checkoutSessionId: string;
|
||||
}): Promise<Stripe.Checkout.Session> => {
|
||||
const { org, env } = ctx;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
return stripeCli.checkout.sessions.retrieve(checkoutSessionId);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { findSubscriptionItemByAutumnPrice } from "@/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice";
|
||||
|
||||
export const stripeSubscriptionItemUtils = {
|
||||
find: {
|
||||
byAutumnPrice: findSubscriptionItemByAutumnPrice,
|
||||
},
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
InternalError,
|
||||
isFixedPrice,
|
||||
type Price,
|
||||
type Product,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import type Stripe from "stripe";
|
||||
|
||||
type FindSubscriptionItemParams = {
|
||||
stripeSubscriptionItems: Stripe.SubscriptionItem[];
|
||||
price: Price;
|
||||
product: Product;
|
||||
};
|
||||
|
||||
// Overload: errorOnNotFound = true → guaranteed SubscriptionItem
|
||||
export function findSubscriptionItemByAutumnPrice(
|
||||
params: FindSubscriptionItemParams & { errorOnNotFound: true },
|
||||
): Stripe.SubscriptionItem;
|
||||
|
||||
// Overload: errorOnNotFound = false/undefined → SubscriptionItem | undefined
|
||||
export function findSubscriptionItemByAutumnPrice(
|
||||
params: FindSubscriptionItemParams & { errorOnNotFound?: false },
|
||||
): Stripe.SubscriptionItem | undefined;
|
||||
|
||||
// Implementation
|
||||
export function findSubscriptionItemByAutumnPrice({
|
||||
stripeSubscriptionItems,
|
||||
price,
|
||||
product,
|
||||
errorOnNotFound,
|
||||
}: FindSubscriptionItemParams & { errorOnNotFound?: boolean }):
|
||||
| Stripe.SubscriptionItem
|
||||
| undefined {
|
||||
const stripeProductId = product.processor?.id;
|
||||
|
||||
let result: Stripe.SubscriptionItem | undefined;
|
||||
|
||||
if (isFixedPrice(price)) {
|
||||
const config = price.config;
|
||||
|
||||
result = stripeSubscriptionItems.find((si) => {
|
||||
return (
|
||||
config.stripe_price_id === si.price?.id ||
|
||||
(stripeProductId && si.price?.product === stripeProductId)
|
||||
);
|
||||
});
|
||||
} else {
|
||||
const config = price.config as UsagePriceConfig;
|
||||
result = stripeSubscriptionItems.find(
|
||||
(si: Stripe.SubscriptionItem | Stripe.LineItem) => {
|
||||
return (
|
||||
config.stripe_price_id === si.price?.id ||
|
||||
config.stripe_product_id === si.price?.product ||
|
||||
config.stripe_empty_price_id === si.price?.id ||
|
||||
config.stripe_prepaid_price_v2_id === si.price?.id
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (errorOnNotFound && !result) {
|
||||
throw new InternalError({
|
||||
message: `Stripe subscription item not found for price: ${price.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { ACTIVE_STATUSES } from "@autumn/shared";
|
||||
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import {
|
||||
submitBillingDataToVercel,
|
||||
submitInvoiceToVercel,
|
||||
} from "@/external/vercel/misc/vercelInvoicing.js";
|
||||
import { logVercelWebhook } from "@/external/vercel/misc/vercelMiddleware.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { stripeInvoiceToStripeSubscriptionId } from "../invoices/utils/convertStripeInvoice";
|
||||
import {
|
||||
getFullStripeInvoice,
|
||||
getStripeExpandedInvoice,
|
||||
} from "../stripeInvoiceUtils.js";
|
||||
import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js";
|
||||
|
||||
/**
|
||||
* Handles invoice.finalized webhook
|
||||
*
|
||||
* For regular invoices: Creates Autumn invoice records
|
||||
* For Vercel custom payment method invoices: Submits invoice to Vercel marketplace for payment processing
|
||||
*/
|
||||
export const handleInvoiceFinalized = async ({
|
||||
ctx,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
}) => {
|
||||
const { db, org, env, logger, stripeEvent, stripeCli, fullCustomer } = ctx;
|
||||
|
||||
const invoiceData = stripeEvent.data.object as Stripe.Invoice;
|
||||
|
||||
const invoice = await getFullStripeInvoice({
|
||||
stripeCli,
|
||||
stripeId: invoiceData.id!,
|
||||
});
|
||||
|
||||
const features = await FeatureService.list({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
const subId = stripeInvoiceToStripeSubscriptionId(invoice);
|
||||
|
||||
if (subId) {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
// Handle Vercel custom payment method invoices
|
||||
if (subId && invoice.amount_due > 0) {
|
||||
const subscription = await stripeCli.subscriptions.retrieve(subId);
|
||||
|
||||
const vercelInstallationId =
|
||||
subscription.metadata?.vercel_installation_id;
|
||||
const vercelBillingPlanId = subscription.metadata?.vercel_billing_plan_id;
|
||||
|
||||
if (
|
||||
vercelInstallationId &&
|
||||
vercelBillingPlanId &&
|
||||
subscription.default_payment_method
|
||||
) {
|
||||
const paymentMethod = await stripeCli.paymentMethods.retrieve(
|
||||
subscription.default_payment_method as string,
|
||||
);
|
||||
|
||||
// Only process if it's a custom payment method (Vercel)
|
||||
if (paymentMethod.type === "custom" && fullCustomer) {
|
||||
logVercelWebhook({
|
||||
logger,
|
||||
org,
|
||||
event: {
|
||||
type: "marketplace.invoice.finalized",
|
||||
id: invoice.id,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const product = await ProductService.getFull({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
idOrInternalId: vercelBillingPlanId,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
console.error("Product not found for Vercel billing plan", {
|
||||
billingPlanId: vercelBillingPlanId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit billing data to Vercel (detailed usage breakdown)
|
||||
await submitBillingDataToVercel({
|
||||
installationId: vercelInstallationId,
|
||||
invoice,
|
||||
customer: fullCustomer,
|
||||
product,
|
||||
});
|
||||
|
||||
// Submit invoice to Vercel
|
||||
await submitInvoiceToVercel({
|
||||
installationId: vercelInstallationId,
|
||||
invoice,
|
||||
customer: fullCustomer,
|
||||
product,
|
||||
org,
|
||||
features,
|
||||
});
|
||||
|
||||
// Do NOT report payment to Stripe here - we've only submitted the invoice to Vercel
|
||||
// Vercel will process payment asynchronously and send marketplace.invoice.paid webhook
|
||||
// handleMarketplaceInvoicePaid will then:
|
||||
// 1. Create cus_product (user gets access)
|
||||
// 2. Report payment as "guaranteed" to Stripe
|
||||
// 3. Attach payment record to invoice (marks it as paid)
|
||||
} catch (error) {
|
||||
logger.error("Failed to process Vercel invoice", {
|
||||
data: {
|
||||
error: String(error),
|
||||
invoiceId: invoice.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const expandedInvoice = await getStripeExpandedInvoice({
|
||||
stripeCli,
|
||||
stripeInvoiceId: invoice.id!,
|
||||
});
|
||||
|
||||
const activeProducts = await CusProductService.getByStripeSubId({
|
||||
db,
|
||||
stripeSubId: subId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
inStatuses: ACTIVE_STATUSES,
|
||||
});
|
||||
|
||||
if (activeProducts.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { handleStripeInvoiceFinalized } from "./handleStripeInvoiceFinalized";
|
||||
export type { InvoiceFinalizedContext } from "./setupInvoiceFinalizedContext";
|
||||
@@ -1,38 +0,0 @@
|
||||
import { type FullCusProduct, isPrepaidPrice } from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Convert cusProduct.options to feature_quantities with actual quantities
|
||||
* (multiplied by billingUnits for prepaid features)
|
||||
*/
|
||||
function cusProductToFeatureQuantities({
|
||||
cusProduct,
|
||||
}: {
|
||||
cusProduct: FullCusProduct;
|
||||
}) {
|
||||
return cusProduct.options.map((option) => {
|
||||
const cusPrice = cusProduct.customer_prices.find((cp) => {
|
||||
const cusEnt = cusProduct.customer_entitlements.find(
|
||||
(ce) =>
|
||||
ce.internal_feature_id === option.internal_feature_id ||
|
||||
ce.entitlement.feature_id === option.feature_id,
|
||||
);
|
||||
return (
|
||||
cusEnt &&
|
||||
cp.price.config.internal_feature_id ===
|
||||
cusEnt.entitlement.internal_feature_id
|
||||
);
|
||||
});
|
||||
|
||||
let quantity = option.quantity;
|
||||
|
||||
if (cusPrice && isPrepaidPrice(cusPrice.price)) {
|
||||
const billingUnits = cusPrice.price.config.billing_units ?? 1;
|
||||
quantity = option.quantity * billingUnits;
|
||||
}
|
||||
|
||||
return {
|
||||
feature_id: option.feature_id,
|
||||
quantity,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { AutumnBillingPlan } from "@autumn/shared";
|
||||
import type { MaterializedScheduledPhase } from "../utils/materializeScheduledPhases";
|
||||
|
||||
/** Merge immediate billing changes with future scheduled rows for Autumn execution. */
|
||||
export const buildCreateScheduleExecutionPlan = ({
|
||||
immediateAutumnBillingPlan,
|
||||
futureScheduledPhases,
|
||||
}: {
|
||||
immediateAutumnBillingPlan: AutumnBillingPlan;
|
||||
futureScheduledPhases: MaterializedScheduledPhase[];
|
||||
}): AutumnBillingPlan => ({
|
||||
...immediateAutumnBillingPlan,
|
||||
insertCustomerProducts: [
|
||||
...immediateAutumnBillingPlan.insertCustomerProducts,
|
||||
...futureScheduledPhases.flatMap((phase) => phase.customerProducts),
|
||||
],
|
||||
customPrices: [
|
||||
...(immediateAutumnBillingPlan.customPrices ?? []),
|
||||
...futureScheduledPhases.flatMap((phase) => phase.customPrices),
|
||||
],
|
||||
customEntitlements: [
|
||||
...(immediateAutumnBillingPlan.customEntitlements ?? []),
|
||||
...futureScheduledPhases.flatMap((phase) => phase.customEntitlements),
|
||||
],
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import {
|
||||
addDuration,
|
||||
BillingVersion,
|
||||
type CreateScheduleParamsV0,
|
||||
CusProductStatus,
|
||||
type FullCustomer,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
|
||||
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
|
||||
import { setupAttachProductContext } from "../../attach/setup/setupAttachProductContext";
|
||||
import { validateCreateSchedulePhasePlans } from "../errors/validateCreateSchedulePhasePlans";
|
||||
|
||||
export type MaterializedScheduledPhase = {
|
||||
starts_at: number;
|
||||
customerProducts: Awaited<ReturnType<typeof initFullCustomerProduct>>[];
|
||||
customPrices: NonNullable<
|
||||
Awaited<ReturnType<typeof setupAttachProductContext>>["customPrices"]
|
||||
>;
|
||||
customEntitlements: NonNullable<
|
||||
Awaited<ReturnType<typeof setupAttachProductContext>>["customEnts"]
|
||||
>;
|
||||
};
|
||||
|
||||
/** Build scheduled customer products for future phases. */
|
||||
export const materializeScheduledPhases = async ({
|
||||
ctx,
|
||||
currentEpochMs,
|
||||
fullCustomer,
|
||||
phases,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
currentEpochMs: number;
|
||||
fullCustomer: FullCustomer;
|
||||
phases: CreateScheduleParamsV0["phases"][number][];
|
||||
}): Promise<MaterializedScheduledPhase[]> => {
|
||||
return await Promise.all(
|
||||
phases.map(async (phase, index) => {
|
||||
const nextPhaseStartsAt = phases[index + 1]?.starts_at;
|
||||
const materializedProducts = await Promise.all(
|
||||
phase.plans.map(async (plan) => {
|
||||
const {
|
||||
fullProduct,
|
||||
customPrices = [],
|
||||
customEnts: customEntitlements = [],
|
||||
} = await setupAttachProductContext({
|
||||
ctx,
|
||||
params: plan,
|
||||
});
|
||||
const trialEndsAt = fullProduct.free_trial
|
||||
? addDuration({
|
||||
now: phase.starts_at,
|
||||
durationType: fullProduct.free_trial.duration,
|
||||
durationLength: fullProduct.free_trial.length,
|
||||
})
|
||||
: undefined;
|
||||
const featureQuantities = setupFeatureQuantitiesContext({
|
||||
ctx,
|
||||
featureQuantitiesParams: {
|
||||
feature_quantities: plan.feature_quantities,
|
||||
},
|
||||
fullProduct,
|
||||
initializeUndefinedQuantities: true,
|
||||
});
|
||||
|
||||
return {
|
||||
fullProduct,
|
||||
customerProduct: initFullCustomerProduct({
|
||||
ctx,
|
||||
initContext: {
|
||||
fullCustomer,
|
||||
fullProduct,
|
||||
featureQuantities,
|
||||
resetCycleAnchor: phase.starts_at,
|
||||
freeTrial: fullProduct.free_trial ?? null,
|
||||
trialEndsAt,
|
||||
now: currentEpochMs,
|
||||
billingVersion: BillingVersion.V2,
|
||||
},
|
||||
initOptions: {
|
||||
startsAt: phase.starts_at,
|
||||
endedAt: nextPhaseStartsAt,
|
||||
status: CusProductStatus.Scheduled,
|
||||
isCustom:
|
||||
customPrices.length > 0 || customEntitlements.length > 0,
|
||||
},
|
||||
}),
|
||||
customPrices,
|
||||
customEntitlements,
|
||||
};
|
||||
}),
|
||||
);
|
||||
validateCreateSchedulePhasePlans({
|
||||
fullProducts: materializedProducts.map(
|
||||
({ fullProduct }) => fullProduct,
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
starts_at: phase.starts_at,
|
||||
customerProducts: materializedProducts.map(
|
||||
({ customerProduct }) => customerProduct,
|
||||
),
|
||||
customPrices: materializedProducts.flatMap(
|
||||
({ customPrices }) => customPrices,
|
||||
),
|
||||
customEntitlements: materializedProducts.flatMap(
|
||||
({ customEntitlements }) => customEntitlements,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { getTestClockFrozenTimeMs } from "@/external/stripe/testClocks/utils/convertStripeTestClock";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
|
||||
/** Resolves "now" for schedule operations, respecting Stripe test clocks in sandbox. */
|
||||
export const resolveCurrentEpochMs = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
}): Promise<number> => {
|
||||
const customer = await CusService.get({
|
||||
db: ctx.db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
const testClockMs = await getTestClockFrozenTimeMs({
|
||||
ctx,
|
||||
stripeCustomerId: customer?.processor?.id,
|
||||
});
|
||||
return testClockMs ?? Date.now();
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
customerPriceToBillingUnits,
|
||||
type FullCustomerEntitlement,
|
||||
type FullCustomerPrice,
|
||||
priceToProrationConfig,
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
/**
|
||||
* Calculates the entitlement balance change resulting from a quantity update.
|
||||
*
|
||||
* Computes balance change as: quantity_difference × billing_units_per_quantity.
|
||||
* Returns entitlement ID from price association, or undefined if price has no entitlement.
|
||||
*
|
||||
* @param quantityDifferenceForEntitlements - Change in quantity (can be negative)
|
||||
* @param billingUnitsPerQuantity - Multiplier for converting quantities to usage units
|
||||
* @param customerPrice - Customer's price configuration
|
||||
* @param customerEntitlements - Array of all entitlements for this customer product
|
||||
* @returns Entitlement ID and balance change to apply
|
||||
*/
|
||||
export const calculateUpdateQuantityEntitlementChange = ({
|
||||
quantityDifferenceForEntitlements,
|
||||
customerPrice,
|
||||
customerEntitlement,
|
||||
}: {
|
||||
quantityDifferenceForEntitlements: number;
|
||||
customerPrice: FullCustomerPrice;
|
||||
customerEntitlement: FullCustomerEntitlement;
|
||||
}): {
|
||||
customerEntitlementId: string;
|
||||
customerEntitlementBalanceChange: number;
|
||||
} => {
|
||||
const isUpgrade = quantityDifferenceForEntitlements > 0;
|
||||
|
||||
const { shouldApplyProration } = priceToProrationConfig({
|
||||
price: customerPrice.price,
|
||||
isUpgrade,
|
||||
});
|
||||
|
||||
// If downgrade and no proration, don't change entitlement balance THIS cycle
|
||||
if (!isUpgrade && !shouldApplyProration) {
|
||||
return {
|
||||
customerEntitlementId: customerEntitlement?.id,
|
||||
customerEntitlementBalanceChange: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const billingUnits = customerPriceToBillingUnits({ customerPrice });
|
||||
const customerEntitlementBalanceChange = new Decimal(
|
||||
quantityDifferenceForEntitlements,
|
||||
)
|
||||
.mul(billingUnits)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
customerEntitlementId: customerEntitlement?.id,
|
||||
customerEntitlementBalanceChange,
|
||||
};
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { AutumnBillingPlan } from "@autumn/shared";
|
||||
import {
|
||||
cusProductToPrices,
|
||||
ErrCode,
|
||||
isOneOffPrice,
|
||||
isPrepaidPrice,
|
||||
RecaseError,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const handleFeatureQuantityErrors = ({
|
||||
autumnBillingPlan,
|
||||
}: {
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
}) => {
|
||||
const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0];
|
||||
if (!newCustomerProduct) return;
|
||||
|
||||
const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct });
|
||||
const prepaidPrices = newPrices.filter(
|
||||
(p) => isPrepaidPrice(p) && !isOneOffPrice(p),
|
||||
);
|
||||
|
||||
if (prepaidPrices.length === 0) return;
|
||||
|
||||
const options = newCustomerProduct.options || [];
|
||||
const missingFeatures: string[] = [];
|
||||
|
||||
for (const price of prepaidPrices) {
|
||||
const config = price.config as UsagePriceConfig;
|
||||
const internalFeatureId = config.internal_feature_id;
|
||||
|
||||
// Check if there's an option for this prepaid price
|
||||
const hasOption = options.some(
|
||||
(opt) => opt.internal_feature_id === internalFeatureId,
|
||||
);
|
||||
|
||||
if (!hasOption) {
|
||||
// Try to find the feature_id from customer_entitlements
|
||||
const cusEnt = newCustomerProduct.customer_entitlements?.find(
|
||||
(ce) => ce.entitlement.internal_feature_id === internalFeatureId,
|
||||
);
|
||||
const featureId = cusEnt?.entitlement.feature_id || internalFeatureId;
|
||||
missingFeatures.push(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFeatures.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Missing quantity options for prepaid features: ${missingFeatures.join(", ")}`,
|
||||
code: ErrCode.InvalidOptions,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { BillingVersion, type FullCustomer } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/** Build a minimal BillingContext with just the fields createInvoiceForBilling needs. */
|
||||
export const buildMinimalBillingContext = ({
|
||||
fullCustomer,
|
||||
stripeCustomerId,
|
||||
paymentMethod,
|
||||
}: {
|
||||
fullCustomer: FullCustomer;
|
||||
stripeCustomerId: string;
|
||||
paymentMethod: Stripe.PaymentMethod;
|
||||
}) => ({
|
||||
fullCustomer,
|
||||
fullProducts: [],
|
||||
featureQuantities: [],
|
||||
currentEpochMs: Date.now(),
|
||||
billingCycleAnchorMs: "now" as const,
|
||||
resetCycleAnchorMs: "now" as const,
|
||||
stripeCustomer: { id: stripeCustomerId } as Stripe.Customer,
|
||||
paymentMethod,
|
||||
billingVersion: BillingVersion.V2,
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { BillingContext, BillingPlan } from "@autumn/shared";
|
||||
import {
|
||||
type CheckoutLineV0,
|
||||
type CheckoutResponseV0,
|
||||
CheckoutResponseV0Schema,
|
||||
orgToCurrency,
|
||||
toProductItem,
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils";
|
||||
import {
|
||||
getProductItemResponse,
|
||||
getProductResponse,
|
||||
} from "@/internal/products/productUtils/productResponseUtils/getProductResponse";
|
||||
import { notNullish } from "@/utils/genUtils";
|
||||
import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview";
|
||||
|
||||
export const billingContextToCheckoutResponse = async ({
|
||||
ctx,
|
||||
billingContext,
|
||||
billingPlan,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: BillingContext;
|
||||
billingPlan: BillingPlan;
|
||||
}): Promise<CheckoutResponseV0> => {
|
||||
const { fullCustomer, fullProducts, featureQuantities } = billingContext;
|
||||
const { features, org } = ctx;
|
||||
const currency = orgToCurrency({ org });
|
||||
|
||||
// 1. Get primary product (first non-add-on or first product)
|
||||
const mainProduct = fullProducts.find((p) => !p.is_add_on) ?? fullProducts[0];
|
||||
|
||||
const product = mainProduct
|
||||
? await getProductResponse({
|
||||
product: mainProduct,
|
||||
features,
|
||||
fullCus: fullCustomer,
|
||||
currency,
|
||||
db: ctx.db,
|
||||
options: featureQuantities,
|
||||
})
|
||||
: null;
|
||||
|
||||
// 2. Build line items from billing plan
|
||||
const planLineItems = billingPlan.autumn.lineItems ?? [];
|
||||
|
||||
// Collect all prices and entitlements from products for lookup
|
||||
const allPrices = fullProducts.flatMap((p) => p.prices);
|
||||
const allEnts = fullProducts.flatMap((p) => p.entitlements);
|
||||
|
||||
const lines: CheckoutLineV0[] = planLineItems
|
||||
.filter((line) => line.chargeImmediately)
|
||||
.map((line) => {
|
||||
const { price } = line.context;
|
||||
|
||||
// Find entitlement for this price
|
||||
const ent = getPriceEntitlement(price, allEnts);
|
||||
|
||||
// Build product item from price + entitlement
|
||||
const productItem = toProductItem({ ent, price });
|
||||
|
||||
return {
|
||||
description: line.description,
|
||||
amount: line.amountAfterDiscounts,
|
||||
item: getProductItemResponse({
|
||||
item: productItem,
|
||||
features,
|
||||
currency,
|
||||
withDisplay: true,
|
||||
options: featureQuantities,
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter(notNullish);
|
||||
|
||||
// 3. Calculate total
|
||||
const total = new Decimal(lines.reduce((acc, line) => acc + line.amount, 0))
|
||||
.toDecimalPlaces(2)
|
||||
.toNumber();
|
||||
|
||||
// 4. Get next cycle preview
|
||||
const { nextCycle } = billingPlanToNextCyclePreview({
|
||||
ctx,
|
||||
billingContext,
|
||||
billingPlan,
|
||||
});
|
||||
|
||||
// 5. Build options from feature quantities
|
||||
const options = featureQuantities
|
||||
.map((fq) => {
|
||||
const price = allPrices.find(
|
||||
(p) =>
|
||||
p.config &&
|
||||
"feature_id" in p.config &&
|
||||
(p.config.feature_id === fq.feature_id ||
|
||||
p.config.internal_feature_id === fq.internal_feature_id),
|
||||
);
|
||||
|
||||
if (!price) return undefined;
|
||||
|
||||
const billingUnits =
|
||||
price.config && "billing_units" in price.config
|
||||
? price.config.billing_units || 1
|
||||
: 1;
|
||||
|
||||
return {
|
||||
feature_id: fq.feature_id,
|
||||
quantity: fq.quantity * billingUnits,
|
||||
};
|
||||
})
|
||||
.filter(notNullish);
|
||||
|
||||
return CheckoutResponseV0Schema.parse({
|
||||
customer_id: fullCustomer.id || fullCustomer.internal_id,
|
||||
product,
|
||||
current_product: null,
|
||||
lines,
|
||||
options,
|
||||
total,
|
||||
currency,
|
||||
next_cycle: nextCycle,
|
||||
});
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { cp } from "@autumn/shared";
|
||||
import type { AutumnBillingPlan } from "@autumn/shared";
|
||||
|
||||
export const billingPlanToNewActiveCustomerProduct = ({
|
||||
autumnBillingPlan,
|
||||
}: {
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
}) => {
|
||||
return autumnBillingPlan.insertCustomerProducts?.find(
|
||||
(customerProduct) => cp(customerProduct).hasActiveStatus().valid,
|
||||
);
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import type {
|
||||
InsertDbInvoiceLineItem,
|
||||
InvoiceLineItemDiscount,
|
||||
LineItem,
|
||||
} from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Helper for full match case - converts an Autumn LineItem to InsertInvoiceLineItem.
|
||||
*/
|
||||
export const billingLineItemToInsertDbLineItem = ({
|
||||
lineItem,
|
||||
invoiceId,
|
||||
stripeInvoiceId,
|
||||
stripeLineItemId,
|
||||
}: {
|
||||
lineItem: LineItem;
|
||||
invoiceId: string;
|
||||
stripeInvoiceId: string;
|
||||
stripeLineItemId?: string;
|
||||
}): InsertDbInvoiceLineItem => {
|
||||
const { context } = lineItem;
|
||||
|
||||
return {
|
||||
id: lineItem.id,
|
||||
invoice_id: invoiceId,
|
||||
stripe_id: stripeLineItemId ?? null,
|
||||
stripe_invoice_id: stripeInvoiceId,
|
||||
stripe_product_id: lineItem.stripeProductId ?? null,
|
||||
stripe_price_id: lineItem.stripePriceId ?? null,
|
||||
stripe_discountable: context.discountable ?? true,
|
||||
|
||||
amount: lineItem.amount,
|
||||
amount_after_discounts: lineItem.amountAfterDiscounts,
|
||||
currency: context.currency,
|
||||
|
||||
total_quantity: lineItem.totalQuantity ?? null,
|
||||
paid_quantity: lineItem.paidQuantity ?? null,
|
||||
|
||||
description: lineItem.description,
|
||||
direction: context.direction,
|
||||
billing_timing: context.billingTiming,
|
||||
prorated: lineItem.prorated,
|
||||
|
||||
price_id: context.price.id,
|
||||
customer_product_ids: context.customerProduct?.id
|
||||
? [context.customerProduct.id]
|
||||
: [],
|
||||
customer_price_ids: context.customerPrice?.id
|
||||
? [context.customerPrice.id]
|
||||
: [],
|
||||
customer_entitlement_ids: context.customerEntitlement?.id
|
||||
? [context.customerEntitlement.id]
|
||||
: [],
|
||||
internal_product_id: context.product.internal_id,
|
||||
product_id: context.product.id,
|
||||
internal_feature_id: context.feature?.internal_id ?? null,
|
||||
feature_id: context.feature?.id ?? null,
|
||||
|
||||
effective_period_start: context.effectivePeriod?.start ?? null,
|
||||
effective_period_end: context.effectivePeriod?.end ?? null,
|
||||
|
||||
discounts: lineItem.discounts.map(
|
||||
(d): InvoiceLineItemDiscount => ({
|
||||
amount_off: d.amountOff,
|
||||
percent_off: d.percentOff,
|
||||
stripe_coupon_id: d.stripeCouponId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import type {
|
||||
BillingPreviewResponse,
|
||||
FullCusProduct,
|
||||
LineItem,
|
||||
} from "@autumn/shared";
|
||||
import { formatMs } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
|
||||
import type { NextCyclePreviewDebug } from "../billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview";
|
||||
|
||||
const formatCustomerProduct = (customerProduct: FullCusProduct) =>
|
||||
`${customerProduct.product.name} (${customerProduct.product_id}) [${customerProduct.status}]`;
|
||||
|
||||
const formatLineItem = (item: LineItem) =>
|
||||
`${item.description}: ${item.amountAfterDiscounts} (charge: ${item.chargeImmediately})`;
|
||||
|
||||
export const logBillingPreview = ({
|
||||
ctx,
|
||||
allLineItems,
|
||||
immediateLineItems,
|
||||
total,
|
||||
currency,
|
||||
nextCycleDebug,
|
||||
nextCycle,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
allLineItems: LineItem[];
|
||||
immediateLineItems: LineItem[];
|
||||
total: number;
|
||||
currency: string;
|
||||
nextCycleDebug: NextCyclePreviewDebug;
|
||||
nextCycle: BillingPreviewResponse["next_cycle"];
|
||||
}) => {
|
||||
const {
|
||||
allCustomerProducts,
|
||||
currentCustomerProducts,
|
||||
smallestInterval,
|
||||
anchorMs,
|
||||
nextCycleStart,
|
||||
filteredCustomerProducts,
|
||||
} = nextCycleDebug;
|
||||
|
||||
addToExtraLogs({
|
||||
ctx,
|
||||
extras: {
|
||||
billingPreview: {
|
||||
// Immediate charge breakdown
|
||||
total: `${total} ${currency}`,
|
||||
allLineItems:
|
||||
allLineItems.length > 0 ? allLineItems.map(formatLineItem) : "none",
|
||||
immediateLineItems:
|
||||
immediateLineItems.length > 0
|
||||
? immediateLineItems.map(formatLineItem)
|
||||
: "none",
|
||||
|
||||
// Next cycle calculation
|
||||
nextCycle: {
|
||||
allCustomerProducts:
|
||||
allCustomerProducts.map(formatCustomerProduct).join(", ") || "none",
|
||||
currentCustomerProducts:
|
||||
currentCustomerProducts.map(formatCustomerProduct).join(", ") ||
|
||||
"none",
|
||||
smallestInterval: smallestInterval
|
||||
? `${smallestInterval.intervalCount} ${smallestInterval.interval}`
|
||||
: "none (not a subscription)",
|
||||
anchor: formatMs(anchorMs),
|
||||
nextCycleStart: nextCycleStart ? formatMs(nextCycleStart) : "n/a",
|
||||
filteredCustomerProducts:
|
||||
filteredCustomerProducts.map(formatCustomerProduct).join(", ") ||
|
||||
"none",
|
||||
result: nextCycle
|
||||
? `starts: ${formatMs(nextCycle.starts_at)} | total: ${nextCycle.total} | items: ${nextCycle.line_items.length}`
|
||||
: "undefined",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,197 +0,0 @@
|
||||
import {
|
||||
type AttachConfig,
|
||||
calculateProrationAmount,
|
||||
cusProductToProduct,
|
||||
customerPriceToCustomerEntitlement,
|
||||
type Feature,
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
findCusPriceByFeature,
|
||||
getFeatureInvoiceDescription,
|
||||
OnDecrease,
|
||||
priceToInvoiceAmount,
|
||||
shouldBillNow,
|
||||
shouldProrate,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { Stripe } from "stripe";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
|
||||
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
|
||||
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
|
||||
export const handleQuantityDowngrade = async ({
|
||||
ctx,
|
||||
attachParams,
|
||||
attachConfig,
|
||||
cusProduct,
|
||||
stripeSub,
|
||||
oldOptions,
|
||||
newOptions,
|
||||
subItem,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
attachParams: AttachParams;
|
||||
attachConfig: AttachConfig;
|
||||
cusProduct: FullCusProduct;
|
||||
stripeSub: Stripe.Subscription;
|
||||
oldOptions: FeatureOptions;
|
||||
newOptions: FeatureOptions;
|
||||
subItem: Stripe.SubscriptionItem;
|
||||
}) => {
|
||||
const { db, logger, org, features } = ctx;
|
||||
const { stripeCli, paymentMethod } = attachParams;
|
||||
|
||||
const cusPrice = findCusPriceByFeature({
|
||||
internalFeatureId: newOptions.internal_feature_id!,
|
||||
cusPrices: cusProduct.customer_prices,
|
||||
})!;
|
||||
|
||||
const onDecrease =
|
||||
cusPrice.price.proration_config?.on_decrease ||
|
||||
OnDecrease.ProrateImmediately;
|
||||
|
||||
const subItemDifference = new Decimal(newOptions.quantity)
|
||||
.minus(
|
||||
notNullish(oldOptions.upcoming_quantity)
|
||||
? oldOptions.upcoming_quantity!
|
||||
: oldOptions.quantity,
|
||||
)
|
||||
.toNumber();
|
||||
|
||||
const billingUnits =
|
||||
(cusPrice.price.config as UsagePriceConfig).billing_units || 1;
|
||||
|
||||
const newSubItemQuantity = new Decimal(subItem.quantity || 0)
|
||||
.plus(subItemDifference)
|
||||
.toNumber();
|
||||
|
||||
let invoice = null;
|
||||
const createDowngradeInvoice = async () => {
|
||||
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
|
||||
|
||||
const prevAmount = priceToInvoiceAmount({
|
||||
price: cusPrice.price,
|
||||
quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(),
|
||||
});
|
||||
|
||||
const newAmount = priceToInvoiceAmount({
|
||||
price: cusPrice.price,
|
||||
quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(),
|
||||
});
|
||||
|
||||
let amount = new Decimal(newAmount).minus(prevAmount).toNumber();
|
||||
|
||||
amount = calculateProrationAmount({
|
||||
periodEnd: end * 1000,
|
||||
periodStart: start * 1000,
|
||||
now: attachParams.now || Date.now(),
|
||||
amount,
|
||||
allowNegative: true,
|
||||
});
|
||||
|
||||
const product = cusProductToProduct({ cusProduct });
|
||||
const feature = features.find(
|
||||
(f: Feature) => f.internal_id === newOptions.internal_feature_id,
|
||||
)!;
|
||||
const invoiceItem = constructStripeInvoiceItem({
|
||||
ctx,
|
||||
product,
|
||||
amount: amount,
|
||||
price: cusPrice.price,
|
||||
description: getFeatureInvoiceDescription({
|
||||
feature: feature,
|
||||
usage: newOptions.quantity,
|
||||
billingUnits: (cusPrice.price.config as UsagePriceConfig).billing_units,
|
||||
prodName: product.name,
|
||||
isPrepaid: true,
|
||||
fromUnix: attachParams.now,
|
||||
}),
|
||||
stripeSubId: stripeSub.id,
|
||||
stripeCustomerId: stripeSub.customer as string,
|
||||
periodStart: Math.floor(
|
||||
attachParams.now ? attachParams.now / 1000 : Date.now(),
|
||||
),
|
||||
periodEnd: Math.floor(end * 1000),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`🔥 Creating downgrade prepaid invoice item: ${invoiceItem.description} - ${amount}`,
|
||||
);
|
||||
|
||||
await stripeCli.invoiceItems.create(invoiceItem);
|
||||
|
||||
if (shouldBillNow(onDecrease)) {
|
||||
const { invoice: finalInvoice } = await createAndFinalizeInvoice({
|
||||
stripeCli,
|
||||
stripeCusId: stripeSub.customer as string,
|
||||
stripeSubId: stripeSub.id,
|
||||
paymentMethod: paymentMethod || null,
|
||||
chargeAutomatically: !attachConfig.invoiceOnly,
|
||||
logger,
|
||||
});
|
||||
|
||||
invoice = finalInvoice;
|
||||
|
||||
try {
|
||||
const invoiceItems = await getInvoiceItems({
|
||||
stripeInvoice: finalInvoice,
|
||||
prices: [cusPrice.price],
|
||||
logger,
|
||||
});
|
||||
|
||||
await InvoiceService.createInvoiceFromStripe({
|
||||
db,
|
||||
stripeInvoice: finalInvoice,
|
||||
internalCustomerId: cusProduct.internal_customer_id!,
|
||||
internalEntityId: cusProduct.internal_entity_id,
|
||||
productIds: [cusProduct.product_id],
|
||||
internalProductIds: [cusProduct.internal_product_id],
|
||||
org,
|
||||
sendRevenueEvent: true,
|
||||
items: invoiceItems,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create invoice from stripe: ${error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await stripeCli.subscriptionItems.update(subItem.id, {
|
||||
quantity: Math.max(newSubItemQuantity, 0),
|
||||
// proration_behavior: stripeProration,
|
||||
proration_behavior: "none",
|
||||
});
|
||||
|
||||
if (!shouldProrate(onDecrease)) {
|
||||
newOptions.upcoming_quantity = newOptions.quantity;
|
||||
newOptions.quantity = oldOptions.quantity;
|
||||
return;
|
||||
}
|
||||
|
||||
await createDowngradeInvoice();
|
||||
|
||||
const cusEnt = customerPriceToCustomerEntitlement({
|
||||
customerPrice: cusPrice,
|
||||
customerEntitlements: cusProduct.customer_entitlements,
|
||||
});
|
||||
|
||||
if (cusEnt) {
|
||||
const decrementBy = new Decimal(oldOptions.quantity)
|
||||
.minus(new Decimal(newOptions.quantity))
|
||||
.mul(billingUnits)
|
||||
.toNumber();
|
||||
|
||||
await CusEntService.decrement({
|
||||
ctx,
|
||||
id: cusEnt.id,
|
||||
amount: decrementBy,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
import {
|
||||
type AttachConfig,
|
||||
calculateProrationAmount,
|
||||
cusProductToProduct,
|
||||
customerPriceToCustomerEntitlement,
|
||||
type Feature,
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
type FullCustomerPrice,
|
||||
getFeatureInvoiceDescription,
|
||||
OnIncrease,
|
||||
priceToInvoiceAmount,
|
||||
shouldBillNow,
|
||||
shouldProrate,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { Stripe } from "stripe";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
|
||||
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
|
||||
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
|
||||
export const handleQuantityUpgrade = async ({
|
||||
ctx,
|
||||
attachParams,
|
||||
cusProduct,
|
||||
stripeSubs,
|
||||
attachConfig,
|
||||
oldOptions,
|
||||
newOptions,
|
||||
cusPrice,
|
||||
stripeSub,
|
||||
subItem,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
attachParams: AttachParams;
|
||||
cusProduct: FullCusProduct;
|
||||
attachConfig: AttachConfig;
|
||||
stripeSubs: Stripe.Subscription[];
|
||||
oldOptions: FeatureOptions;
|
||||
newOptions: FeatureOptions;
|
||||
cusPrice: FullCustomerPrice;
|
||||
stripeSub: Stripe.Subscription;
|
||||
subItem: Stripe.SubscriptionItem;
|
||||
}) => {
|
||||
// Manually calculate prorations...
|
||||
const { features, org, logger, db } = ctx;
|
||||
const { stripeCli, now, paymentMethod } = attachParams;
|
||||
|
||||
const difference = new Decimal(newOptions.quantity)
|
||||
.minus(oldOptions.quantity)
|
||||
.toNumber();
|
||||
|
||||
const subItemDifference = new Decimal(newOptions.quantity)
|
||||
.minus(
|
||||
notNullish(oldOptions.upcoming_quantity)
|
||||
? oldOptions.upcoming_quantity!
|
||||
: oldOptions.quantity,
|
||||
)
|
||||
.toNumber();
|
||||
|
||||
const onIncrease =
|
||||
cusPrice.price.proration_config?.on_increase ||
|
||||
OnIncrease.ProrateImmediately;
|
||||
|
||||
const prorate = shouldProrate(onIncrease);
|
||||
const config = cusPrice.price.config as UsagePriceConfig;
|
||||
const billingUnits = config.billing_units || 1;
|
||||
|
||||
let invoice = null;
|
||||
if (prorate && stripeSub?.status !== "trialing") {
|
||||
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
|
||||
|
||||
const prevAmount = priceToInvoiceAmount({
|
||||
price: cusPrice.price,
|
||||
quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(),
|
||||
});
|
||||
|
||||
const newAmount = priceToInvoiceAmount({
|
||||
price: cusPrice.price,
|
||||
quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(),
|
||||
});
|
||||
|
||||
let amount = new Decimal(newAmount).minus(prevAmount).toNumber();
|
||||
if (prorate) {
|
||||
amount = calculateProrationAmount({
|
||||
periodEnd: end * 1000,
|
||||
periodStart: start * 1000,
|
||||
now: now || Date.now(),
|
||||
amount,
|
||||
});
|
||||
}
|
||||
|
||||
const feature = features.find(
|
||||
(f: Feature) => f.internal_id === newOptions.internal_feature_id,
|
||||
)!;
|
||||
|
||||
const product = cusProductToProduct({ cusProduct });
|
||||
const invoiceItem = constructStripeInvoiceItem({
|
||||
ctx,
|
||||
product,
|
||||
amount: amount,
|
||||
price: cusPrice.price,
|
||||
description: getFeatureInvoiceDescription({
|
||||
feature: feature,
|
||||
usage: newOptions.quantity,
|
||||
billingUnits,
|
||||
prodName: product.name,
|
||||
isPrepaid: true,
|
||||
fromUnix: now,
|
||||
}),
|
||||
stripeSubId: stripeSub.id,
|
||||
stripeCustomerId: stripeSub.customer as string,
|
||||
periodStart: Math.floor((now || Date.now()) / 1000),
|
||||
periodEnd: Math.floor(end * 1000),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`🔥 Creating prepaid invoice item: ${invoiceItem.description} - ${amount}`,
|
||||
);
|
||||
|
||||
await stripeCli.invoiceItems.create(invoiceItem);
|
||||
|
||||
if (shouldBillNow(onIncrease)) {
|
||||
const { invoice: finalInvoice } = await createAndFinalizeInvoice({
|
||||
stripeCli,
|
||||
stripeCusId: stripeSub.customer as string,
|
||||
stripeSubId: stripeSub.id,
|
||||
paymentMethod: paymentMethod || null,
|
||||
chargeAutomatically: !attachConfig.invoiceOnly,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const invoiceItems = await getInvoiceItems({
|
||||
stripeInvoice: finalInvoice,
|
||||
prices: [cusPrice.price],
|
||||
logger,
|
||||
});
|
||||
|
||||
await InvoiceService.createInvoiceFromStripe({
|
||||
db,
|
||||
stripeInvoice: finalInvoice,
|
||||
internalCustomerId: cusProduct.internal_customer_id!,
|
||||
internalEntityId: cusProduct.internal_entity_id,
|
||||
productIds: [cusProduct.product_id],
|
||||
internalProductIds: [cusProduct.internal_product_id],
|
||||
org,
|
||||
sendRevenueEvent: true,
|
||||
items: invoiceItems,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create invoice from stripe: ${error}`);
|
||||
}
|
||||
invoice = finalInvoice;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. If no sub item, add
|
||||
if (!subItem) {
|
||||
if (!cusPrice.price.config.stripe_price_id) {
|
||||
throw new Error(
|
||||
"Trying to add new sub item for upgrade quantity flow, but no Stripe price ID found",
|
||||
);
|
||||
}
|
||||
|
||||
await stripeCli.subscriptionItems.create({
|
||||
subscription: stripeSub.id,
|
||||
price: cusPrice.price.config.stripe_price_id,
|
||||
quantity: subItemDifference,
|
||||
proration_behavior: "none",
|
||||
});
|
||||
} else {
|
||||
await stripeCli.subscriptionItems.update(subItem.id, {
|
||||
// quantity: newOptions.quantity,
|
||||
quantity: (subItem.quantity || 0) + subItemDifference,
|
||||
proration_behavior: "none",
|
||||
});
|
||||
}
|
||||
|
||||
// Update cus ent
|
||||
|
||||
const cusEnt = customerPriceToCustomerEntitlement({
|
||||
customerPrice: cusPrice,
|
||||
customerEntitlements: cusProduct.customer_entitlements,
|
||||
});
|
||||
|
||||
if (cusEnt) {
|
||||
const incrementBy = new Decimal(difference).mul(billingUnits).toNumber();
|
||||
logger.info(
|
||||
`🔥 Incrementing feature ${cusEnt.entitlement.feature.id} balance by ${incrementBy}`,
|
||||
);
|
||||
await CusEntService.increment({
|
||||
ctx,
|
||||
id: cusEnt.id,
|
||||
amount: incrementBy,
|
||||
});
|
||||
}
|
||||
return { invoice };
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import {
|
||||
type AttachConfig,
|
||||
ErrCode,
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
findCusPriceByFeature,
|
||||
} from "@autumn/shared";
|
||||
import type { Stripe } from "stripe";
|
||||
import { stripeSubscriptionItemUtils } from "@/external/stripe/subscriptions/subscriptionItems/index.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
|
||||
import { handleQuantityDowngrade } from "./handleQuantityDowngrade.js";
|
||||
import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js";
|
||||
|
||||
export const handleUpdateFeatureQuantity = async ({
|
||||
ctx,
|
||||
attachParams,
|
||||
attachConfig,
|
||||
cusProduct,
|
||||
stripeSubs,
|
||||
oldOptions,
|
||||
newOptions,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
attachParams: AttachParams;
|
||||
attachConfig: AttachConfig;
|
||||
cusProduct: FullCusProduct;
|
||||
stripeSubs: Stripe.Subscription[];
|
||||
oldOptions: FeatureOptions;
|
||||
newOptions: FeatureOptions;
|
||||
}) => {
|
||||
const subToUpdate = stripeSubs?.[0];
|
||||
|
||||
const cusPrice = findCusPriceByFeature({
|
||||
internalFeatureId: newOptions.internal_feature_id!,
|
||||
cusPrices: cusProduct.customer_prices,
|
||||
})!;
|
||||
|
||||
const price = cusPrice.price;
|
||||
|
||||
if (!subToUpdate) {
|
||||
throw new RecaseError({
|
||||
message: `Failed to update prepaid quantity for ${newOptions.feature_id} because no subscription found`,
|
||||
code: ErrCode.InternalError,
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const subItem = stripeSubscriptionItemUtils.find.byAutumnPrice({
|
||||
stripeSubscriptionItems: subToUpdate.items.data,
|
||||
price,
|
||||
product: cusProduct.product,
|
||||
errorOnNotFound: true,
|
||||
});
|
||||
|
||||
if (newOptions.quantity < oldOptions.quantity) {
|
||||
return await handleQuantityDowngrade({
|
||||
ctx,
|
||||
attachParams,
|
||||
attachConfig,
|
||||
cusProduct,
|
||||
stripeSub: subToUpdate,
|
||||
oldOptions,
|
||||
newOptions,
|
||||
subItem,
|
||||
});
|
||||
} else {
|
||||
return await handleQuantityUpgrade({
|
||||
ctx,
|
||||
attachParams,
|
||||
attachConfig,
|
||||
cusProduct,
|
||||
stripeSubs,
|
||||
oldOptions,
|
||||
newOptions,
|
||||
cusPrice,
|
||||
stripeSub: subToUpdate,
|
||||
subItem,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { AttachConfig, FullCusProduct } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
||||
import type { AttachParams } from "../../cusProducts/AttachParams.js";
|
||||
import { CusProductService } from "../../cusProducts/CusProductService.js";
|
||||
import { paramsToScheduleItems } from "./paramsToScheduleItems.js";
|
||||
import { getCusProductsToRemove } from "./paramsToSubItems.js";
|
||||
|
||||
export const subToNewSchedule = async ({
|
||||
ctx,
|
||||
sub,
|
||||
attachParams,
|
||||
config,
|
||||
endOfBillingPeriod,
|
||||
removeCusProducts,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
sub: Stripe.Subscription;
|
||||
attachParams: AttachParams;
|
||||
config: AttachConfig;
|
||||
endOfBillingPeriod: number;
|
||||
removeCusProducts?: FullCusProduct[];
|
||||
}) => {
|
||||
const itemSet = await getStripeSubItems2({
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
|
||||
let cusProductsToRemove: FullCusProduct[] = [];
|
||||
cusProductsToRemove = getCusProductsToRemove({
|
||||
attachParams,
|
||||
includeCanceled: true,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`REMOVING CUS PRODUCTS: ${cusProductsToRemove.map((cp) => `${cp.product.id} (E: ${cp.entity_id})`).join(", ")}`,
|
||||
);
|
||||
|
||||
const res = await paramsToScheduleItems({
|
||||
ctx,
|
||||
sub,
|
||||
attachParams,
|
||||
config,
|
||||
removeCusProducts: removeCusProducts || cusProductsToRemove,
|
||||
billingPeriodEnd: endOfBillingPeriod,
|
||||
});
|
||||
|
||||
const { stripeCli } = attachParams;
|
||||
let newSchedule: Stripe.SubscriptionSchedule | undefined;
|
||||
|
||||
// if (sub.cancel_at) {
|
||||
// logger.info(`UNCANCELING SUB ${sub.id}`);
|
||||
// await stripeCli.subscriptions.update(sub.id, {
|
||||
// cancel_at: null,
|
||||
// });
|
||||
// }
|
||||
|
||||
// console.log("New phase");
|
||||
// await logPhases({
|
||||
// phases: res.phases,
|
||||
// db: req.db,
|
||||
// });
|
||||
// throw new Error("test");
|
||||
|
||||
if (res.phases[0].items.length > 0) {
|
||||
itemSet.subItems = res.phases[0].items;
|
||||
|
||||
// Create schedule from existing subscription
|
||||
newSchedule = await stripeCli.subscriptionSchedules.create({
|
||||
from_subscription: sub.id,
|
||||
});
|
||||
|
||||
// console.log("SChedule ID: ", newSchedule.id);
|
||||
|
||||
// const newScheduleId = "sub_sched_1RxyM89mx3u0jkgOgbbsAbDS";
|
||||
const newScheduleId = newSchedule.id;
|
||||
await stripeCli.subscriptionSchedules.update(newScheduleId, {
|
||||
phases: [
|
||||
{
|
||||
items: newSchedule.phases[0].items.map((item) => {
|
||||
const priceId = item.price as string;
|
||||
|
||||
// Re-apply metadata from subscription items since
|
||||
// Stripe's from_subscription doesn't copy item metadata
|
||||
const subItem = sub.items.data.find(
|
||||
(si) => si.price.id === priceId,
|
||||
);
|
||||
const metadata =
|
||||
subItem?.metadata && Object.keys(subItem.metadata).length > 0
|
||||
? subItem.metadata
|
||||
: item.metadata && Object.keys(item.metadata).length > 0
|
||||
? item.metadata
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
price: priceId,
|
||||
quantity: item.quantity,
|
||||
...(metadata && { metadata }),
|
||||
};
|
||||
}),
|
||||
start_date: newSchedule.phases[0].start_date,
|
||||
end_date: endOfBillingPeriod,
|
||||
trial_end: sub?.trial_end || undefined,
|
||||
},
|
||||
{
|
||||
items: res.phases[0].items,
|
||||
start_date: endOfBillingPeriod,
|
||||
},
|
||||
],
|
||||
end_behavior: "release",
|
||||
});
|
||||
|
||||
await CusProductService.updateByStripeSubId({
|
||||
db: ctx.db,
|
||||
stripeSubId: sub.id!,
|
||||
updates: {
|
||||
scheduled_ids: [newSchedule!.id],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return newSchedule as Stripe.SubscriptionSchedule;
|
||||
};
|
||||
|
||||
// phases: ([
|
||||
// {
|
||||
// items: scheduleItems.items,
|
||||
// // Set proration behavior for this phase transition
|
||||
// proration_behavior: "create_prorations", // Options: 'create_prorations', 'none', 'always_invoice'
|
||||
// // Optional: Set how long this phase should last
|
||||
// iterations: 1, // Number of billing cycles for this phase
|
||||
// // Optional: Add metadata for this phase
|
||||
// metadata: {
|
||||
// phase_type: "scheduled_update",
|
||||
// created_by: "attach_flow",
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
|
||||
// Option 2: Use your existing createSubSchedule function
|
||||
// newSchedule = await createSubSchedule({
|
||||
// db: req.db,
|
||||
// attachParams,
|
||||
// itemSet,
|
||||
// endOfBillingPeriod,
|
||||
// });
|
||||
@@ -1 +0,0 @@
|
||||
export const customerEntitlementRepo = {};
|
||||
@@ -1,495 +0,0 @@
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import { getEntityOptionsAggregateFragments } from "./getEntityOptionsAggregateFragments.js";
|
||||
|
||||
/**
|
||||
* Per-entity rollover rows sourced from the `rollovers` table, mirroring the
|
||||
* four branches in `entity_balance_rows` (product-attached / loose, per-entity
|
||||
* jsonb / top-level). Top-level rollovers are attributed to the owning entity
|
||||
* via `cp.internal_entity_id` (product-attached) or `ce.internal_entity_id`
|
||||
* (loose), matching main-balance behaviour. Also exposes per-entity and
|
||||
* per-feature rollups used by the outer aggregate CTEs.
|
||||
*/
|
||||
const buildEntityRolloverCtes = ({
|
||||
statusFilter,
|
||||
}: {
|
||||
statusFilter: SQL;
|
||||
}) => sql`
|
||||
entity_rollover_rows AS (
|
||||
-- Product-attached cusEnt, per-entity rollover (rollovers.entities jsonb)
|
||||
SELECT
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
kv.entity_key AS entity_key,
|
||||
COALESCE((kv.entity_value->>'balance')::numeric, 0) AS rollover_balance,
|
||||
COALESCE((kv.entity_value->>'usage')::numeric, 0) AS rollover_usage
|
||||
FROM rollovers r
|
||||
JOIN customer_entitlements ce ON r.cus_ent_id = ce.id
|
||||
JOIN customer_products cp ON ce.customer_product_id = cp.id
|
||||
CROSS JOIN LATERAL jsonb_each(r.entities) AS kv(entity_key, entity_value)
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND cp.internal_entity_id IS NOT NULL
|
||||
AND jsonb_typeof(r.entities) = 'object'
|
||||
AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
${statusFilter}
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Product-attached cusEnt, top-level rollover (attributed to cp.internal_entity_id)
|
||||
SELECT
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
cp.internal_entity_id AS entity_key,
|
||||
r.balance::numeric AS rollover_balance,
|
||||
COALESCE(r.usage, 0)::numeric AS rollover_usage
|
||||
FROM rollovers r
|
||||
JOIN customer_entitlements ce ON r.cus_ent_id = ce.id
|
||||
JOIN customer_products cp ON ce.customer_product_id = cp.id
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND cp.internal_entity_id IS NOT NULL
|
||||
AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
${statusFilter}
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Loose cusEnt (no customer_product), per-entity rollover
|
||||
SELECT
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
kv.entity_key AS entity_key,
|
||||
COALESCE((kv.entity_value->>'balance')::numeric, 0) AS rollover_balance,
|
||||
COALESCE((kv.entity_value->>'usage')::numeric, 0) AS rollover_usage
|
||||
FROM rollovers r
|
||||
JOIN customer_entitlements ce ON r.cus_ent_id = ce.id
|
||||
CROSS JOIN LATERAL jsonb_each(r.entities) AS kv(entity_key, entity_value)
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND ce.customer_product_id IS NULL
|
||||
AND ce.internal_entity_id IS NOT NULL
|
||||
AND jsonb_typeof(r.entities) = 'object'
|
||||
AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Loose cusEnt, top-level rollover (attributed to ce.internal_entity_id)
|
||||
SELECT
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
ce.internal_entity_id AS entity_key,
|
||||
r.balance::numeric AS rollover_balance,
|
||||
COALESCE(r.usage, 0)::numeric AS rollover_usage
|
||||
FROM rollovers r
|
||||
JOIN customer_entitlements ce ON r.cus_ent_id = ce.id
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND ce.customer_product_id IS NULL
|
||||
AND ce.internal_entity_id IS NOT NULL
|
||||
AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
),
|
||||
|
||||
entity_rollover_keys AS (
|
||||
SELECT
|
||||
internal_feature_id,
|
||||
internal_customer_id,
|
||||
entity_key,
|
||||
SUM(rollover_balance) AS rollover_balance,
|
||||
SUM(rollover_usage) AS rollover_usage
|
||||
FROM entity_rollover_rows
|
||||
WHERE entity_key IS NOT NULL
|
||||
GROUP BY internal_feature_id, internal_customer_id, entity_key
|
||||
),
|
||||
|
||||
entity_rollover_feature AS (
|
||||
SELECT
|
||||
internal_feature_id,
|
||||
internal_customer_id,
|
||||
SUM(rollover_balance) AS rollover_balance,
|
||||
SUM(rollover_usage) AS rollover_usage
|
||||
FROM entity_rollover_rows
|
||||
GROUP BY internal_feature_id, internal_customer_id
|
||||
)
|
||||
`;
|
||||
|
||||
/**
|
||||
* Per-feature/per-entity aggregates sourced strictly from `ce.entities` JSON.
|
||||
* This powers the `entities` map in customer-level aggregates and intentionally
|
||||
* excludes top-level entity attribution paths.
|
||||
*/
|
||||
const buildEntityEntitiesCtes = ({
|
||||
statusFilter,
|
||||
}: {
|
||||
statusFilter: SQL;
|
||||
}) => sql`
|
||||
entity_entities_rows AS (
|
||||
-- Product-attached entitlements: aggregate directly from ce.entities keys
|
||||
SELECT
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
kv.entity_key AS entity_key,
|
||||
COALESCE((kv.entity_value->>'balance')::numeric, 0) AS entity_balance,
|
||||
COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment,
|
||||
COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance
|
||||
FROM customer_entitlements ce
|
||||
JOIN customer_products cp ON ce.customer_product_id = cp.id
|
||||
CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value)
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND cp.internal_entity_id IS NOT NULL
|
||||
AND jsonb_typeof(ce.entities) = 'object'
|
||||
${statusFilter}
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Loose entitlements: aggregate directly from ce.entities keys
|
||||
SELECT
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
kv.entity_key AS entity_key,
|
||||
COALESCE((kv.entity_value->>'balance')::numeric, 0) AS entity_balance,
|
||||
COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment,
|
||||
COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance
|
||||
FROM customer_entitlements ce
|
||||
CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value)
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND ce.customer_product_id IS NULL
|
||||
AND ce.internal_entity_id IS NOT NULL
|
||||
AND jsonb_typeof(ce.entities) = 'object'
|
||||
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
),
|
||||
|
||||
entity_entities_aggregate_keys AS (
|
||||
SELECT
|
||||
internal_feature_id,
|
||||
internal_customer_id,
|
||||
entity_key,
|
||||
SUM(entity_balance) AS balance,
|
||||
SUM(entity_adjustment) AS adjustment,
|
||||
SUM(entity_additional_balance) AS additional_balance
|
||||
FROM entity_entities_rows
|
||||
WHERE entity_key IS NOT NULL
|
||||
GROUP BY internal_feature_id, internal_customer_id, entity_key
|
||||
)
|
||||
`;
|
||||
|
||||
export const getEntityAggregateFragments = ({
|
||||
entityId,
|
||||
statusFilter,
|
||||
}: {
|
||||
entityId?: string;
|
||||
statusFilter: SQL;
|
||||
}) => {
|
||||
if (entityId) {
|
||||
return {
|
||||
ctes: sql``,
|
||||
productRefsUnion: sql``,
|
||||
entitlementRefsUnion: sql``,
|
||||
priceRefsUnion: sql``,
|
||||
freeTrialRefsUnion: sql``,
|
||||
selectColumns: sql``,
|
||||
};
|
||||
}
|
||||
|
||||
const entityOptionsAggregateFragments = getEntityOptionsAggregateFragments();
|
||||
|
||||
const ctes = sql`,
|
||||
|
||||
entity_distinct_product_ids AS (
|
||||
SELECT DISTINCT cp.internal_product_id, cp.internal_customer_id
|
||||
FROM customer_products cp
|
||||
JOIN subject_customer_records scr
|
||||
ON cp.internal_customer_id = scr.internal_id
|
||||
WHERE cp.internal_entity_id IS NOT NULL
|
||||
${statusFilter}
|
||||
),
|
||||
|
||||
entity_distinct_cus_products AS (
|
||||
SELECT sub.*
|
||||
FROM entity_distinct_product_ids edpi
|
||||
JOIN LATERAL (
|
||||
SELECT cp.*
|
||||
FROM customer_products cp
|
||||
WHERE cp.internal_customer_id = edpi.internal_customer_id
|
||||
AND cp.internal_product_id = edpi.internal_product_id
|
||||
AND cp.internal_entity_id IS NOT NULL
|
||||
${statusFilter}
|
||||
ORDER BY cp.created_at DESC
|
||||
LIMIT 1
|
||||
) sub ON true
|
||||
),
|
||||
|
||||
entity_cus_products_for_options AS (
|
||||
SELECT cp.*
|
||||
FROM customer_products cp
|
||||
JOIN subject_customer_records scr
|
||||
ON cp.internal_customer_id = scr.internal_id
|
||||
WHERE cp.internal_entity_id IS NOT NULL
|
||||
${statusFilter}
|
||||
),
|
||||
|
||||
entity_cus_prices AS (
|
||||
SELECT cpr.*
|
||||
FROM customer_prices cpr
|
||||
WHERE cpr.customer_product_id IN (SELECT id FROM entity_distinct_cus_products)
|
||||
),
|
||||
|
||||
${entityOptionsAggregateFragments.ctes},
|
||||
|
||||
entity_balance_rows AS (
|
||||
SELECT
|
||||
COALESCE(ce.external_id, ce.id) AS api_id,
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
ce.feature_id,
|
||||
COALESCE(ent.allowance, 0)::numeric AS allowance,
|
||||
ce.balance::numeric AS balance,
|
||||
ce.adjustment::numeric AS adjustment,
|
||||
COALESCE(ce.additional_balance, 0)::numeric AS additional_balance,
|
||||
ce.unlimited,
|
||||
ce.usage_allowed,
|
||||
cp.internal_entity_id AS entity_key,
|
||||
ce.balance::numeric AS entity_balance,
|
||||
COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment,
|
||||
COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance
|
||||
FROM customer_entitlements ce
|
||||
JOIN customer_products cp ON ce.customer_product_id = cp.id
|
||||
JOIN entitlements ent ON ce.entitlement_id = ent.id
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND cp.internal_entity_id IS NOT NULL
|
||||
${statusFilter}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
COALESCE(ce.external_id, ce.id) AS api_id,
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
ce.feature_id,
|
||||
COALESCE(ent.allowance, 0)::numeric AS allowance,
|
||||
0::numeric AS balance,
|
||||
0::numeric AS adjustment,
|
||||
0::numeric AS additional_balance,
|
||||
ce.unlimited,
|
||||
ce.usage_allowed,
|
||||
kv.entity_key AS entity_key,
|
||||
(kv.entity_value->>'balance')::numeric AS entity_balance,
|
||||
COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment,
|
||||
COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance
|
||||
FROM customer_entitlements ce
|
||||
JOIN customer_products cp ON ce.customer_product_id = cp.id
|
||||
JOIN entitlements ent ON ce.entitlement_id = ent.id
|
||||
CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value)
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND cp.internal_entity_id IS NOT NULL
|
||||
AND jsonb_typeof(ce.entities) = 'object'
|
||||
${statusFilter}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
COALESCE(ce.external_id, ce.id) AS api_id,
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
ce.feature_id,
|
||||
COALESCE(ent.allowance, 0)::numeric AS allowance,
|
||||
0::numeric AS balance,
|
||||
0::numeric AS adjustment,
|
||||
0::numeric AS additional_balance,
|
||||
ce.unlimited,
|
||||
ce.usage_allowed,
|
||||
kv.entity_key AS entity_key,
|
||||
(kv.entity_value->>'balance')::numeric AS entity_balance,
|
||||
COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment,
|
||||
COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance
|
||||
FROM customer_entitlements ce
|
||||
JOIN entitlements ent ON ce.entitlement_id = ent.id
|
||||
CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value)
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND ce.customer_product_id IS NULL
|
||||
AND ce.internal_entity_id IS NOT NULL
|
||||
AND jsonb_typeof(ce.entities) = 'object'
|
||||
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
COALESCE(ce.external_id, ce.id) AS api_id,
|
||||
ce.internal_feature_id,
|
||||
ce.internal_customer_id,
|
||||
ce.feature_id,
|
||||
COALESCE(ent.allowance, 0)::numeric AS allowance,
|
||||
ce.balance::numeric AS balance,
|
||||
COALESCE(ce.adjustment, 0)::numeric AS adjustment,
|
||||
COALESCE(ce.additional_balance, 0)::numeric AS additional_balance,
|
||||
ce.unlimited,
|
||||
ce.usage_allowed,
|
||||
ce.internal_entity_id AS entity_key,
|
||||
ce.balance::numeric AS entity_balance,
|
||||
COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment,
|
||||
COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance
|
||||
FROM customer_entitlements ce
|
||||
JOIN entitlements ent ON ce.entitlement_id = ent.id
|
||||
WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records)
|
||||
AND ce.customer_product_id IS NULL
|
||||
AND ce.internal_entity_id IS NOT NULL
|
||||
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
),
|
||||
|
||||
${buildEntityRolloverCtes({ statusFilter })},
|
||||
|
||||
${buildEntityEntitiesCtes({ statusFilter })},
|
||||
|
||||
entity_aggregate_keys AS (
|
||||
SELECT
|
||||
COALESCE(ebk.internal_feature_id, erk.internal_feature_id) AS internal_feature_id,
|
||||
COALESCE(ebk.internal_customer_id, erk.internal_customer_id) AS internal_customer_id,
|
||||
COALESCE(ebk.entity_key, erk.entity_key) AS entity_key,
|
||||
COALESCE(ebk.balance, 0) AS balance,
|
||||
COALESCE(ebk.adjustment, 0) AS adjustment,
|
||||
COALESCE(ebk.additional_balance, 0) AS additional_balance,
|
||||
COALESCE(erk.rollover_balance, 0) AS rollover_balance,
|
||||
COALESCE(erk.rollover_usage, 0) AS rollover_usage
|
||||
FROM (
|
||||
SELECT
|
||||
internal_feature_id,
|
||||
internal_customer_id,
|
||||
entity_key,
|
||||
SUM(entity_balance) AS balance,
|
||||
SUM(entity_adjustment) AS adjustment,
|
||||
SUM(entity_additional_balance) AS additional_balance
|
||||
FROM entity_balance_rows
|
||||
WHERE entity_key IS NOT NULL
|
||||
GROUP BY internal_feature_id, internal_customer_id, entity_key
|
||||
) ebk
|
||||
FULL OUTER JOIN entity_rollover_keys erk
|
||||
ON erk.internal_feature_id = ebk.internal_feature_id
|
||||
AND erk.internal_customer_id = ebk.internal_customer_id
|
||||
AND erk.entity_key = ebk.entity_key
|
||||
),
|
||||
|
||||
entity_aggregate_map AS (
|
||||
SELECT
|
||||
ejk.internal_feature_id,
|
||||
ejk.internal_customer_id,
|
||||
jsonb_object_agg(
|
||||
ejk.entity_key,
|
||||
jsonb_build_object(
|
||||
'id', ejk.entity_key,
|
||||
'balance', ejk.balance,
|
||||
'adjustment', ejk.adjustment,
|
||||
'additional_balance', ejk.additional_balance,
|
||||
'rollover_balance', COALESCE(erk.rollover_balance, 0),
|
||||
'rollover_usage', COALESCE(erk.rollover_usage, 0)
|
||||
)
|
||||
) AS entities
|
||||
FROM entity_entities_aggregate_keys ejk
|
||||
LEFT JOIN entity_rollover_keys erk
|
||||
ON erk.internal_feature_id = ejk.internal_feature_id
|
||||
AND erk.internal_customer_id = ejk.internal_customer_id
|
||||
AND erk.entity_key = ejk.entity_key
|
||||
GROUP BY ejk.internal_feature_id, ejk.internal_customer_id
|
||||
),
|
||||
|
||||
entity_aggregated_cus_entitlements AS (
|
||||
SELECT
|
||||
MIN(ebr.api_id) AS api_id,
|
||||
ebr.internal_feature_id,
|
||||
ebr.internal_customer_id,
|
||||
MIN(ebr.feature_id) AS feature_id,
|
||||
SUM(ebr.allowance) AS allowance_total,
|
||||
COALESCE(MAX(epgo.prepaid_grant_from_options), 0) AS prepaid_grant_from_options,
|
||||
SUM(ebr.balance) AS balance,
|
||||
SUM(ebr.adjustment) AS adjustment,
|
||||
SUM(ebr.additional_balance) AS additional_balance,
|
||||
COALESCE(MAX(erf.rollover_balance), 0) AS rollover_balance,
|
||||
COALESCE(MAX(erf.rollover_usage), 0) AS rollover_usage,
|
||||
BOOL_OR(ebr.unlimited) AS unlimited,
|
||||
BOOL_OR(ebr.usage_allowed) AS usage_allowed,
|
||||
COUNT(DISTINCT ebr.entity_key) FILTER (WHERE ebr.entity_key IS NOT NULL) AS entity_count,
|
||||
eam.entities
|
||||
FROM entity_balance_rows ebr
|
||||
LEFT JOIN entity_aggregate_map eam
|
||||
ON eam.internal_feature_id = ebr.internal_feature_id
|
||||
AND eam.internal_customer_id = ebr.internal_customer_id
|
||||
LEFT JOIN entity_rollover_feature erf
|
||||
ON erf.internal_feature_id = ebr.internal_feature_id
|
||||
AND erf.internal_customer_id = ebr.internal_customer_id
|
||||
LEFT JOIN entity_prepaid_grant_from_options epgo
|
||||
ON epgo.internal_feature_id = ebr.internal_feature_id
|
||||
AND epgo.internal_customer_id = ebr.internal_customer_id
|
||||
GROUP BY
|
||||
ebr.internal_feature_id,
|
||||
ebr.internal_customer_id,
|
||||
eam.entities
|
||||
)
|
||||
`;
|
||||
|
||||
const productRefsUnion = sql`
|
||||
UNION ALL
|
||||
SELECT ecp.internal_customer_id, ecp.internal_product_id
|
||||
FROM entity_distinct_cus_products ecp
|
||||
`;
|
||||
|
||||
const entitlementRefsUnion = sql`
|
||||
UNION
|
||||
SELECT DISTINCT
|
||||
ce.internal_customer_id,
|
||||
ce.entitlement_id
|
||||
FROM customer_entitlements ce
|
||||
JOIN customer_products cp ON ce.customer_product_id = cp.id
|
||||
WHERE cp.internal_entity_id IS NOT NULL
|
||||
${statusFilter}
|
||||
|
||||
UNION
|
||||
SELECT DISTINCT
|
||||
ce.internal_customer_id,
|
||||
ce.entitlement_id
|
||||
FROM customer_entitlements ce
|
||||
WHERE ce.customer_product_id IS NULL
|
||||
AND ce.internal_entity_id IS NOT NULL
|
||||
AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000)
|
||||
`;
|
||||
|
||||
const priceRefsUnion = sql`
|
||||
UNION ALL
|
||||
SELECT ecpr.price_id, ecp.internal_customer_id
|
||||
FROM entity_cus_prices ecpr
|
||||
JOIN entity_distinct_cus_products ecp
|
||||
ON ecp.id = ecpr.customer_product_id
|
||||
`;
|
||||
|
||||
const freeTrialRefsUnion = sql`
|
||||
UNION ALL
|
||||
SELECT ecp.free_trial_id, ecp.internal_customer_id
|
||||
FROM entity_distinct_cus_products ecp
|
||||
WHERE ecp.free_trial_id IS NOT NULL
|
||||
`;
|
||||
|
||||
const selectColumns = sql`,
|
||||
|
||||
json_build_object(
|
||||
'aggregated_customer_products', COALESCE(
|
||||
(
|
||||
SELECT json_agg(row_to_json(ecp))
|
||||
FROM entity_distinct_cus_products ecp
|
||||
WHERE ecp.internal_customer_id = scr.internal_id
|
||||
),
|
||||
'[]'::json
|
||||
),
|
||||
'aggregated_customer_entitlements', COALESCE(
|
||||
(
|
||||
SELECT json_agg(row_to_json(eace))
|
||||
FROM entity_aggregated_cus_entitlements eace
|
||||
WHERE eace.internal_customer_id = scr.internal_id
|
||||
),
|
||||
'[]'::json
|
||||
)
|
||||
) AS entity_aggregations
|
||||
`;
|
||||
|
||||
return {
|
||||
ctes,
|
||||
productRefsUnion,
|
||||
entitlementRefsUnion,
|
||||
priceRefsUnion,
|
||||
freeTrialRefsUnion,
|
||||
selectColumns,
|
||||
};
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Entity } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { EntityService } from "@/internal/api/entities/EntityService.js";
|
||||
|
||||
export const updateEntityDbAndCache = async ({
|
||||
ctx,
|
||||
entity,
|
||||
updates,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
entity: Entity;
|
||||
updates: Partial<
|
||||
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
|
||||
>;
|
||||
}) => {
|
||||
const filteredUpdates = Object.fromEntries(
|
||||
Object.entries(updates).filter(([, value]) => value !== undefined),
|
||||
) as Partial<
|
||||
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
|
||||
>;
|
||||
|
||||
if (Object.keys(filteredUpdates).length === 0) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
return EntityService.update({
|
||||
db: ctx.db,
|
||||
internalId: entity.internal_id,
|
||||
update: filteredUpdates,
|
||||
});
|
||||
};
|
||||
@@ -1,249 +0,0 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { dbCritical, dbGeneral } from "@/db/initDrizzle.js";
|
||||
import {
|
||||
forceDegraded,
|
||||
forceHealthy,
|
||||
getPgHealthState,
|
||||
} from "@/db/pgHealthMonitor.js";
|
||||
import { redis } from "@/external/redis/initRedis.js";
|
||||
import {
|
||||
disconnectPrimary,
|
||||
getFailoverState,
|
||||
reconnectPrimary,
|
||||
} from "@/external/redis/redisFailover.js";
|
||||
import { orgConfigMiddleware } from "@/honoMiddlewares/orgConfigMiddleware.js";
|
||||
import { secretKeyMiddleware } from "@/honoMiddlewares/secretKeyMiddleware.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
|
||||
const ALLOWED_ORG_IDS = new Set([
|
||||
"org_2sWv2S8LJ9iaTjLI6UtNsfL88Kt",
|
||||
"org_2rzkkRh7r5dBSaBC101QHG9KDgt",
|
||||
"org_2vwdxwTdqxRrLEdUYddcynMv3n3",
|
||||
]);
|
||||
|
||||
export const debugRouter = new Hono<HonoEnv>();
|
||||
|
||||
debugRouter.use("*", secretKeyMiddleware);
|
||||
debugRouter.use("*", orgConfigMiddleware);
|
||||
|
||||
debugRouter.get("/memory", async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
|
||||
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
pid: process.pid,
|
||||
timestamp: new Date().toISOString(),
|
||||
memory: {
|
||||
rssMB: +(mem.rss / 1024 / 1024).toFixed(1),
|
||||
heapUsedMB: +(mem.heapUsed / 1024 / 1024).toFixed(1),
|
||||
heapTotalMB: +(mem.heapTotal / 1024 / 1024).toFixed(1),
|
||||
externalMB: +(mem.external / 1024 / 1024).toFixed(1),
|
||||
arrayBuffersMB: +(mem.arrayBuffers / 1024 / 1024).toFixed(1),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/** Check what statement_timeout the DB connections actually see. */
|
||||
debugRouter.get("/statement-timeout", async (c) => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return c.json({ error: "Not available in production" }, 403);
|
||||
}
|
||||
|
||||
const ctx = c.get("ctx");
|
||||
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const criticalResult = await dbCritical.execute(sql`SHOW statement_timeout`);
|
||||
const generalResult = await dbGeneral.execute(sql`SHOW statement_timeout`);
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
critical: criticalResult[0],
|
||||
general: generalResult[0],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pool isolation test endpoint. Runs pg_sleep or SELECT 1 on a specific pool.
|
||||
* Blocked in production.
|
||||
*/
|
||||
debugRouter.post("/pool-test", async (c) => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return c.json({ error: "Not available in production" }, 403);
|
||||
}
|
||||
|
||||
const ctx = c.get("ctx");
|
||||
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL?.includes("us-east-2")) {
|
||||
return c.json({ error: "Not available against production database" }, 403);
|
||||
}
|
||||
|
||||
const body = await c.req.json<{
|
||||
action: "sleep" | "ping" | "cpu";
|
||||
pool: "general" | "critical";
|
||||
seconds?: number;
|
||||
/** Row count for CPU burn (default 5_000_000). Higher = more CPU time. */
|
||||
rows?: number;
|
||||
}>();
|
||||
|
||||
const { action, pool, seconds = 5, rows = 5_000_000 } = body;
|
||||
const db = pool === "critical" ? dbCritical : dbGeneral;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
if (action === "sleep") {
|
||||
await db.execute(sql`SELECT pg_sleep(${seconds})`);
|
||||
} else if (action === "cpu") {
|
||||
// CPU-intensive: hash millions of rows. Burns real CPU on the DB.
|
||||
await db.execute(
|
||||
sql`SELECT count(*) FROM generate_series(1, ${rows}) AS s WHERE md5(s::text) IS NOT NULL`,
|
||||
);
|
||||
} else {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
pool,
|
||||
action,
|
||||
durationMs: Date.now() - start,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
pool,
|
||||
action,
|
||||
durationMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Redis failover test endpoints. Blocked in production.
|
||||
*/
|
||||
debugRouter.post("/redis-failover", async (c) => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return c.json({ error: "Not available in production" }, 403);
|
||||
}
|
||||
|
||||
const ctx = c.get("ctx");
|
||||
|
||||
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const body = await c.req.json<{
|
||||
action: "status" | "kill-primary" | "recover-primary" | "ping";
|
||||
}>();
|
||||
|
||||
if (body.action === "status") {
|
||||
return c.json({ ok: true, ...getFailoverState() });
|
||||
}
|
||||
|
||||
if (body.action === "kill-primary") {
|
||||
disconnectPrimary();
|
||||
return c.json({ ok: true, message: "Primary disconnected" });
|
||||
}
|
||||
|
||||
if (body.action === "recover-primary") {
|
||||
reconnectPrimary();
|
||||
return c.json({ ok: true, message: "Primary reconnect triggered" });
|
||||
}
|
||||
|
||||
if (body.action === "ping") {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await redis.ping();
|
||||
return c.json({
|
||||
ok: true,
|
||||
durationMs: Date.now() - start,
|
||||
...getFailoverState(),
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
durationMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
...getFailoverState(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ error: "Unknown action" }, 400);
|
||||
});
|
||||
|
||||
/**
|
||||
* PG health monitor test endpoints. Blocked in production.
|
||||
*/
|
||||
debugRouter.post("/pg-health", async (c) => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return c.json({ error: "Not available in production" }, 403);
|
||||
}
|
||||
|
||||
const ctx = c.get("ctx");
|
||||
if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const body = await c.req.json<{
|
||||
action: "status" | "force-degraded" | "force-healthy";
|
||||
}>();
|
||||
|
||||
if (body.action === "status") {
|
||||
return c.json({ ok: true, ...getPgHealthState() });
|
||||
}
|
||||
|
||||
if (body.action === "force-degraded") {
|
||||
forceDegraded();
|
||||
return c.json({
|
||||
ok: true,
|
||||
message: "Forced DEGRADED",
|
||||
...getPgHealthState(),
|
||||
});
|
||||
}
|
||||
|
||||
if (body.action === "force-healthy") {
|
||||
forceHealthy();
|
||||
return c.json({
|
||||
ok: true,
|
||||
message: "Forced HEALTHY",
|
||||
...getPgHealthState(),
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ error: "Unknown action" }, 400);
|
||||
});
|
||||
|
||||
/** Write a V8 heap snapshot to disk. Requires secret key auth + dev-only. */
|
||||
// debugRouter.get("/heap-snapshot", async (c) => {
|
||||
// if (process.env.NODE_ENV === "production") {
|
||||
// return c.json({ error: "Not available in production" }, 403);
|
||||
// }
|
||||
|
||||
// const ctx = c.get("ctx");
|
||||
// if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
|
||||
// return c.json({ error: "Forbidden" }, 403);
|
||||
// }
|
||||
|
||||
// const snapshotDir = new URL("../../../perf/snapshots/", import.meta.url)
|
||||
// .pathname;
|
||||
// const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
// const filename = `heap-${timestamp}-pid${process.pid}.heapsnapshot`;
|
||||
// const filepath = `${snapshotDir}${filename}`;
|
||||
|
||||
// writeHeapSnapshot(filepath);
|
||||
|
||||
// return c.json({ ok: true, file: filename, path: filepath });
|
||||
// });
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js";
|
||||
import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js";
|
||||
import { handleCreatePlan } from "./handlers/handleCreateProduct/handleCreatePlan.js";
|
||||
import { handleCreatePlanV2 } from "./handlers/handleCreateProduct/handleCreatePlanV2.js";
|
||||
import { handleDeletePlanV1 } from "./handlers/handleDeletePlan/handleDeletePlanV1.js";
|
||||
import { handleDeletePlanV2 } from "./handlers/handleDeletePlan/handleDeletePlanV2.js";
|
||||
import { handleGetPlanV1 } from "./handlers/handleGetPlan/handleGetPlanV1.js";
|
||||
import { handleGetPlanV2 } from "./handlers/handleGetPlan/handleGetPlanV2.js";
|
||||
import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js";
|
||||
import { handleListPlansV2 } from "./handlers/handleListPlans/handleListPlansV2.js";
|
||||
import { handleListPlans } from "./handlers/handleListPlans.js";
|
||||
import { handleMigrateProductV2 } from "./handlers/handleMigrateProductV2.js";
|
||||
import { handleUpdatePlanV1 } from "./handlers/handleUpdatePlan/handleUpdatePlanV1.js";
|
||||
import { handleUpdatePlanV2 } from "./handlers/handleUpdatePlan/handleUpdatePlanV2.js";
|
||||
|
||||
export const honoProductBetaRouter = new Hono<HonoEnv>();
|
||||
honoProductBetaRouter.get("", ...handleListPlans);
|
||||
|
||||
// Create a Hono app for products
|
||||
export const honoProductRouter = new Hono<HonoEnv>();
|
||||
export const migrationRouter = new Hono<HonoEnv>();
|
||||
|
||||
// Migrations
|
||||
migrationRouter.post("/migrations", ...handleMigrateProductV2);
|
||||
|
||||
// CRUD
|
||||
honoProductRouter.get("", ...handleListPlans);
|
||||
honoProductRouter.post("", ...handleCreatePlan);
|
||||
honoProductRouter.get("/:product_id", ...handleGetPlanV1);
|
||||
honoProductRouter.post("/:product_id", ...handleUpdatePlanV1); // will be deprecated
|
||||
honoProductRouter.patch("/:product_id", ...handleUpdatePlanV1); // will be deprecated
|
||||
honoProductRouter.delete("/:product_id", ...handleDeletePlanV1);
|
||||
|
||||
// Others
|
||||
honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2);
|
||||
|
||||
// Info before deleting plan
|
||||
honoProductRouter.get(
|
||||
"/:product_id/has_customers",
|
||||
...handlePlanHasCustomersV2,
|
||||
);
|
||||
honoProductRouter.post(
|
||||
"/:product_id/has_customers",
|
||||
...handlePlanHasCustomersV2,
|
||||
);
|
||||
honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo);
|
||||
|
||||
// RPC
|
||||
export const plansRpcRouter = new Hono<HonoEnv>();
|
||||
plansRpcRouter.post("/plans.get", ...handleGetPlanV2);
|
||||
plansRpcRouter.post("/plans.list", ...handleListPlansV2);
|
||||
plansRpcRouter.post("/plans.create", ...handleCreatePlanV2);
|
||||
plansRpcRouter.post("/plans.update", ...handleUpdatePlanV2);
|
||||
plansRpcRouter.post("/plans.delete", ...handleDeletePlanV2);
|
||||
@@ -1,39 +0,0 @@
|
||||
import {
|
||||
type FullCustomer,
|
||||
fullCustomerToCustomerEntitlements,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
|
||||
export const updateUsages = async ({
|
||||
ctx,
|
||||
featureId,
|
||||
usage,
|
||||
fullCus,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
featureId: string;
|
||||
usage: number;
|
||||
fullCus: FullCustomer;
|
||||
}) => {
|
||||
const cusEnts = fullCustomerToCustomerEntitlements({
|
||||
fullCustomer: fullCus,
|
||||
inStatuses: RELEVANT_STATUSES,
|
||||
featureId,
|
||||
});
|
||||
if (cusEnts.length === 0) {
|
||||
throw new Error(`No cus ent for ${featureId}`);
|
||||
}
|
||||
|
||||
const cusEnt = cusEnts[0];
|
||||
const newBalance = cusEnt.balance! - usage;
|
||||
|
||||
await CusEntService.update({
|
||||
ctx,
|
||||
id: cusEnt.id,
|
||||
updates: {
|
||||
balance: newBalance,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
import {
|
||||
AppEnv,
|
||||
customers,
|
||||
features,
|
||||
type Organization,
|
||||
products,
|
||||
} from "@autumn/shared";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
|
||||
export const clearCustomersInBatches = async ({
|
||||
db,
|
||||
org,
|
||||
batchSize = 450,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
org: Organization;
|
||||
batchSize?: number;
|
||||
}) => {
|
||||
let deletedCount = 0;
|
||||
|
||||
while (true) {
|
||||
// Get a batch of customer IDs to delete
|
||||
const customerBatch = await db
|
||||
.select({ internalId: customers.internal_id })
|
||||
.from(customers)
|
||||
.where(
|
||||
and(eq(customers.org_id, org.id), eq(customers.env, AppEnv.Sandbox)),
|
||||
)
|
||||
.limit(batchSize);
|
||||
|
||||
if (customerBatch.length === 0) {
|
||||
break; // No more customers to delete
|
||||
}
|
||||
|
||||
// Delete the batch
|
||||
const customerIds = customerBatch
|
||||
.map((c) => c.internalId)
|
||||
.filter((id) => id !== null);
|
||||
|
||||
console.log("Deleting customers:", customerIds);
|
||||
|
||||
await db
|
||||
.delete(customers)
|
||||
.where(inArray(customers.internal_id, customerIds));
|
||||
|
||||
deletedCount += customerBatch.length;
|
||||
console.log(
|
||||
`Deleted ${customerBatch.length} customers (total: ${deletedCount})`,
|
||||
);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
};
|
||||
|
||||
export const clearOrg = async ({
|
||||
db,
|
||||
org,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
org: Organization;
|
||||
}) => {
|
||||
const deletedCount = await clearCustomersInBatches({ db, org });
|
||||
console.log(`Cleared ${deletedCount} customers`);
|
||||
|
||||
await db
|
||||
.delete(products)
|
||||
.where(and(eq(products.org_id, org.id), eq(products.env, AppEnv.Sandbox)));
|
||||
|
||||
console.log("Cleared products");
|
||||
|
||||
await db
|
||||
.delete(features)
|
||||
.where(and(eq(features.org_id, org.id), eq(features.env, AppEnv.Sandbox)));
|
||||
|
||||
console.log("Cleared features");
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import csv from "csv-parser";
|
||||
import fs from "fs";
|
||||
|
||||
export const parseCsv = ({
|
||||
path,
|
||||
delimiter = ",",
|
||||
}: {
|
||||
path: string;
|
||||
delimiter?: string;
|
||||
}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = fs.createReadStream(path);
|
||||
const results: any[] = [];
|
||||
const headers: string[] = [];
|
||||
stream
|
||||
.pipe(csv({ separator: delimiter }))
|
||||
.on("data", (data) => {
|
||||
results.push(data);
|
||||
// if (headers.length === 0) {
|
||||
// headers = Object.keys(data);
|
||||
// } else {
|
||||
// results.push(data);
|
||||
// }
|
||||
})
|
||||
.on("end", () => resolve(results))
|
||||
.on("error", (error) => reject(error));
|
||||
}) as Promise<any[]>;
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
import { AppEnv, type FullCusProduct } from "@autumn/shared";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
|
||||
const cusProductsQuery = ({
|
||||
lastProductId,
|
||||
internalProductId,
|
||||
pageSize = 250,
|
||||
}: {
|
||||
lastProductId?: string;
|
||||
internalProductId?: string;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
// const withStatusFilter = () => {
|
||||
// return inStatuses
|
||||
// ? sql`AND cp.status = ANY(ARRAY[${sql.join(
|
||||
// inStatuses.map((status) => sql`${status}`),
|
||||
// sql`, `,
|
||||
// )}])`
|
||||
// : sql``;
|
||||
// };
|
||||
|
||||
return sql`
|
||||
SELECT
|
||||
cp.*,
|
||||
row_to_json(prod) AS product,
|
||||
|
||||
-- Spread customer_prices fields + add price field
|
||||
COALESCE(
|
||||
json_agg(DISTINCT (
|
||||
to_jsonb(cpr.*) || jsonb_build_object('price', to_jsonb(p.*))
|
||||
)) FILTER (WHERE cpr.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS customer_prices,
|
||||
|
||||
-- Spread customer_entitlements fields + add entitlement and replaceables
|
||||
COALESCE(
|
||||
json_agg(DISTINCT (
|
||||
to_jsonb(ce.*) || jsonb_build_object(
|
||||
'entitlement', (
|
||||
SELECT row_to_json(ent_with_feature)
|
||||
FROM (
|
||||
SELECT e.*, row_to_json(f) AS feature
|
||||
FROM entitlements e
|
||||
JOIN features f ON e.internal_feature_id = f.internal_id
|
||||
WHERE e.id = ce.entitlement_id
|
||||
) AS ent_with_feature
|
||||
),
|
||||
'replaceables', (
|
||||
SELECT COALESCE(
|
||||
json_agg(row_to_json(r)) FILTER (WHERE r.id IS NOT NULL),
|
||||
'[]'::json
|
||||
)
|
||||
FROM replaceables r
|
||||
WHERE r.cus_ent_id = ce.id
|
||||
)
|
||||
)
|
||||
)) FILTER (WHERE ce.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS customer_entitlements,
|
||||
|
||||
-- free_trial
|
||||
(
|
||||
SELECT row_to_json(ft)
|
||||
FROM free_trials ft
|
||||
WHERE ft.id = cp.free_trial_id
|
||||
) AS free_trial
|
||||
|
||||
FROM customer_products cp
|
||||
JOIN products prod ON cp.internal_product_id = prod.internal_id
|
||||
LEFT JOIN customer_prices cpr ON cpr.customer_product_id = cp.id
|
||||
LEFT JOIN prices p ON cpr.price_id = p.id
|
||||
LEFT JOIN customer_entitlements ce ON ce.customer_product_id = cp.id
|
||||
WHERE cp.internal_product_id = ${internalProductId}
|
||||
${lastProductId ? sql`AND cp.id < ${lastProductId}` : sql``}
|
||||
GROUP BY cp.id, prod.*
|
||||
ORDER BY cp.id DESC
|
||||
LIMIT ${pageSize}
|
||||
`;
|
||||
};
|
||||
|
||||
export const getAllFullCusProducts = async ({
|
||||
db,
|
||||
internalProductId,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
internalProductId: string;
|
||||
}) => {
|
||||
let lastProductId = "";
|
||||
const allData: any[] = [];
|
||||
const pageSize = 500;
|
||||
|
||||
while (true) {
|
||||
const data = await db.execute(
|
||||
cusProductsQuery({
|
||||
lastProductId,
|
||||
pageSize,
|
||||
internalProductId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (data.length === 0) break;
|
||||
|
||||
console.log(`Fetched ${data.length} customer products`);
|
||||
allData.push(...data);
|
||||
lastProductId = data[data.length - 1].id as string;
|
||||
}
|
||||
|
||||
return allData as FullCusProduct[];
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { Stripe } from "stripe";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
|
||||
export const getAllStripeSubscriptions = async ({
|
||||
numPages,
|
||||
limit = 100,
|
||||
stripeCli,
|
||||
waitForSeconds,
|
||||
}: {
|
||||
numPages?: number;
|
||||
limit?: number;
|
||||
stripeCli: Stripe;
|
||||
waitForSeconds?: number;
|
||||
}) => {
|
||||
let hasMore = true;
|
||||
let startingAfter: string | null = null;
|
||||
const allSubscriptions: any[] = [];
|
||||
|
||||
let pageCount = 0;
|
||||
while (hasMore) {
|
||||
const response: any = await stripeCli.subscriptions.list({
|
||||
limit,
|
||||
starting_after: startingAfter || undefined,
|
||||
expand: ["data.discounts.coupon"],
|
||||
});
|
||||
|
||||
if (response.data.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
allSubscriptions.push(...response.data);
|
||||
|
||||
hasMore = response.has_more;
|
||||
startingAfter = response.data[response.data.length - 1].id;
|
||||
|
||||
pageCount++;
|
||||
if (numPages && pageCount >= numPages) {
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("Fetched", allSubscriptions.length, "subscriptions");
|
||||
if (waitForSeconds) {
|
||||
await timeout(1000);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptions: allSubscriptions,
|
||||
total: allSubscriptions.length,
|
||||
};
|
||||
};
|
||||
export const getAllStripeSchedules = async ({
|
||||
numPages,
|
||||
limit = 100,
|
||||
stripeCli,
|
||||
waitForSeconds,
|
||||
}: {
|
||||
numPages?: number;
|
||||
limit?: number;
|
||||
stripeCli: Stripe;
|
||||
waitForSeconds?: number;
|
||||
}) => {
|
||||
let hasMore = true;
|
||||
let startingAfter: string | null = null;
|
||||
const allSchedules: any[] = [];
|
||||
|
||||
let pageCount = 0;
|
||||
while (hasMore) {
|
||||
const response: any = await stripeCli.subscriptionSchedules.list({
|
||||
limit,
|
||||
starting_after: startingAfter || undefined,
|
||||
expand: ["data.phases.items.price"],
|
||||
});
|
||||
|
||||
if (response.data.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
allSchedules.push(...response.data);
|
||||
|
||||
hasMore = response.has_more;
|
||||
startingAfter = response.data[response.data.length - 1].id;
|
||||
|
||||
pageCount++;
|
||||
if (numPages && pageCount >= numPages) {
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("Fetched", allSchedules.length, "schedules");
|
||||
if (waitForSeconds) {
|
||||
await timeout(1000);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schedules: allSchedules,
|
||||
total: allSchedules.length,
|
||||
};
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { user } from "@autumn/shared";
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
|
||||
export const getAllUsers = async (db: DrizzleCli) => {
|
||||
const users = [];
|
||||
let offset = 0;
|
||||
const limit = 200;
|
||||
|
||||
while (true) {
|
||||
const batch = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.orderBy(desc(user.createdAt));
|
||||
users.push(...batch);
|
||||
|
||||
if (batch.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += limit;
|
||||
console.log(`Fetched ${users.length} users`);
|
||||
}
|
||||
return users;
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { stripeToAtmnAmount } from "@autumn/shared";
|
||||
import { subItemToAutumnInterval } from "@tests/utils/stripeUtils";
|
||||
import type Stripe from "stripe";
|
||||
import type { Logger } from "../../../external/logtail/logtailUtils";
|
||||
|
||||
export const logSubItems = ({
|
||||
sub,
|
||||
subItems,
|
||||
withPriceId = false,
|
||||
withItemId = false,
|
||||
logger,
|
||||
}: {
|
||||
sub?: Stripe.Subscription;
|
||||
subItems?: Stripe.SubscriptionItem[];
|
||||
withPriceId?: boolean;
|
||||
withItemId?: boolean;
|
||||
logger?: Logger | Console;
|
||||
}) => {
|
||||
const finalSubItems = subItems || sub!.items.data;
|
||||
|
||||
if (!logger) {
|
||||
logger = console;
|
||||
}
|
||||
|
||||
for (const item of finalSubItems) {
|
||||
const isMetered = item.price.recurring?.usage_type === "metered";
|
||||
|
||||
const atmnPrice = stripeToAtmnAmount({
|
||||
amount: item.price.unit_amount || 0,
|
||||
currency: item.price.currency,
|
||||
});
|
||||
|
||||
if (isMetered) {
|
||||
logger.info(`Usage price`);
|
||||
} else {
|
||||
const price = atmnPrice;
|
||||
const subInterval = subItemToAutumnInterval(item);
|
||||
logger.info(
|
||||
`${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval} ${withPriceId ? `(${item.price.id})` : ""} ${withItemId ? `(${item.id})` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
|
||||
export interface HandleCustomerCreatedData {
|
||||
req: Partial<ExtendedRequest>;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
internalCustomerId: string;
|
||||
}
|
||||
@@ -61,7 +61,10 @@ mock.module("@/internal/balances/track/v3/runRedisTrackV3.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { runTrackV3 } from "@/internal/balances/track/v3/runTrackV3.js";
|
||||
const { runTrackV3 } = await import(
|
||||
// @ts-expect-error - Bun test cache-busting import query isolates module mocks.
|
||||
"@/internal/balances/track/v3/runTrackV3.js?runTrackV3Idempotency"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
apiVersion: new ApiVersionClass(ApiVersion.V2_1),
|
||||
|
||||
@@ -1,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 != ''
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
defineVersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import type { z } from "zod/v4";
|
||||
import {
|
||||
TrackResponseV0Schema,
|
||||
TrackResponseV1Schema,
|
||||
} from "../prevVersions/trackResponseV1.js";
|
||||
import {
|
||||
type TrackLegacyData,
|
||||
TrackLegacyDataSchema,
|
||||
} from "../trackLegacyData.js";
|
||||
|
||||
/**
|
||||
* V0_2_CheckChange: Transforms check response TO V0_2 format
|
||||
*
|
||||
* Applied when: targetVersion <= V0_2
|
||||
*
|
||||
* Breaking changes introduced in V0.2 (that we reverse here):
|
||||
*
|
||||
* 1. Structure: Single check result object → balances array format
|
||||
* - V0.2+: Single object with allowed, feature_id, balance, unlimited, etc.
|
||||
* - V0_2: { allowed, balances: [{ feature_id, required, balance, unlimited, usage_allowed }] }
|
||||
*
|
||||
* 2. Boolean features: No balance fields → balance: null
|
||||
* 3. Unlimited features: Return unlimited: true, usage_allowed based on overage_allowed
|
||||
* 4. Metered features: Return required_balance and balance
|
||||
*
|
||||
* Input: CheckResult (V0.2+ format)
|
||||
* Output: CheckResponseV0 (V0_2 balances array format)
|
||||
*/
|
||||
|
||||
export const V0_2_CheckChange = defineVersionChange({
|
||||
name: "V0.2 Check Change",
|
||||
newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1
|
||||
oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2
|
||||
description: [
|
||||
"Check response transformed to balances array format",
|
||||
"Single check result object → { allowed, balances: [...] }",
|
||||
],
|
||||
affectedResources: [AffectedResource.Check],
|
||||
newSchema: TrackResponseV1Schema,
|
||||
oldSchema: TrackResponseV0Schema,
|
||||
legacyDataSchema: TrackLegacyDataSchema,
|
||||
affectsResponse: true,
|
||||
|
||||
// Response: V1.1+ (CheckResult) → V0_2 (CheckResponseV0)
|
||||
transformResponse: ({
|
||||
input,
|
||||
legacyData,
|
||||
}: {
|
||||
input: z.infer<typeof TrackResponseV1Schema>;
|
||||
legacyData?: TrackLegacyData;
|
||||
}): z.infer<typeof TrackResponseV0Schema> => {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ApiBalanceInput } from "@api/customers/cusFeatures/utils/convert/apiBalanceToAllowed";
|
||||
|
||||
export const balancesToCheckFeature = ({
|
||||
balances,
|
||||
}: {
|
||||
balances: Record<string, ApiBalanceInput>;
|
||||
}) => {
|
||||
return balances.map((balance) => {
|
||||
return {
|
||||
featureId: balance.featureId,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { planV1ToV0 } from "@api/products/mappers/planV1ToV0";
|
||||
import type { ApiSubscription } from "../apiSubscription";
|
||||
import type { ApiSubscriptionV1 } from "../apiSubscriptionV1";
|
||||
|
||||
export function transformApiSubscriptionV1ToV0({
|
||||
input,
|
||||
}: {
|
||||
input: ApiSubscriptionV1;
|
||||
}): ApiSubscription {
|
||||
return {
|
||||
plan: input.plan ? planV1ToV0(input.plan) : undefined,
|
||||
plan_id: input.plan_id,
|
||||
default: input.auto_enable,
|
||||
add_on: input.add_on,
|
||||
status: input.status,
|
||||
past_due: input.past_due,
|
||||
canceled_at: input.canceled_at,
|
||||
expires_at: input.expires_at,
|
||||
trial_ends_at: input.trial_ends_at,
|
||||
started_at: input.started_at,
|
||||
current_period_start: input.current_period_start,
|
||||
current_period_end: input.current_period_end,
|
||||
quantity: input.quantity,
|
||||
};
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { planV1ToV0 } from "@api/products/mappers/planV1ToV0";
|
||||
import type { SharedContext } from "../../../../types/sharedContext";
|
||||
import type { ApiSubscription } from "../apiSubscription";
|
||||
import type { ApiPurchaseV0 } from "../apiSubscriptionV1";
|
||||
|
||||
/**
|
||||
* Converts an ApiPurchaseV0 to an ApiSubscription (V0) for backwards compatibility.
|
||||
* Purchases are represented as subscriptions with sensible defaults for missing fields.
|
||||
*/
|
||||
export function apiPurchaseV0ToSubscriptionV0({
|
||||
ctx,
|
||||
input,
|
||||
}: {
|
||||
ctx: SharedContext;
|
||||
input: ApiPurchaseV0;
|
||||
}): ApiSubscription {
|
||||
return {
|
||||
plan: input.plan ? planV1ToV0({ ctx, plan: input.plan }) : undefined,
|
||||
plan_id: input.plan_id,
|
||||
default: false,
|
||||
add_on: true,
|
||||
status: "active",
|
||||
past_due: false,
|
||||
canceled_at: null,
|
||||
expires_at: input.expires_at,
|
||||
trial_ends_at: null,
|
||||
started_at: input.started_at,
|
||||
current_period_start: null,
|
||||
current_period_end: null,
|
||||
quantity: input.quantity,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ApiFeatureV1 } from "@api/features/apiFeatureV1";
|
||||
|
||||
export const findCreditSystemsByFeatureId = ({
|
||||
featureId,
|
||||
creditSystems,
|
||||
}: {
|
||||
featureId: string;
|
||||
creditSystems: ApiFeatureV1[];
|
||||
}) => {
|
||||
return creditSystems.filter((creditSystem) =>
|
||||
creditSystem.credit_schema?.some(
|
||||
(schema) => schema.metered_feature_id === featureId,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { FeatureNotFoundError } from "@api/errors/classes/featureErrClasses.js";
|
||||
import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js";
|
||||
import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1.js";
|
||||
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
|
||||
import { featureUtils } from "@utils/index";
|
||||
import { subtractIncludedFromTiers } from "@utils/productV2Utils/productItemUtils/tierUtils.js";
|
||||
import type { SharedContext } from "../../../../types/sharedContext.js";
|
||||
|
||||
/**
|
||||
* Converts V1 plan item params (CreatePlanItemParamsV1) to V0 response format (ApiPlanItemV0)
|
||||
*/
|
||||
export function planItemParamsV1ToPlanItemV0({
|
||||
ctx,
|
||||
item,
|
||||
}: {
|
||||
ctx: SharedContext;
|
||||
item: CreatePlanItemParamsV1;
|
||||
}): ApiPlanItemV0 {
|
||||
const { features } = ctx;
|
||||
|
||||
const feature = features.find((f) => f.id === item.feature_id);
|
||||
if (!feature) {
|
||||
throw new FeatureNotFoundError({ featureId: item.feature_id });
|
||||
}
|
||||
|
||||
const isAllocatedFeature = featureUtils.isAllocated(feature);
|
||||
|
||||
const included = item.included ?? 0;
|
||||
|
||||
// V1 API: tier `to` values INCLUDE included usage.
|
||||
// Internal: tier `to` values do NOT include included usage.
|
||||
const internalTiers = item.price?.tiers
|
||||
? subtractIncludedFromTiers({ tiers: item.price.tiers, included })
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
feature_id: item.feature_id,
|
||||
granted_balance: included,
|
||||
unlimited: item.unlimited ?? false,
|
||||
|
||||
reset: item.reset
|
||||
? {
|
||||
interval: item.reset.interval,
|
||||
interval_count: item.reset.interval_count,
|
||||
reset_when_enabled: !isAllocatedFeature,
|
||||
}
|
||||
: null,
|
||||
|
||||
price: item.price
|
||||
? {
|
||||
amount: item.price.amount,
|
||||
tiers: internalTiers,
|
||||
tier_behavior: item.price.tier_behavior,
|
||||
interval: item.price.interval,
|
||||
interval_count: item.price.interval_count,
|
||||
billing_units: item.price.billing_units ?? 1,
|
||||
usage_model: billingMethodToUsageModel(item.price.billing_method),
|
||||
max_purchase: item.price.max_purchase ?? null,
|
||||
}
|
||||
: null,
|
||||
|
||||
rollover: item.rollover
|
||||
? {
|
||||
max: item.rollover.max ?? null,
|
||||
max_percentage: item.rollover.max_percentage ?? null,
|
||||
expiry_duration_type: item.rollover.expiry_duration_type,
|
||||
expiry_duration_length: item.rollover.expiry_duration_length,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
proration: item.proration,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user