diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 0141ef708..056dd9c68 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -25,4 +25,4 @@ jobs: run: bun install - name: Run Knip - run: bun knip --no-exit-code + run: bun knip diff --git a/.github/workflows/server-typecheck.yml b/.github/workflows/server-typecheck.yml index 9fb4283f2..a081ea20f 100644 --- a/.github/workflows/server-typecheck.yml +++ b/.github/workflows/server-typecheck.yml @@ -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: diff --git a/.github/workflows/server-unit-tests.yml b/.github/workflows/server-unit-tests.yml index bdb743a1d..2c99faaec 100644 --- a/.github/workflows/server-unit-tests.yml +++ b/.github/workflows/server-unit-tests.yml @@ -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: diff --git a/.github/workflows/validate-schema.yml b/.github/workflows/validate-schema.yml new file mode 100644 index 000000000..0a087cae0 --- /dev/null +++ b/.github/workflows/validate-schema.yml @@ -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 diff --git a/.github/workflows/vite-build.yml b/.github/workflows/vite-build.yml index 71214f9c0..35917b017 100644 --- a/.github/workflows/vite-build.yml +++ b/.github/workflows/vite-build.yml @@ -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: diff --git a/.github/workflows/vite-unit-tests.yml b/.github/workflows/vite-unit-tests.yml index db4708dc5..cdae25ad9 100644 --- a/.github/workflows/vite-unit-tests.yml +++ b/.github/workflows/vite-unit-tests.yml @@ -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: diff --git a/.husky/pre-commit b/.husky/pre-commit index d71478d5d..abc76b154 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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 diff --git a/apps/checkout/src/components/checkout/layout/CardBackground.tsx b/apps/checkout/src/components/checkout/layout/CardBackground.tsx deleted file mode 100644 index 578a3c50b..000000000 --- a/apps/checkout/src/components/checkout/layout/CardBackground.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { ReactNode } from "react"; -import { motion } from "motion/react"; - -/** - * Full-screen background wrapper with subtle diagonal gradients from primary color. - * Includes entrance animation for the content container. - */ -export function CardBackground({ children }: { children: ReactNode }) { - return ( -
- {/* Top-right diagonal gradient */} -
- ); -} diff --git a/apps/checkout/src/components/ui/card.tsx b/apps/checkout/src/components/ui/card.tsx deleted file mode 100644 index 5b4c79868..000000000 --- a/apps/checkout/src/components/ui/card.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const cardVariants = { - default: "ring-foreground/10 bg-card ring-1 shadow-[0_0_10px_2px_rgba(0,0,0,0.02)]", - muted: "bg-muted/50", -} - -function Card({ - className, - size = "default", - variant = "default", - ...props -}: React.ComponentProps<"div"> & { - size?: "default" | "sm" - variant?: "default" | "muted" -}) { - return ( -
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", - cardVariants[variant], - className - )} - {...props} - /> - ) -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, -} diff --git a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx index a781761ac..c4320c63f 100644 --- a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx @@ -8,7 +8,7 @@ import { DynamicResponseField } from "/components/dynamic-response-field.jsx"; import { DynamicResponseExample } from "/components/dynamic-response-example.jsx"; - 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. ### Common Use Cases diff --git a/apps/docs/mintlify/api-reference/plans/createPlan.mdx b/apps/docs/mintlify/api-reference/plans/createPlan.mdx index 3d136f99c..1d1561772 100644 --- a/apps/docs/mintlify/api-reference/plans/createPlan.mdx +++ b/apps/docs/mintlify/api-reference/plans/createPlan.mdx @@ -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 diff --git a/apps/docs/mintlify/api-reference/plans/updatePlan.mdx b/apps/docs/mintlify/api-reference/plans/updatePlan.mdx index 93974b37f..f28cfe93c 100644 --- a/apps/docs/mintlify/api-reference/plans/updatePlan.mdx +++ b/apps/docs/mintlify/api-reference/plans/updatePlan.mdx @@ -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. Updates create a new plan version by default. Existing customers remain on their current version until their subscription renews or they explicitly upgrade. diff --git a/apps/docs/mintlify/cli/getting-started.mdx b/apps/docs/mintlify/cli/getting-started.mdx index 661f98c91..03201c41c 100644 --- a/apps/docs/mintlify/cli/getting-started.mdx +++ b/apps/docs/mintlify/cli/getting-started.mdx @@ -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`. Complete reference for features, plans, and plan features diff --git a/apps/docs/mintlify/documentation/concepts/subscriptions.mdx b/apps/docs/mintlify/documentation/concepts/subscriptions.mdx index 3552a2b15..5052e0ddb 100644 --- a/apps/docs/mintlify/documentation/concepts/subscriptions.mdx +++ b/apps/docs/mintlify/documentation/concepts/subscriptions.mdx @@ -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 | - + Learn about the checkout and payment flow - + Handle upgrades, downgrades and cancellations diff --git a/apps/docs/mintlify/documentation/customers/feature-entities.mdx b/apps/docs/mintlify/documentation/customers/feature-entities.mdx index 545c34f9d..f5908aa4a 100644 --- a/apps/docs/mintlify/documentation/customers/feature-entities.mdx +++ b/apps/docs/mintlify/documentation/customers/feature-entities.mdx @@ -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. **Example** diff --git a/apps/docs/mintlify/documentation/customers/managing-balances.mdx b/apps/docs/mintlify/documentation/customers/managing-balances.mdx index dd7272aaa..2125c28cc 100644 --- a/apps/docs/mintlify/documentation/customers/managing-balances.mdx +++ b/apps/docs/mintlify/documentation/customers/managing-balances.mdx @@ -76,7 +76,7 @@ curl -X POST https://api.useautumn.com/v1/balances/create \ -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 \ -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 diff --git a/apps/docs/mintlify/documentation/customers/payment-flow.mdx b/apps/docs/mintlify/documentation/customers/payment-flow.mdx index d12efb96c..e563c6645 100644 --- a/apps/docs/mintlify/documentation/customers/payment-flow.mdx +++ b/apps/docs/mintlify/documentation/customers/payment-flow.mdx @@ -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. diff --git a/apps/docs/mintlify/documentation/customers/updating-subscriptions.mdx b/apps/docs/mintlify/documentation/customers/updating-subscriptions.mdx index 3d6012d7c..b5555316b 100644 --- a/apps/docs/mintlify/documentation/customers/updating-subscriptions.mdx +++ b/apps/docs/mintlify/documentation/customers/updating-subscriptions.mdx @@ -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. diff --git a/apps/docs/mintlify/documentation/modelling-pricing/add-ons.mdx b/apps/docs/mintlify/documentation/modelling-pricing/add-ons.mdx index 61de922fa..f4024e26e 100644 --- a/apps/docs/mintlify/documentation/modelling-pricing/add-ons.mdx +++ b/apps/docs/mintlify/documentation/modelling-pricing/add-ons.mdx @@ -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: diff --git a/apps/docs/mintlify/documentation/modelling-pricing/recurring.mdx b/apps/docs/mintlify/documentation/modelling-pricing/recurring.mdx index 0503a6b17..81c0a377d 100644 --- a/apps/docs/mintlify/documentation/modelling-pricing/recurring.mdx +++ b/apps/docs/mintlify/documentation/modelling-pricing/recurring.mdx @@ -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. @@ -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 diff --git a/apps/docs/mintlify/documentation/modelling-pricing/sub-entity-plans.mdx b/apps/docs/mintlify/documentation/modelling-pricing/sub-entity-plans.mdx index 4f3bced93..cc27369f7 100644 --- a/apps/docs/mintlify/documentation/modelling-pricing/sub-entity-plans.mdx +++ b/apps/docs/mintlify/documentation/modelling-pricing/sub-entity-plans.mdx @@ -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: @@ -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. diff --git a/apps/docs/mintlify/react/hooks/useCustomer.mdx b/apps/docs/mintlify/react/hooks/useCustomer.mdx index f8952d158..6b471c16d 100644 --- a/apps/docs/mintlify/react/hooks/useCustomer.mdx +++ b/apps/docs/mintlify/react/hooks/useCustomer.mdx @@ -126,7 +126,7 @@ export default function UpgradeButton() { Open checkout URL in a new tab instead of redirecting. -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()` diff --git a/apps/docs/mintlify/understanding.mdx b/apps/docs/mintlify/understanding.mdx deleted file mode 100644 index f3e1202e1..000000000 --- a/apps/docs/mintlify/understanding.mdx +++ /dev/null @@ -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: - - - -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). - - -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. - - - - -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. - - - - -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. - - - - -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. - - - - -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! diff --git a/bun.lock b/bun.lock index 07b58e541..e87ea1966 100644 --- a/bun.lock +++ b/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=="], diff --git a/knip.json b/knip.json index 36422be54..d1163be75 100644 --- a/knip.json +++ b/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"] diff --git a/package.json b/package.json index e83ece9ef..bba7cf07f 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/migrations/validate-schema.ts b/scripts/migrations/validate-schema.ts index 4a7d388ae..c32b23935 100644 --- a/scripts/migrations/validate-schema.ts +++ b/scripts/migrations/validate-schema.ts @@ -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"); diff --git a/server/.env.example b/server/.env.example index 3d45661ab..77b5cd4f5 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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= - diff --git a/server/package.json b/server/package.json index eee69a59b..53f066b80 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/external/autumn/autumnCliV2.ts b/server/src/external/autumn/autumnCliV2.ts deleted file mode 100644 index 28f64bafa..000000000 --- a/server/src/external/autumn/autumnCliV2.ts +++ /dev/null @@ -1,542 +0,0 @@ -/** biome-ignore-all lint/suspicious/noExplicitAny: AutumnCliV2 is used for internal testing & scripts */ -import dotenv from "dotenv"; - -dotenv.config(); - -import { - type ApiCustomerV3, - type ApiEntityBillingControlsParams, - type AttachBodyV0, - type CancelBody, - type CheckoutParams, - type CheckoutResponseV0, - type CheckParams, - type CheckQuery, - type CheckResponseV1, - type CreateEntityParams, - type CreateRewardProgram, - CustomerExpand, - EntityExpand, - ErrCode, - type OrgConfig, - type RewardRedemption, - type SetUsageParams, - type TrackParams, -} from "@autumn/shared"; - -class AutumnError extends Error { - message: string; - code: string; - - constructor({ message, code }: { message: string; code: string }) { - super(message); - this.message = message; - this.code = code; - } - - toString(): string { - return `${this.message} (code: ${this.code})`; - } -} - -/** - * Robust Autumn API client (V2) with proper version handling - * - * Key improvements over V1: - * - Properly respects x-api-version header for ALL requests - * - No legacy v1Schema params - * - Cleaner error handling - * - Type-safe version parameter - */ -export class AutumnCliV2 { - private apiKey: string; - public headers: Record; - public baseUrl: string; - public version?: string; - - constructor({ - apiKey, - secretKey, - baseUrl, - version, - orgConfig, - liveUrl = false, - }: { - apiKey?: string; - secretKey?: string; - baseUrl?: string; - version?: string; - orgConfig?: Partial; - liveUrl?: boolean; - } = {}) { - this.apiKey = - apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || ""; - - this.headers = { - Authorization: `Bearer ${this.apiKey}`, - "Content-Type": "application/json", - }; - - this.version = version; - - if (version) { - this.headers["x-api-version"] = version; - } - - if (orgConfig) { - this.headers["org-config"] = JSON.stringify(orgConfig); - } - - this.baseUrl = - baseUrl || - (liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1"); - } - - async get(path: string) { - const response = await fetch(`${this.baseUrl}${path}`, { - headers: this.headers, - }); - - if (response.status !== 200) { - let error: any; - try { - error = await response.json(); - } catch (_e) { - throw new AutumnError({ - message: `GET ${path} failed with status ${response.status}`, - code: ErrCode.InternalError, - }); - } - - throw new AutumnError({ - message: error.message || `GET ${path} failed`, - code: error.code || ErrCode.InternalError, - }); - } - - return response.json(); - } - - async post(path: string, body: any) { - const response = await fetch(`${this.baseUrl}${path}`, { - method: "POST", - headers: this.headers, - body: JSON.stringify(body), - }); - - if (response.status !== 200) { - let error: any; - try { - error = await response.json(); - } catch (_e) { - throw new AutumnError({ - message: `POST ${path} failed with status ${response.status}`, - code: ErrCode.InternalError, - }); - } - - throw new AutumnError({ - message: error.message || `POST ${path} failed`, - code: error.code || ErrCode.InternalError, - }); - } - - return response.json(); - } - - async patch(path: string, body: any) { - const response = await fetch(`${this.baseUrl}${path}`, { - method: "PATCH", - headers: this.headers, - body: JSON.stringify(body), - }); - - if (response.status !== 200) { - let error: any; - try { - error = await response.json(); - } catch (_e) { - throw new AutumnError({ - message: `PATCH ${path} failed with status ${response.status}`, - code: ErrCode.InternalError, - }); - } - - throw new AutumnError({ - message: error.message || `PATCH ${path} failed`, - code: error.code || ErrCode.InternalError, - }); - } - - return response.json(); - } - - async delete( - path: string, - { - deleteInStripe = false, - }: { - deleteInStripe?: boolean; - } = {}, - ) { - const queryParams = deleteInStripe ? "?delete_in_stripe=true" : ""; - const response = await fetch(`${this.baseUrl}${path}${queryParams}`, { - method: "DELETE", - headers: this.headers, - }); - - if (response.status !== 200) { - let error: any; - try { - error = await response.json(); - } catch (_e) { - throw new AutumnError({ - message: `DELETE ${path} failed with status ${response.status}`, - code: ErrCode.InternalError, - }); - } - - throw new AutumnError({ - message: error.message || `DELETE ${path} failed`, - code: error.code || ErrCode.InternalError, - }); - } - - return response.json(); - } - - async createCustomer({ - id, - email, - name, - fingerprint, - }: { - id: string; - email: string; - name: string; - fingerprint?: string; - }) { - return await this.post("/customers", { - id, - email, - name, - fingerprint, - }); - } - - async attach(params: AttachBodyV0) { - return await this.post(`/attach`, params); - } - - async checkout( - params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean }, - ) { - const data = await this.post(`/checkout`, params); - return data as CheckoutResponseV0; - } - - async transfer( - customerId: string, - params: { - from_entity_id?: string; - to_entity_id: string; - product_id: string; - }, - ) { - const data = await this.post(`/customers/${customerId}/transfer`, params); - return data; - } - - async sendEvent({ - customerId, - eventName, - properties, - customer_data, - idempotency_key, - }: { - customerId: string; - eventName: string; - properties?: any; - customer_data?: any; - idempotency_key?: string; - }) { - return await this.post(`/events`, { - customer_id: customerId, - event_name: eventName, - properties, - customer_data, - idempotency_key, - }); - } - - async entitled({ - customerId, - featureId, - quantity, - customer_data, - }: { - customerId: string; - featureId: string; - quantity?: number; - customer_data?: any; - }) { - return await this.post(`/entitled`, { - customer_id: customerId, - feature_id: featureId, - quantity, - customer_data, - }); - } - - customers = { - list: async (params?: { limit?: number; offset?: number }) => { - const queryString = params - ? `?${new URLSearchParams(params as Record).toString()}` - : ""; - return await this.get(`/customers${queryString}`); - }, - - get: async ( - customerId: string, - params?: { - expand?: CustomerExpand[]; - }, - ): Promise< - ApiCustomerV3 & { - invoices: any[]; - } - > => { - const queryParams = new URLSearchParams(); - const defaultParams = { - expand: [CustomerExpand.Invoices], - }; - - const finalParams = { ...defaultParams, ...params }; - if (finalParams.expand) { - queryParams.append("expand", finalParams.expand.join(",")); - } - - return await this.get( - `/customers/${customerId}?${queryParams.toString()}`, - ); - }, - - create: async (customer: { id: string; email?: string; name?: string }) => { - return await this.post(`/customers?with_autumn_id=true`, customer); - }, - - delete: async ( - customerId: string, - { - deleteInStripe = false, - }: { - deleteInStripe?: boolean; - } = {}, - ) => { - return await this.delete(`/customers/${customerId}`, { - deleteInStripe, - }); - }, - }; - - entities = { - get: async (customerId: string, entityId: string) => { - return await this.get( - `/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`, - ); - }, - - create: async ( - customerId: string, - entity: CreateEntityParams | CreateEntityParams[], - ) => { - return await this.post( - `/customers/${customerId}/entities?with_autumn_id=true`, - entity, - ); - }, - - list: async (customerId: string) => { - return await this.get(`/customers/${customerId}/entities`); - }, - - delete: async (customerId: string, entityId: string) => { - return await this.delete(`/customers/${customerId}/entities/${entityId}`); - }, - - update: async ( - customerId: string, - entityId: string, - updates: { - billing_controls?: ApiEntityBillingControlsParams; - }, - ) => { - return await this.post(`/entities.update`, { - customer_id: customerId, - entity_id: entityId, - ...updates, - }); - }, - }; - - products = { - /** - * Get product - respects x-api-version header set in constructor - */ - get: async (productId: string) => { - return await this.get(`/products/${productId}`); - }, - - /** - * Create product - respects x-api-version header - */ - create: async (product: any) => { - return await this.post(`/products`, product); - }, - - /** - * Update product - respects x-api-version header - */ - update: async (productId: string, product: any) => { - return await this.post(`/products/${productId}`, product); - }, - - /** - * Delete product - */ - delete: async (productId: string) => { - return await this.delete(`/products/${productId}`); - }, - - /** - * List products - respects x-api-version header - */ - list: async (params?: { limit?: number; offset?: number }) => { - const queryString = params - ? `?${new URLSearchParams(params as Record).toString()}` - : ""; - return await this.get(`/products${queryString}`); - }, - }; - - rewards = { - get: async (rewardId: string) => { - return await this.get(`/rewards/${rewardId}`); - }, - - create: async (reward: any) => { - return await this.post(`/rewards?legacyStripe=true`, reward); - }, - - delete: async (rewardId: string) => { - return await this.delete(`/rewards/${rewardId}`); - }, - }; - - rewardPrograms = { - create: async (rewardProgram: CreateRewardProgram) => { - return await this.post(`/reward_programs`, rewardProgram); - }, - }; - - referrals = { - createCode: async ({ - customerId, - referralId, - }: { - customerId: string; - referralId: string; - }) => { - return await this.post(`/referrals/code`, { - customer_id: customerId, - program_id: referralId, - }); - }, - - redeem: async ({ - customerId, - code, - }: { - customerId: string; - code: string; - }) => { - return await this.post(`/referrals/redeem`, { - customer_id: customerId, - code, - }); - }, - }; - - redemptions = { - get: async (redemptionId: string) => { - const data = await this.get(`/redemptions/${redemptionId}`); - return data as RewardRedemption; - }, - }; - - events = { - send: async ({ - customerId, - featureId, - value, - properties, - }: { - customerId: string; - featureId: string; - value: number; - properties?: any; - }) => { - return await this.post(`/events`, { - customer_id: customerId, - feature_id: featureId, - value, - properties, - }); - }, - }; - - stripe = { - connect: async (params: { - secret_key: string; - success_url: string; - default_currency: string; - }) => { - return await this.post(`/organization/stripe`, params); - }, - - delete: async () => { - return await this.delete(`/organization/stripe`); - }, - }; - - track = async (params: TrackParams) => { - return await this.post(`/track`, params); - }; - - usage = async (params: SetUsageParams) => { - return await this.post(`/usage`, params); - }; - - check = async ( - params: CheckParams & CheckQuery, - ): Promise => { - return await this.post(`/check`, params); - }; - - attachPreview = async (params: AttachBodyV0) => { - return await this.post(`/attach/preview`, params); - }; - - cancel = async (params: CancelBody) => { - return await this.post(`/cancel`, params); - }; - - migrate = async (params: { - from_product_id: string; - to_product_id: string; - from_version: number; - to_version: number; - }) => { - return await this.post(`/migrations`, params); - }; -} diff --git a/server/src/external/redis/loadCaCert.ts b/server/src/external/redis/loadCaCert.ts deleted file mode 100644 index 40e1d4844..000000000 --- a/server/src/external/redis/loadCaCert.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const loadCaCert = async ({ - caPath, - type, - caValue, -}: { - caPath?: string; - type: "queue" | "cache"; - caValue?: string; -}) => { - try { - if (caValue) { - if (caValue.startsWith("-----BEGIN CERTIFICATE-----")) { - return caValue; - } - - return undefined; - } - - const ca = Bun.file(caPath || `/etc/secrets/${type}-cert.pem`); - const caText = await ca.text(); - - return caText; - } catch (_error) { - return; - } -}; diff --git a/server/src/external/redis/redisFailover.ts b/server/src/external/redis/redisFailover.ts deleted file mode 100644 index 5fcf9c670..000000000 --- a/server/src/external/redis/redisFailover.ts +++ /dev/null @@ -1,283 +0,0 @@ -import type { Redis } from "ioredis"; -import { logger } from "@/external/logtail/logtailUtils.js"; - -// ── Config ────────────────────────────────────────────────────────── -/** How long primary must stay down before we switch to failover. */ -const FAILOVER_THRESHOLD_MS = 60_000; - -/** How long primary must stay healthy before we switch back. */ -const RECOVERY_THRESHOLD_MS = 5_000; - -/** Health-check polling interval. */ -const POLL_INTERVAL_MS = 2_000; - -/** Log a warning if blip count exceeds this in the trailing window. */ -const BLIP_WARN_THRESHOLD = 10; - -/** Trailing window for blip counting. */ -const BLIP_WINDOW_MS = 60 * 60 * 1_000; // 1 hour - -// ── State machine ─────────────────────────────────────────────────── -type FailoverPhase = "NORMAL" | "DEGRADED" | "FAILOVER" | "RECOVERING"; - -type FailoverState = { - phase: FailoverPhase; - active: Redis; - primary: Redis; - failover: Redis | null; - failoverRegion: string | null; - /** Timestamp when the current phase was entered. */ - phaseEnteredAt: number; -}; - -let state: FailoverState; -let primaryHasBeenReady = false; -let pollTimer: ReturnType | null = null; - -/** Tracks timestamps of recent transient blips (DEGRADED → NORMAL). */ -const blipTimestamps: number[] = []; - -// ── Callbacks ─────────────────────────────────────────────────────── -type StateChangeCallback = () => void; -const onChangeCallbacks: StateChangeCallback[] = []; - -/** Register a callback invoked whenever `active` changes. */ -export const onActiveChange = (cb: StateChangeCallback): void => { - onChangeCallbacks.push(cb); -}; - -const notifyChange = (): void => { - for (const cb of onChangeCallbacks) { - try { - cb(); - } catch (err) { - logger.error("[Redis failover] onActiveChange callback threw", { - error: err, - }); - } - } -}; - -// ── Helpers ───────────────────────────────────────────────────────── -const isPrimaryReady = (): boolean => state.primary.status === "ready"; -const isFailoverReady = (): boolean => state.failover?.status === "ready"; - -const setPhase = (phase: FailoverPhase): void => { - state.phase = phase; - state.phaseEnteredAt = Date.now(); -}; - -const msInPhase = (): number => Date.now() - state.phaseEnteredAt; - -const pruneBlips = (): void => { - const cutoff = Date.now() - BLIP_WINDOW_MS; - while (blipTimestamps.length > 0 && blipTimestamps[0] < cutoff) { - blipTimestamps.shift(); - } -}; - -const recordBlip = ({ durationMs }: { durationMs: number }): void => { - blipTimestamps.push(Date.now()); - pruneBlips(); - - logger.warn( - `[Redis failover] Primary blip #${blipTimestamps.length} (recovered in ${durationMs}ms)`, - { - type: "redis_failover_blip", - blipCount: blipTimestamps.length, - durationMs, - }, - ); - - if (blipTimestamps.length >= BLIP_WARN_THRESHOLD) { - logger.error( - `[Redis failover] ${blipTimestamps.length} blips in the last hour — check Redis health`, - { - type: "redis_failover_blip_alert", - blipCount: blipTimestamps.length, - }, - ); - } -}; - -// ── Core poll tick ────────────────────────────────────────────────── -const tick = (): void => { - const ready = isPrimaryReady(); - - switch (state.phase) { - case "NORMAL": { - if (!ready && primaryHasBeenReady) { - setPhase("DEGRADED"); - logger.warn("[Redis failover] Primary unhealthy — entering DEGRADED", { - type: "redis_failover_degraded", - primaryStatus: state.primary.status, - }); - } - break; - } - - case "DEGRADED": { - if (ready) { - // Blip — primary recovered before we had to failover - recordBlip({ durationMs: msInPhase() }); - setPhase("NORMAL"); - break; - } - - if (msInPhase() >= FAILOVER_THRESHOLD_MS) { - if (!state.failover || !isFailoverReady()) { - logger.error( - "[Redis failover] Threshold reached but failover instance not ready", - { - type: "redis_failover_switch", - failoverStatus: state.failover?.status ?? "none", - }, - ); - break; - } - - state.active = state.failover; - setPhase("FAILOVER"); - notifyChange(); - - logger.error( - `[Redis failover] SWITCHED to failover region (${state.failoverRegion})`, - { - type: "redis_failover_switch", - failoverRegion: state.failoverRegion, - }, - ); - } - break; - } - - case "FAILOVER": { - if (ready) { - setPhase("RECOVERING"); - logger.info("[Redis failover] Primary back — entering RECOVERING", { - type: "redis_failover_recovering", - }); - } - break; - } - - case "RECOVERING": { - if (!ready) { - // Primary dropped again — go back to failover - setPhase("FAILOVER"); - logger.warn( - "[Redis failover] Primary dropped during recovery — back to FAILOVER", - { type: "redis_failover_recovery_failed" }, - ); - break; - } - - if (msInPhase() >= RECOVERY_THRESHOLD_MS) { - state.active = state.primary; - setPhase("NORMAL"); - notifyChange(); - - logger.info("[Redis failover] RECOVERED to primary region", { - type: "redis_failover_recovered", - }); - } - break; - } - } -}; - -// ── Public API ────────────────────────────────────────────────────── - -/** Initialize failover. Call once after creating both Redis instances. */ -export const initFailover = ({ - primary, - failover, - failoverRegion, - currentRegion, -}: { - primary: Redis; - failover: Redis | null; - failoverRegion: string | null; - currentRegion: string; -}): void => { - state = { - phase: "NORMAL", - active: primary, - primary, - failover, - failoverRegion, - phaseEnteredAt: Date.now(), - }; - - if (!failover) { - logger.info( - "[Redis failover] No failover region configured — failover disabled", - { type: "redis_failover_init" }, - ); - return; - } - - logger.info( - `[Redis failover] Enabled: primary=${currentRegion}, failover=${failoverRegion}`, - { type: "redis_failover_init", currentRegion, failoverRegion }, - ); - - // Track when primary first connects so we don't failover during startup - primary.on("ready", () => { - primaryHasBeenReady = true; - }); - - // Clear any existing state from a previous init - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - primaryHasBeenReady = false; - blipTimestamps.length = 0; - - // Start the single polling loop - pollTimer = setInterval(tick, POLL_INTERVAL_MS); -}; - -/** Get the currently active Redis instance. */ -export const getActiveRedis = (): Redis => state.active; - -/** Get current failover state (for debug/monitoring). */ -export const getFailoverState = (): { - phase: FailoverPhase; - isUsingFailover: boolean; - failoverRegion: string | null; - primaryStatus: string; - failoverStatus: string | null; - msInPhase: number; - blipsLastHour: number; -} => { - pruneBlips(); - return { - phase: state.phase, - isUsingFailover: state.phase === "FAILOVER" || state.phase === "RECOVERING", - failoverRegion: state.failoverRegion, - primaryStatus: state.primary.status, - failoverStatus: state.failover?.status ?? null, - msInPhase: msInPhase(), - blipsLastHour: blipTimestamps.length, - }; -}; - -/** Force disconnect the primary (for testing). */ -export const disconnectPrimary = (): void => { - state.primary.disconnect(); -}; - -/** Force reconnect the primary (for testing). */ -export const reconnectPrimary = (): void => { - state.primary.connect(); -}; - -/** Stop the polling loop (for testing/cleanup). */ -export const stopFailoverPolling = (): void => { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } -}; diff --git a/server/src/external/redis/utils/index.ts b/server/src/external/redis/utils/index.ts deleted file mode 100644 index 471c3ea10..000000000 --- a/server/src/external/redis/utils/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { RedisUnavailableError } from "./errors.js"; -export { - runRedisOp, - tryRedisOp, - type UnavailableReason, -} from "./runRedisOp.js"; -export { withRedisFailOpen } from "./withRedisFailOpen.js"; diff --git a/server/src/external/stripe/checkoutSessions/operations/getExpandedStripeCheckoutSession.ts b/server/src/external/stripe/checkoutSessions/operations/getExpandedStripeCheckoutSession.ts deleted file mode 100644 index 76b3e74b8..000000000 --- a/server/src/external/stripe/checkoutSessions/operations/getExpandedStripeCheckoutSession.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type Stripe from "stripe"; -import { createStripeCli } from "@/external/connect/createStripeCli"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; - -export const getStripeCheckoutSession = async ({ - ctx, - checkoutSessionId, -}: { - ctx: AutumnContext; - checkoutSessionId: string; -}): Promise => { - const { org, env } = ctx; - const stripeCli = createStripeCli({ org, env }); - - return stripeCli.checkout.sessions.retrieve(checkoutSessionId); -}; diff --git a/server/src/external/stripe/subscriptions/subscriptionItems/index.ts b/server/src/external/stripe/subscriptions/subscriptionItems/index.ts deleted file mode 100644 index 9952ae103..000000000 --- a/server/src/external/stripe/subscriptions/subscriptionItems/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { findSubscriptionItemByAutumnPrice } from "@/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice"; - -export const stripeSubscriptionItemUtils = { - find: { - byAutumnPrice: findSubscriptionItemByAutumnPrice, - }, -}; diff --git a/server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts b/server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts deleted file mode 100644 index b2c46d828..000000000 --- a/server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - InternalError, - isFixedPrice, - type Price, - type Product, - type UsagePriceConfig, -} from "@autumn/shared"; - -import type Stripe from "stripe"; - -type FindSubscriptionItemParams = { - stripeSubscriptionItems: Stripe.SubscriptionItem[]; - price: Price; - product: Product; -}; - -// Overload: errorOnNotFound = true → guaranteed SubscriptionItem -export function findSubscriptionItemByAutumnPrice( - params: FindSubscriptionItemParams & { errorOnNotFound: true }, -): Stripe.SubscriptionItem; - -// Overload: errorOnNotFound = false/undefined → SubscriptionItem | undefined -export function findSubscriptionItemByAutumnPrice( - params: FindSubscriptionItemParams & { errorOnNotFound?: false }, -): Stripe.SubscriptionItem | undefined; - -// Implementation -export function findSubscriptionItemByAutumnPrice({ - stripeSubscriptionItems, - price, - product, - errorOnNotFound, -}: FindSubscriptionItemParams & { errorOnNotFound?: boolean }): - | Stripe.SubscriptionItem - | undefined { - const stripeProductId = product.processor?.id; - - let result: Stripe.SubscriptionItem | undefined; - - if (isFixedPrice(price)) { - const config = price.config; - - result = stripeSubscriptionItems.find((si) => { - return ( - config.stripe_price_id === si.price?.id || - (stripeProductId && si.price?.product === stripeProductId) - ); - }); - } else { - const config = price.config as UsagePriceConfig; - result = stripeSubscriptionItems.find( - (si: Stripe.SubscriptionItem | Stripe.LineItem) => { - return ( - config.stripe_price_id === si.price?.id || - config.stripe_product_id === si.price?.product || - config.stripe_empty_price_id === si.price?.id || - config.stripe_prepaid_price_v2_id === si.price?.id - ); - }, - ); - } - - if (errorOnNotFound && !result) { - throw new InternalError({ - message: `Stripe subscription item not found for price: ${price.id}`, - }); - } - - return result; -} diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts deleted file mode 100644 index f946492be..000000000 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceFinalized.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { ACTIVE_STATUSES } from "@autumn/shared"; - -import type Stripe from "stripe"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { - submitBillingDataToVercel, - submitInvoiceToVercel, -} from "@/external/vercel/misc/vercelInvoicing.js"; -import { logVercelWebhook } from "@/external/vercel/misc/vercelMiddleware.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import { ProductService } from "@/internal/products/ProductService.js"; -import { stripeInvoiceToStripeSubscriptionId } from "../invoices/utils/convertStripeInvoice"; -import { - getFullStripeInvoice, - getStripeExpandedInvoice, -} from "../stripeInvoiceUtils.js"; -import type { StripeWebhookContext } from "../webhookMiddlewares/stripeWebhookContext.js"; - -/** - * Handles invoice.finalized webhook - * - * For regular invoices: Creates Autumn invoice records - * For Vercel custom payment method invoices: Submits invoice to Vercel marketplace for payment processing - */ -export const handleInvoiceFinalized = async ({ - ctx, -}: { - ctx: StripeWebhookContext; -}) => { - const { db, org, env, logger, stripeEvent, stripeCli, fullCustomer } = ctx; - - const invoiceData = stripeEvent.data.object as Stripe.Invoice; - - const invoice = await getFullStripeInvoice({ - stripeCli, - stripeId: invoiceData.id!, - }); - - const features = await FeatureService.list({ - db, - orgId: org.id, - env, - }); - - const subId = stripeInvoiceToStripeSubscriptionId(invoice); - - if (subId) { - const stripeCli = createStripeCli({ org, env }); - // Handle Vercel custom payment method invoices - if (subId && invoice.amount_due > 0) { - const subscription = await stripeCli.subscriptions.retrieve(subId); - - const vercelInstallationId = - subscription.metadata?.vercel_installation_id; - const vercelBillingPlanId = subscription.metadata?.vercel_billing_plan_id; - - if ( - vercelInstallationId && - vercelBillingPlanId && - subscription.default_payment_method - ) { - const paymentMethod = await stripeCli.paymentMethods.retrieve( - subscription.default_payment_method as string, - ); - - // Only process if it's a custom payment method (Vercel) - if (paymentMethod.type === "custom" && fullCustomer) { - logVercelWebhook({ - logger, - org, - event: { - type: "marketplace.invoice.finalized", - id: invoice.id, - }, - }); - - try { - const product = await ProductService.getFull({ - db, - orgId: org.id, - env, - idOrInternalId: vercelBillingPlanId, - }); - - if (!product) { - console.error("Product not found for Vercel billing plan", { - billingPlanId: vercelBillingPlanId, - }); - return; - } - - // Submit billing data to Vercel (detailed usage breakdown) - await submitBillingDataToVercel({ - installationId: vercelInstallationId, - invoice, - customer: fullCustomer, - product, - }); - - // Submit invoice to Vercel - await submitInvoiceToVercel({ - installationId: vercelInstallationId, - invoice, - customer: fullCustomer, - product, - org, - features, - }); - - // Do NOT report payment to Stripe here - we've only submitted the invoice to Vercel - // Vercel will process payment asynchronously and send marketplace.invoice.paid webhook - // handleMarketplaceInvoicePaid will then: - // 1. Create cus_product (user gets access) - // 2. Report payment as "guaranteed" to Stripe - // 3. Attach payment record to invoice (marks it as paid) - } catch (error) { - logger.error("Failed to process Vercel invoice", { - data: { - error: String(error), - invoiceId: invoice.id, - }, - }); - } - } - } - } - const expandedInvoice = await getStripeExpandedInvoice({ - stripeCli, - stripeInvoiceId: invoice.id!, - }); - - const activeProducts = await CusProductService.getByStripeSubId({ - db, - stripeSubId: subId, - orgId: org.id, - env, - inStatuses: ACTIVE_STATUSES, - }); - - if (activeProducts.length === 0) { - return; - } - } -}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts deleted file mode 100644 index 2475ba037..000000000 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { handleStripeInvoiceFinalized } from "./handleStripeInvoiceFinalized"; -export type { InvoiceFinalizedContext } from "./setupInvoiceFinalizedContext"; diff --git a/server/src/internal/billing/attachPreview/attachParamsToChanges.ts b/server/src/internal/billing/attachPreview/attachParamsToChanges.ts deleted file mode 100644 index 6677c7be6..000000000 --- a/server/src/internal/billing/attachPreview/attachParamsToChanges.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type FullCusProduct, isPrepaidPrice } from "@autumn/shared"; - -/** - * Convert cusProduct.options to feature_quantities with actual quantities - * (multiplied by billingUnits for prepaid features) - */ -function cusProductToFeatureQuantities({ - cusProduct, -}: { - cusProduct: FullCusProduct; -}) { - return cusProduct.options.map((option) => { - const cusPrice = cusProduct.customer_prices.find((cp) => { - const cusEnt = cusProduct.customer_entitlements.find( - (ce) => - ce.internal_feature_id === option.internal_feature_id || - ce.entitlement.feature_id === option.feature_id, - ); - return ( - cusEnt && - cp.price.config.internal_feature_id === - cusEnt.entitlement.internal_feature_id - ); - }); - - let quantity = option.quantity; - - if (cusPrice && isPrepaidPrice(cusPrice.price)) { - const billingUnits = cusPrice.price.config.billing_units ?? 1; - quantity = option.quantity * billingUnits; - } - - return { - feature_id: option.feature_id, - quantity, - }; - }); -} diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/buildCreateScheduleExecutionPlan.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/buildCreateScheduleExecutionPlan.ts deleted file mode 100644 index 2d909f8cd..000000000 --- a/server/src/internal/billing/v2/actions/createSchedule/compute/buildCreateScheduleExecutionPlan.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { AutumnBillingPlan } from "@autumn/shared"; -import type { MaterializedScheduledPhase } from "../utils/materializeScheduledPhases"; - -/** Merge immediate billing changes with future scheduled rows for Autumn execution. */ -export const buildCreateScheduleExecutionPlan = ({ - immediateAutumnBillingPlan, - futureScheduledPhases, -}: { - immediateAutumnBillingPlan: AutumnBillingPlan; - futureScheduledPhases: MaterializedScheduledPhase[]; -}): AutumnBillingPlan => ({ - ...immediateAutumnBillingPlan, - insertCustomerProducts: [ - ...immediateAutumnBillingPlan.insertCustomerProducts, - ...futureScheduledPhases.flatMap((phase) => phase.customerProducts), - ], - customPrices: [ - ...(immediateAutumnBillingPlan.customPrices ?? []), - ...futureScheduledPhases.flatMap((phase) => phase.customPrices), - ], - customEntitlements: [ - ...(immediateAutumnBillingPlan.customEntitlements ?? []), - ...futureScheduledPhases.flatMap((phase) => phase.customEntitlements), - ], -}); diff --git a/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts b/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts deleted file mode 100644 index 38ca6fcbf..000000000 --- a/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - addDuration, - BillingVersion, - type CreateScheduleParamsV0, - CusProductStatus, - type FullCustomer, -} from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; -import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; -import { setupAttachProductContext } from "../../attach/setup/setupAttachProductContext"; -import { validateCreateSchedulePhasePlans } from "../errors/validateCreateSchedulePhasePlans"; - -export type MaterializedScheduledPhase = { - starts_at: number; - customerProducts: Awaited>[]; - customPrices: NonNullable< - Awaited>["customPrices"] - >; - customEntitlements: NonNullable< - Awaited>["customEnts"] - >; -}; - -/** Build scheduled customer products for future phases. */ -export const materializeScheduledPhases = async ({ - ctx, - currentEpochMs, - fullCustomer, - phases, -}: { - ctx: AutumnContext; - currentEpochMs: number; - fullCustomer: FullCustomer; - phases: CreateScheduleParamsV0["phases"][number][]; -}): Promise => { - return await Promise.all( - phases.map(async (phase, index) => { - const nextPhaseStartsAt = phases[index + 1]?.starts_at; - const materializedProducts = await Promise.all( - phase.plans.map(async (plan) => { - const { - fullProduct, - customPrices = [], - customEnts: customEntitlements = [], - } = await setupAttachProductContext({ - ctx, - params: plan, - }); - const trialEndsAt = fullProduct.free_trial - ? addDuration({ - now: phase.starts_at, - durationType: fullProduct.free_trial.duration, - durationLength: fullProduct.free_trial.length, - }) - : undefined; - const featureQuantities = setupFeatureQuantitiesContext({ - ctx, - featureQuantitiesParams: { - feature_quantities: plan.feature_quantities, - }, - fullProduct, - initializeUndefinedQuantities: true, - }); - - return { - fullProduct, - customerProduct: initFullCustomerProduct({ - ctx, - initContext: { - fullCustomer, - fullProduct, - featureQuantities, - resetCycleAnchor: phase.starts_at, - freeTrial: fullProduct.free_trial ?? null, - trialEndsAt, - now: currentEpochMs, - billingVersion: BillingVersion.V2, - }, - initOptions: { - startsAt: phase.starts_at, - endedAt: nextPhaseStartsAt, - status: CusProductStatus.Scheduled, - isCustom: - customPrices.length > 0 || customEntitlements.length > 0, - }, - }), - customPrices, - customEntitlements, - }; - }), - ); - validateCreateSchedulePhasePlans({ - fullProducts: materializedProducts.map( - ({ fullProduct }) => fullProduct, - ), - }); - - return { - starts_at: phase.starts_at, - customerProducts: materializedProducts.map( - ({ customerProduct }) => customerProduct, - ), - customPrices: materializedProducts.flatMap( - ({ customPrices }) => customPrices, - ), - customEntitlements: materializedProducts.flatMap( - ({ customEntitlements }) => customEntitlements, - ), - }; - }), - ); -}; diff --git a/server/src/internal/billing/v2/actions/createSchedule/utils/resolveCurrentEpochMs.ts b/server/src/internal/billing/v2/actions/createSchedule/utils/resolveCurrentEpochMs.ts deleted file mode 100644 index fa4587a49..000000000 --- a/server/src/internal/billing/v2/actions/createSchedule/utils/resolveCurrentEpochMs.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getTestClockFrozenTimeMs } from "@/external/stripe/testClocks/utils/convertStripeTestClock"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { CusService } from "@/internal/customers/CusService"; - -/** Resolves "now" for schedule operations, respecting Stripe test clocks in sandbox. */ -export const resolveCurrentEpochMs = async ({ - ctx, - customerId, -}: { - ctx: AutumnContext; - customerId: string; -}): Promise => { - const customer = await CusService.get({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - const testClockMs = await getTestClockFrozenTimeMs({ - ctx, - stripeCustomerId: customer?.processor?.id, - }); - return testClockMs ?? Date.now(); -}; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/calculateUpdateQuantityEntitlementChange.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/calculateUpdateQuantityEntitlementChange.ts deleted file mode 100644 index 6dc53c1c7..000000000 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/calculateUpdateQuantityEntitlementChange.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - customerPriceToBillingUnits, - type FullCustomerEntitlement, - type FullCustomerPrice, - priceToProrationConfig, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; - -/** - * Calculates the entitlement balance change resulting from a quantity update. - * - * Computes balance change as: quantity_difference × billing_units_per_quantity. - * Returns entitlement ID from price association, or undefined if price has no entitlement. - * - * @param quantityDifferenceForEntitlements - Change in quantity (can be negative) - * @param billingUnitsPerQuantity - Multiplier for converting quantities to usage units - * @param customerPrice - Customer's price configuration - * @param customerEntitlements - Array of all entitlements for this customer product - * @returns Entitlement ID and balance change to apply - */ -export const calculateUpdateQuantityEntitlementChange = ({ - quantityDifferenceForEntitlements, - customerPrice, - customerEntitlement, -}: { - quantityDifferenceForEntitlements: number; - customerPrice: FullCustomerPrice; - customerEntitlement: FullCustomerEntitlement; -}): { - customerEntitlementId: string; - customerEntitlementBalanceChange: number; -} => { - const isUpgrade = quantityDifferenceForEntitlements > 0; - - const { shouldApplyProration } = priceToProrationConfig({ - price: customerPrice.price, - isUpgrade, - }); - - // If downgrade and no proration, don't change entitlement balance THIS cycle - if (!isUpgrade && !shouldApplyProration) { - return { - customerEntitlementId: customerEntitlement?.id, - customerEntitlementBalanceChange: 0, - }; - } - - const billingUnits = customerPriceToBillingUnits({ customerPrice }); - const customerEntitlementBalanceChange = new Decimal( - quantityDifferenceForEntitlements, - ) - .mul(billingUnits) - .toNumber(); - - return { - customerEntitlementId: customerEntitlement?.id, - customerEntitlementBalanceChange, - }; -}; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts deleted file mode 100644 index 07b4f5618..000000000 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { AutumnBillingPlan } from "@autumn/shared"; -import { - cusProductToPrices, - ErrCode, - isOneOffPrice, - isPrepaidPrice, - RecaseError, - type UsagePriceConfig, -} from "@autumn/shared"; - -export const handleFeatureQuantityErrors = ({ - autumnBillingPlan, -}: { - autumnBillingPlan: AutumnBillingPlan; -}) => { - const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0]; - if (!newCustomerProduct) return; - - const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct }); - const prepaidPrices = newPrices.filter( - (p) => isPrepaidPrice(p) && !isOneOffPrice(p), - ); - - if (prepaidPrices.length === 0) return; - - const options = newCustomerProduct.options || []; - const missingFeatures: string[] = []; - - for (const price of prepaidPrices) { - const config = price.config as UsagePriceConfig; - const internalFeatureId = config.internal_feature_id; - - // Check if there's an option for this prepaid price - const hasOption = options.some( - (opt) => opt.internal_feature_id === internalFeatureId, - ); - - if (!hasOption) { - // Try to find the feature_id from customer_entitlements - const cusEnt = newCustomerProduct.customer_entitlements?.find( - (ce) => ce.entitlement.internal_feature_id === internalFeatureId, - ); - const featureId = cusEnt?.entitlement.feature_id || internalFeatureId; - missingFeatures.push(featureId); - } - } - - if (missingFeatures.length > 0) { - throw new RecaseError({ - message: `Missing quantity options for prepaid features: ${missingFeatures.join(", ")}`, - code: ErrCode.InvalidOptions, - statusCode: 400, - }); - } -}; diff --git a/server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts b/server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts deleted file mode 100644 index 7d4de5633..000000000 --- a/server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BillingVersion, type FullCustomer } from "@autumn/shared"; -import type Stripe from "stripe"; - -/** Build a minimal BillingContext with just the fields createInvoiceForBilling needs. */ -export const buildMinimalBillingContext = ({ - fullCustomer, - stripeCustomerId, - paymentMethod, -}: { - fullCustomer: FullCustomer; - stripeCustomerId: string; - paymentMethod: Stripe.PaymentMethod; -}) => ({ - fullCustomer, - fullProducts: [], - featureQuantities: [], - currentEpochMs: Date.now(), - billingCycleAnchorMs: "now" as const, - resetCycleAnchorMs: "now" as const, - stripeCustomer: { id: stripeCustomerId } as Stripe.Customer, - paymentMethod, - billingVersion: BillingVersion.V2, -}); diff --git a/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts b/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts deleted file mode 100644 index 034405faf..000000000 --- a/server/src/internal/billing/v2/utils/billingContextToCheckoutResponse.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { BillingContext, BillingPlan } from "@autumn/shared"; -import { - type CheckoutLineV0, - type CheckoutResponseV0, - CheckoutResponseV0Schema, - orgToCurrency, - toProductItem, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { getPriceEntitlement } from "@/internal/products/prices/priceUtils"; -import { - getProductItemResponse, - getProductResponse, -} from "@/internal/products/productUtils/productResponseUtils/getProductResponse"; -import { notNullish } from "@/utils/genUtils"; -import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview"; - -export const billingContextToCheckoutResponse = async ({ - ctx, - billingContext, - billingPlan, -}: { - ctx: AutumnContext; - billingContext: BillingContext; - billingPlan: BillingPlan; -}): Promise => { - const { fullCustomer, fullProducts, featureQuantities } = billingContext; - const { features, org } = ctx; - const currency = orgToCurrency({ org }); - - // 1. Get primary product (first non-add-on or first product) - const mainProduct = fullProducts.find((p) => !p.is_add_on) ?? fullProducts[0]; - - const product = mainProduct - ? await getProductResponse({ - product: mainProduct, - features, - fullCus: fullCustomer, - currency, - db: ctx.db, - options: featureQuantities, - }) - : null; - - // 2. Build line items from billing plan - const planLineItems = billingPlan.autumn.lineItems ?? []; - - // Collect all prices and entitlements from products for lookup - const allPrices = fullProducts.flatMap((p) => p.prices); - const allEnts = fullProducts.flatMap((p) => p.entitlements); - - const lines: CheckoutLineV0[] = planLineItems - .filter((line) => line.chargeImmediately) - .map((line) => { - const { price } = line.context; - - // Find entitlement for this price - const ent = getPriceEntitlement(price, allEnts); - - // Build product item from price + entitlement - const productItem = toProductItem({ ent, price }); - - return { - description: line.description, - amount: line.amountAfterDiscounts, - item: getProductItemResponse({ - item: productItem, - features, - currency, - withDisplay: true, - options: featureQuantities, - }), - }; - }) - .filter(notNullish); - - // 3. Calculate total - const total = new Decimal(lines.reduce((acc, line) => acc + line.amount, 0)) - .toDecimalPlaces(2) - .toNumber(); - - // 4. Get next cycle preview - const { nextCycle } = billingPlanToNextCyclePreview({ - ctx, - billingContext, - billingPlan, - }); - - // 5. Build options from feature quantities - const options = featureQuantities - .map((fq) => { - const price = allPrices.find( - (p) => - p.config && - "feature_id" in p.config && - (p.config.feature_id === fq.feature_id || - p.config.internal_feature_id === fq.internal_feature_id), - ); - - if (!price) return undefined; - - const billingUnits = - price.config && "billing_units" in price.config - ? price.config.billing_units || 1 - : 1; - - return { - feature_id: fq.feature_id, - quantity: fq.quantity * billingUnits, - }; - }) - .filter(notNullish); - - return CheckoutResponseV0Schema.parse({ - customer_id: fullCustomer.id || fullCustomer.internal_id, - product, - current_product: null, - lines, - options, - total, - currency, - next_cycle: nextCycle, - }); -}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNewActiveCustomerProduct.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNewActiveCustomerProduct.ts deleted file mode 100644 index b7e1ab4dc..000000000 --- a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNewActiveCustomerProduct.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { cp } from "@autumn/shared"; -import type { AutumnBillingPlan } from "@autumn/shared"; - -export const billingPlanToNewActiveCustomerProduct = ({ - autumnBillingPlan, -}: { - autumnBillingPlan: AutumnBillingPlan; -}) => { - return autumnBillingPlan.insertCustomerProducts?.find( - (customerProduct) => cp(customerProduct).hasActiveStatus().valid, - ); -}; diff --git a/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts deleted file mode 100644 index 82f0eed45..000000000 --- a/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { - InsertDbInvoiceLineItem, - InvoiceLineItemDiscount, - LineItem, -} from "@autumn/shared"; - -/** - * Helper for full match case - converts an Autumn LineItem to InsertInvoiceLineItem. - */ -export const billingLineItemToInsertDbLineItem = ({ - lineItem, - invoiceId, - stripeInvoiceId, - stripeLineItemId, -}: { - lineItem: LineItem; - invoiceId: string; - stripeInvoiceId: string; - stripeLineItemId?: string; -}): InsertDbInvoiceLineItem => { - const { context } = lineItem; - - return { - id: lineItem.id, - invoice_id: invoiceId, - stripe_id: stripeLineItemId ?? null, - stripe_invoice_id: stripeInvoiceId, - stripe_product_id: lineItem.stripeProductId ?? null, - stripe_price_id: lineItem.stripePriceId ?? null, - stripe_discountable: context.discountable ?? true, - - amount: lineItem.amount, - amount_after_discounts: lineItem.amountAfterDiscounts, - currency: context.currency, - - total_quantity: lineItem.totalQuantity ?? null, - paid_quantity: lineItem.paidQuantity ?? null, - - description: lineItem.description, - direction: context.direction, - billing_timing: context.billingTiming, - prorated: lineItem.prorated, - - price_id: context.price.id, - customer_product_ids: context.customerProduct?.id - ? [context.customerProduct.id] - : [], - customer_price_ids: context.customerPrice?.id - ? [context.customerPrice.id] - : [], - customer_entitlement_ids: context.customerEntitlement?.id - ? [context.customerEntitlement.id] - : [], - internal_product_id: context.product.internal_id, - product_id: context.product.id, - internal_feature_id: context.feature?.internal_id ?? null, - feature_id: context.feature?.id ?? null, - - effective_period_start: context.effectivePeriod?.start ?? null, - effective_period_end: context.effectivePeriod?.end ?? null, - - discounts: lineItem.discounts.map( - (d): InvoiceLineItemDiscount => ({ - amount_off: d.amountOff, - percent_off: d.percentOff, - stripe_coupon_id: d.stripeCouponId, - }), - ), - }; -}; diff --git a/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts b/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts deleted file mode 100644 index 55ba0e96d..000000000 --- a/server/src/internal/billing/v2/utils/logs/logNextCyclePreview.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { - BillingPreviewResponse, - FullCusProduct, - LineItem, -} from "@autumn/shared"; -import { formatMs } from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { addToExtraLogs } from "@/utils/logging/addToExtraLogs"; -import type { NextCyclePreviewDebug } from "../billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview"; - -const formatCustomerProduct = (customerProduct: FullCusProduct) => - `${customerProduct.product.name} (${customerProduct.product_id}) [${customerProduct.status}]`; - -const formatLineItem = (item: LineItem) => - `${item.description}: ${item.amountAfterDiscounts} (charge: ${item.chargeImmediately})`; - -export const logBillingPreview = ({ - ctx, - allLineItems, - immediateLineItems, - total, - currency, - nextCycleDebug, - nextCycle, -}: { - ctx: AutumnContext; - allLineItems: LineItem[]; - immediateLineItems: LineItem[]; - total: number; - currency: string; - nextCycleDebug: NextCyclePreviewDebug; - nextCycle: BillingPreviewResponse["next_cycle"]; -}) => { - const { - allCustomerProducts, - currentCustomerProducts, - smallestInterval, - anchorMs, - nextCycleStart, - filteredCustomerProducts, - } = nextCycleDebug; - - addToExtraLogs({ - ctx, - extras: { - billingPreview: { - // Immediate charge breakdown - total: `${total} ${currency}`, - allLineItems: - allLineItems.length > 0 ? allLineItems.map(formatLineItem) : "none", - immediateLineItems: - immediateLineItems.length > 0 - ? immediateLineItems.map(formatLineItem) - : "none", - - // Next cycle calculation - nextCycle: { - allCustomerProducts: - allCustomerProducts.map(formatCustomerProduct).join(", ") || "none", - currentCustomerProducts: - currentCustomerProducts.map(formatCustomerProduct).join(", ") || - "none", - smallestInterval: smallestInterval - ? `${smallestInterval.intervalCount} ${smallestInterval.interval}` - : "none (not a subscription)", - anchor: formatMs(anchorMs), - nextCycleStart: nextCycleStart ? formatMs(nextCycleStart) : "n/a", - filteredCustomerProducts: - filteredCustomerProducts.map(formatCustomerProduct).join(", ") || - "none", - result: nextCycle - ? `starts: ${formatMs(nextCycle.starts_at)} | total: ${nextCycle.total} | items: ${nextCycle.line_items.length}` - : "undefined", - }, - }, - }, - }); -}; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts deleted file mode 100644 index 049abb6ed..000000000 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { - type AttachConfig, - calculateProrationAmount, - cusProductToProduct, - customerPriceToCustomerEntitlement, - type Feature, - type FeatureOptions, - type FullCusProduct, - findCusPriceByFeature, - getFeatureInvoiceDescription, - OnDecrease, - priceToInvoiceAmount, - shouldBillNow, - shouldProrate, - type UsagePriceConfig, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import type { Stripe } from "stripe"; -import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; -import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; -import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js"; -import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js"; -import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; - -export const handleQuantityDowngrade = async ({ - ctx, - attachParams, - attachConfig, - cusProduct, - stripeSub, - oldOptions, - newOptions, - subItem, -}: { - ctx: AutumnContext; - attachParams: AttachParams; - attachConfig: AttachConfig; - cusProduct: FullCusProduct; - stripeSub: Stripe.Subscription; - oldOptions: FeatureOptions; - newOptions: FeatureOptions; - subItem: Stripe.SubscriptionItem; -}) => { - const { db, logger, org, features } = ctx; - const { stripeCli, paymentMethod } = attachParams; - - const cusPrice = findCusPriceByFeature({ - internalFeatureId: newOptions.internal_feature_id!, - cusPrices: cusProduct.customer_prices, - })!; - - const onDecrease = - cusPrice.price.proration_config?.on_decrease || - OnDecrease.ProrateImmediately; - - const subItemDifference = new Decimal(newOptions.quantity) - .minus( - notNullish(oldOptions.upcoming_quantity) - ? oldOptions.upcoming_quantity! - : oldOptions.quantity, - ) - .toNumber(); - - const billingUnits = - (cusPrice.price.config as UsagePriceConfig).billing_units || 1; - - const newSubItemQuantity = new Decimal(subItem.quantity || 0) - .plus(subItemDifference) - .toNumber(); - - let invoice = null; - const createDowngradeInvoice = async () => { - const { start, end } = subToPeriodStartEnd({ sub: stripeSub }); - - const prevAmount = priceToInvoiceAmount({ - price: cusPrice.price, - quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(), - }); - - const newAmount = priceToInvoiceAmount({ - price: cusPrice.price, - quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(), - }); - - let amount = new Decimal(newAmount).minus(prevAmount).toNumber(); - - amount = calculateProrationAmount({ - periodEnd: end * 1000, - periodStart: start * 1000, - now: attachParams.now || Date.now(), - amount, - allowNegative: true, - }); - - const product = cusProductToProduct({ cusProduct }); - const feature = features.find( - (f: Feature) => f.internal_id === newOptions.internal_feature_id, - )!; - const invoiceItem = constructStripeInvoiceItem({ - ctx, - product, - amount: amount, - price: cusPrice.price, - description: getFeatureInvoiceDescription({ - feature: feature, - usage: newOptions.quantity, - billingUnits: (cusPrice.price.config as UsagePriceConfig).billing_units, - prodName: product.name, - isPrepaid: true, - fromUnix: attachParams.now, - }), - stripeSubId: stripeSub.id, - stripeCustomerId: stripeSub.customer as string, - periodStart: Math.floor( - attachParams.now ? attachParams.now / 1000 : Date.now(), - ), - periodEnd: Math.floor(end * 1000), - }); - - logger.info( - `🔥 Creating downgrade prepaid invoice item: ${invoiceItem.description} - ${amount}`, - ); - - await stripeCli.invoiceItems.create(invoiceItem); - - if (shouldBillNow(onDecrease)) { - const { invoice: finalInvoice } = await createAndFinalizeInvoice({ - stripeCli, - stripeCusId: stripeSub.customer as string, - stripeSubId: stripeSub.id, - paymentMethod: paymentMethod || null, - chargeAutomatically: !attachConfig.invoiceOnly, - logger, - }); - - invoice = finalInvoice; - - try { - const invoiceItems = await getInvoiceItems({ - stripeInvoice: finalInvoice, - prices: [cusPrice.price], - logger, - }); - - await InvoiceService.createInvoiceFromStripe({ - db, - stripeInvoice: finalInvoice, - internalCustomerId: cusProduct.internal_customer_id!, - internalEntityId: cusProduct.internal_entity_id, - productIds: [cusProduct.product_id], - internalProductIds: [cusProduct.internal_product_id], - org, - sendRevenueEvent: true, - items: invoiceItems, - }); - } catch (error) { - logger.error(`Failed to create invoice from stripe: ${error}`); - } - } - }; - - await stripeCli.subscriptionItems.update(subItem.id, { - quantity: Math.max(newSubItemQuantity, 0), - // proration_behavior: stripeProration, - proration_behavior: "none", - }); - - if (!shouldProrate(onDecrease)) { - newOptions.upcoming_quantity = newOptions.quantity; - newOptions.quantity = oldOptions.quantity; - return; - } - - await createDowngradeInvoice(); - - const cusEnt = customerPriceToCustomerEntitlement({ - customerPrice: cusPrice, - customerEntitlements: cusProduct.customer_entitlements, - }); - - if (cusEnt) { - const decrementBy = new Decimal(oldOptions.quantity) - .minus(new Decimal(newOptions.quantity)) - .mul(billingUnits) - .toNumber(); - - await CusEntService.decrement({ - ctx, - id: cusEnt.id, - amount: decrementBy, - }); - } -}; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts deleted file mode 100644 index 236a8f184..000000000 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { - type AttachConfig, - calculateProrationAmount, - cusProductToProduct, - customerPriceToCustomerEntitlement, - type Feature, - type FeatureOptions, - type FullCusProduct, - type FullCustomerPrice, - getFeatureInvoiceDescription, - OnIncrease, - priceToInvoiceAmount, - shouldBillNow, - shouldProrate, - type UsagePriceConfig, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import type { Stripe } from "stripe"; -import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; -import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; -import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js"; -import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js"; -import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; - -export const handleQuantityUpgrade = async ({ - ctx, - attachParams, - cusProduct, - stripeSubs, - attachConfig, - oldOptions, - newOptions, - cusPrice, - stripeSub, - subItem, -}: { - ctx: AutumnContext; - attachParams: AttachParams; - cusProduct: FullCusProduct; - attachConfig: AttachConfig; - stripeSubs: Stripe.Subscription[]; - oldOptions: FeatureOptions; - newOptions: FeatureOptions; - cusPrice: FullCustomerPrice; - stripeSub: Stripe.Subscription; - subItem: Stripe.SubscriptionItem; -}) => { - // Manually calculate prorations... - const { features, org, logger, db } = ctx; - const { stripeCli, now, paymentMethod } = attachParams; - - const difference = new Decimal(newOptions.quantity) - .minus(oldOptions.quantity) - .toNumber(); - - const subItemDifference = new Decimal(newOptions.quantity) - .minus( - notNullish(oldOptions.upcoming_quantity) - ? oldOptions.upcoming_quantity! - : oldOptions.quantity, - ) - .toNumber(); - - const onIncrease = - cusPrice.price.proration_config?.on_increase || - OnIncrease.ProrateImmediately; - - const prorate = shouldProrate(onIncrease); - const config = cusPrice.price.config as UsagePriceConfig; - const billingUnits = config.billing_units || 1; - - let invoice = null; - if (prorate && stripeSub?.status !== "trialing") { - const { start, end } = subToPeriodStartEnd({ sub: stripeSub }); - - const prevAmount = priceToInvoiceAmount({ - price: cusPrice.price, - quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(), - }); - - const newAmount = priceToInvoiceAmount({ - price: cusPrice.price, - quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(), - }); - - let amount = new Decimal(newAmount).minus(prevAmount).toNumber(); - if (prorate) { - amount = calculateProrationAmount({ - periodEnd: end * 1000, - periodStart: start * 1000, - now: now || Date.now(), - amount, - }); - } - - const feature = features.find( - (f: Feature) => f.internal_id === newOptions.internal_feature_id, - )!; - - const product = cusProductToProduct({ cusProduct }); - const invoiceItem = constructStripeInvoiceItem({ - ctx, - product, - amount: amount, - price: cusPrice.price, - description: getFeatureInvoiceDescription({ - feature: feature, - usage: newOptions.quantity, - billingUnits, - prodName: product.name, - isPrepaid: true, - fromUnix: now, - }), - stripeSubId: stripeSub.id, - stripeCustomerId: stripeSub.customer as string, - periodStart: Math.floor((now || Date.now()) / 1000), - periodEnd: Math.floor(end * 1000), - }); - - logger.info( - `🔥 Creating prepaid invoice item: ${invoiceItem.description} - ${amount}`, - ); - - await stripeCli.invoiceItems.create(invoiceItem); - - if (shouldBillNow(onIncrease)) { - const { invoice: finalInvoice } = await createAndFinalizeInvoice({ - stripeCli, - stripeCusId: stripeSub.customer as string, - stripeSubId: stripeSub.id, - paymentMethod: paymentMethod || null, - chargeAutomatically: !attachConfig.invoiceOnly, - logger, - }); - - try { - const invoiceItems = await getInvoiceItems({ - stripeInvoice: finalInvoice, - prices: [cusPrice.price], - logger, - }); - - await InvoiceService.createInvoiceFromStripe({ - db, - stripeInvoice: finalInvoice, - internalCustomerId: cusProduct.internal_customer_id!, - internalEntityId: cusProduct.internal_entity_id, - productIds: [cusProduct.product_id], - internalProductIds: [cusProduct.internal_product_id], - org, - sendRevenueEvent: true, - items: invoiceItems, - }); - } catch (error) { - logger.error(`Failed to create invoice from stripe: ${error}`); - } - invoice = finalInvoice; - } - } - - // 1. If no sub item, add - if (!subItem) { - if (!cusPrice.price.config.stripe_price_id) { - throw new Error( - "Trying to add new sub item for upgrade quantity flow, but no Stripe price ID found", - ); - } - - await stripeCli.subscriptionItems.create({ - subscription: stripeSub.id, - price: cusPrice.price.config.stripe_price_id, - quantity: subItemDifference, - proration_behavior: "none", - }); - } else { - await stripeCli.subscriptionItems.update(subItem.id, { - // quantity: newOptions.quantity, - quantity: (subItem.quantity || 0) + subItemDifference, - proration_behavior: "none", - }); - } - - // Update cus ent - - const cusEnt = customerPriceToCustomerEntitlement({ - customerPrice: cusPrice, - customerEntitlements: cusProduct.customer_entitlements, - }); - - if (cusEnt) { - const incrementBy = new Decimal(difference).mul(billingUnits).toNumber(); - logger.info( - `🔥 Incrementing feature ${cusEnt.entitlement.feature.id} balance by ${incrementBy}`, - ); - await CusEntService.increment({ - ctx, - id: cusEnt.id, - amount: incrementBy, - }); - } - return { invoice }; -}; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts deleted file mode 100644 index 1caaf542a..000000000 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - type AttachConfig, - ErrCode, - type FeatureOptions, - type FullCusProduct, - findCusPriceByFeature, -} from "@autumn/shared"; -import type { Stripe } from "stripe"; -import { stripeSubscriptionItemUtils } from "@/external/stripe/subscriptions/subscriptionItems/index.js"; -import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import RecaseError from "@/utils/errorUtils.js"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; -import { handleQuantityDowngrade } from "./handleQuantityDowngrade.js"; -import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js"; - -export const handleUpdateFeatureQuantity = async ({ - ctx, - attachParams, - attachConfig, - cusProduct, - stripeSubs, - oldOptions, - newOptions, -}: { - ctx: AutumnContext; - attachParams: AttachParams; - attachConfig: AttachConfig; - cusProduct: FullCusProduct; - stripeSubs: Stripe.Subscription[]; - oldOptions: FeatureOptions; - newOptions: FeatureOptions; -}) => { - const subToUpdate = stripeSubs?.[0]; - - const cusPrice = findCusPriceByFeature({ - internalFeatureId: newOptions.internal_feature_id!, - cusPrices: cusProduct.customer_prices, - })!; - - const price = cusPrice.price; - - if (!subToUpdate) { - throw new RecaseError({ - message: `Failed to update prepaid quantity for ${newOptions.feature_id} because no subscription found`, - code: ErrCode.InternalError, - statusCode: 500, - }); - } - - const subItem = stripeSubscriptionItemUtils.find.byAutumnPrice({ - stripeSubscriptionItems: subToUpdate.items.data, - price, - product: cusProduct.product, - errorOnNotFound: true, - }); - - if (newOptions.quantity < oldOptions.quantity) { - return await handleQuantityDowngrade({ - ctx, - attachParams, - attachConfig, - cusProduct, - stripeSub: subToUpdate, - oldOptions, - newOptions, - subItem, - }); - } else { - return await handleQuantityUpgrade({ - ctx, - attachParams, - attachConfig, - cusProduct, - stripeSubs, - oldOptions, - newOptions, - cusPrice, - stripeSub: subToUpdate, - subItem, - }); - } -}; diff --git a/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts b/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts deleted file mode 100644 index bb8ceab72..000000000 --- a/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { AttachConfig, FullCusProduct } from "@autumn/shared"; -import type Stripe from "stripe"; -import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import type { AttachParams } from "../../cusProducts/AttachParams.js"; -import { CusProductService } from "../../cusProducts/CusProductService.js"; -import { paramsToScheduleItems } from "./paramsToScheduleItems.js"; -import { getCusProductsToRemove } from "./paramsToSubItems.js"; - -export const subToNewSchedule = async ({ - ctx, - sub, - attachParams, - config, - endOfBillingPeriod, - removeCusProducts, -}: { - ctx: AutumnContext; - sub: Stripe.Subscription; - attachParams: AttachParams; - config: AttachConfig; - endOfBillingPeriod: number; - removeCusProducts?: FullCusProduct[]; -}) => { - const itemSet = await getStripeSubItems2({ - attachParams, - config, - }); - - let cusProductsToRemove: FullCusProduct[] = []; - cusProductsToRemove = getCusProductsToRemove({ - attachParams, - includeCanceled: true, - }); - - console.log( - `REMOVING CUS PRODUCTS: ${cusProductsToRemove.map((cp) => `${cp.product.id} (E: ${cp.entity_id})`).join(", ")}`, - ); - - const res = await paramsToScheduleItems({ - ctx, - sub, - attachParams, - config, - removeCusProducts: removeCusProducts || cusProductsToRemove, - billingPeriodEnd: endOfBillingPeriod, - }); - - const { stripeCli } = attachParams; - let newSchedule: Stripe.SubscriptionSchedule | undefined; - - // if (sub.cancel_at) { - // logger.info(`UNCANCELING SUB ${sub.id}`); - // await stripeCli.subscriptions.update(sub.id, { - // cancel_at: null, - // }); - // } - - // console.log("New phase"); - // await logPhases({ - // phases: res.phases, - // db: req.db, - // }); - // throw new Error("test"); - - if (res.phases[0].items.length > 0) { - itemSet.subItems = res.phases[0].items; - - // Create schedule from existing subscription - newSchedule = await stripeCli.subscriptionSchedules.create({ - from_subscription: sub.id, - }); - - // console.log("SChedule ID: ", newSchedule.id); - - // const newScheduleId = "sub_sched_1RxyM89mx3u0jkgOgbbsAbDS"; - const newScheduleId = newSchedule.id; - await stripeCli.subscriptionSchedules.update(newScheduleId, { - phases: [ - { - items: newSchedule.phases[0].items.map((item) => { - const priceId = item.price as string; - - // Re-apply metadata from subscription items since - // Stripe's from_subscription doesn't copy item metadata - const subItem = sub.items.data.find( - (si) => si.price.id === priceId, - ); - const metadata = - subItem?.metadata && Object.keys(subItem.metadata).length > 0 - ? subItem.metadata - : item.metadata && Object.keys(item.metadata).length > 0 - ? item.metadata - : undefined; - - return { - price: priceId, - quantity: item.quantity, - ...(metadata && { metadata }), - }; - }), - start_date: newSchedule.phases[0].start_date, - end_date: endOfBillingPeriod, - trial_end: sub?.trial_end || undefined, - }, - { - items: res.phases[0].items, - start_date: endOfBillingPeriod, - }, - ], - end_behavior: "release", - }); - - await CusProductService.updateByStripeSubId({ - db: ctx.db, - stripeSubId: sub.id!, - updates: { - scheduled_ids: [newSchedule!.id], - }, - }); - } - - return newSchedule as Stripe.SubscriptionSchedule; -}; - -// phases: ([ -// { -// items: scheduleItems.items, -// // Set proration behavior for this phase transition -// proration_behavior: "create_prorations", // Options: 'create_prorations', 'none', 'always_invoice' -// // Optional: Set how long this phase should last -// iterations: 1, // Number of billing cycles for this phase -// // Optional: Add metadata for this phase -// metadata: { -// phase_type: "scheduled_update", -// created_by: "attach_flow", -// }, -// }, -// ], - -// Option 2: Use your existing createSubSchedule function -// newSchedule = await createSubSchedule({ -// db: req.db, -// attachParams, -// itemSet, -// endOfBillingPeriod, -// }); diff --git a/server/src/internal/customers/cusProducts/cusEnts/repos/customerEntitlementRepo.ts b/server/src/internal/customers/cusProducts/cusEnts/repos/customerEntitlementRepo.ts deleted file mode 100644 index 8cfc9b574..000000000 --- a/server/src/internal/customers/cusProducts/cusEnts/repos/customerEntitlementRepo.ts +++ /dev/null @@ -1 +0,0 @@ -export const customerEntitlementRepo = {}; diff --git a/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments copy.ts b/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments copy.ts deleted file mode 100644 index 0e4062e24..000000000 --- a/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments copy.ts +++ /dev/null @@ -1,495 +0,0 @@ -import { type SQL, sql } from "drizzle-orm"; -import { getEntityOptionsAggregateFragments } from "./getEntityOptionsAggregateFragments.js"; - -/** - * Per-entity rollover rows sourced from the `rollovers` table, mirroring the - * four branches in `entity_balance_rows` (product-attached / loose, per-entity - * jsonb / top-level). Top-level rollovers are attributed to the owning entity - * via `cp.internal_entity_id` (product-attached) or `ce.internal_entity_id` - * (loose), matching main-balance behaviour. Also exposes per-entity and - * per-feature rollups used by the outer aggregate CTEs. - */ -const buildEntityRolloverCtes = ({ - statusFilter, -}: { - statusFilter: SQL; -}) => sql` - entity_rollover_rows AS ( - -- Product-attached cusEnt, per-entity rollover (rollovers.entities jsonb) - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - kv.entity_key AS entity_key, - COALESCE((kv.entity_value->>'balance')::numeric, 0) AS rollover_balance, - COALESCE((kv.entity_value->>'usage')::numeric, 0) AS rollover_usage - FROM rollovers r - JOIN customer_entitlements ce ON r.cus_ent_id = ce.id - JOIN customer_products cp ON ce.customer_product_id = cp.id - CROSS JOIN LATERAL jsonb_each(r.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - AND jsonb_typeof(r.entities) = 'object' - AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ${statusFilter} - - UNION ALL - - -- Product-attached cusEnt, top-level rollover (attributed to cp.internal_entity_id) - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - cp.internal_entity_id AS entity_key, - r.balance::numeric AS rollover_balance, - COALESCE(r.usage, 0)::numeric AS rollover_usage - FROM rollovers r - JOIN customer_entitlements ce ON r.cus_ent_id = ce.id - JOIN customer_products cp ON ce.customer_product_id = cp.id - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ${statusFilter} - - UNION ALL - - -- Loose cusEnt (no customer_product), per-entity rollover - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - kv.entity_key AS entity_key, - COALESCE((kv.entity_value->>'balance')::numeric, 0) AS rollover_balance, - COALESCE((kv.entity_value->>'usage')::numeric, 0) AS rollover_usage - FROM rollovers r - JOIN customer_entitlements ce ON r.cus_ent_id = ce.id - CROSS JOIN LATERAL jsonb_each(r.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND jsonb_typeof(r.entities) = 'object' - AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - - UNION ALL - - -- Loose cusEnt, top-level rollover (attributed to ce.internal_entity_id) - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - ce.internal_entity_id AS entity_key, - r.balance::numeric AS rollover_balance, - COALESCE(r.usage, 0)::numeric AS rollover_usage - FROM rollovers r - JOIN customer_entitlements ce ON r.cus_ent_id = ce.id - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND (r.expires_at IS NULL OR r.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ), - - entity_rollover_keys AS ( - SELECT - internal_feature_id, - internal_customer_id, - entity_key, - SUM(rollover_balance) AS rollover_balance, - SUM(rollover_usage) AS rollover_usage - FROM entity_rollover_rows - WHERE entity_key IS NOT NULL - GROUP BY internal_feature_id, internal_customer_id, entity_key - ), - - entity_rollover_feature AS ( - SELECT - internal_feature_id, - internal_customer_id, - SUM(rollover_balance) AS rollover_balance, - SUM(rollover_usage) AS rollover_usage - FROM entity_rollover_rows - GROUP BY internal_feature_id, internal_customer_id - ) -`; - -/** - * Per-feature/per-entity aggregates sourced strictly from `ce.entities` JSON. - * This powers the `entities` map in customer-level aggregates and intentionally - * excludes top-level entity attribution paths. - */ -const buildEntityEntitiesCtes = ({ - statusFilter, -}: { - statusFilter: SQL; -}) => sql` - entity_entities_rows AS ( - -- Product-attached entitlements: aggregate directly from ce.entities keys - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - kv.entity_key AS entity_key, - COALESCE((kv.entity_value->>'balance')::numeric, 0) AS entity_balance, - COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment, - COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - AND jsonb_typeof(ce.entities) = 'object' - ${statusFilter} - - UNION ALL - - -- Loose entitlements: aggregate directly from ce.entities keys - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - kv.entity_key AS entity_key, - COALESCE((kv.entity_value->>'balance')::numeric, 0) AS entity_balance, - COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment, - COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance - FROM customer_entitlements ce - CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND jsonb_typeof(ce.entities) = 'object' - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ), - - entity_entities_aggregate_keys AS ( - SELECT - internal_feature_id, - internal_customer_id, - entity_key, - SUM(entity_balance) AS balance, - SUM(entity_adjustment) AS adjustment, - SUM(entity_additional_balance) AS additional_balance - FROM entity_entities_rows - WHERE entity_key IS NOT NULL - GROUP BY internal_feature_id, internal_customer_id, entity_key - ) -`; - -export const getEntityAggregateFragments = ({ - entityId, - statusFilter, -}: { - entityId?: string; - statusFilter: SQL; -}) => { - if (entityId) { - return { - ctes: sql``, - productRefsUnion: sql``, - entitlementRefsUnion: sql``, - priceRefsUnion: sql``, - freeTrialRefsUnion: sql``, - selectColumns: sql``, - }; - } - - const entityOptionsAggregateFragments = getEntityOptionsAggregateFragments(); - - const ctes = sql`, - - entity_distinct_product_ids AS ( - SELECT DISTINCT cp.internal_product_id, cp.internal_customer_id - FROM customer_products cp - JOIN subject_customer_records scr - ON cp.internal_customer_id = scr.internal_id - WHERE cp.internal_entity_id IS NOT NULL - ${statusFilter} - ), - - entity_distinct_cus_products AS ( - SELECT sub.* - FROM entity_distinct_product_ids edpi - JOIN LATERAL ( - SELECT cp.* - FROM customer_products cp - WHERE cp.internal_customer_id = edpi.internal_customer_id - AND cp.internal_product_id = edpi.internal_product_id - AND cp.internal_entity_id IS NOT NULL - ${statusFilter} - ORDER BY cp.created_at DESC - LIMIT 1 - ) sub ON true - ), - - entity_cus_products_for_options AS ( - SELECT cp.* - FROM customer_products cp - JOIN subject_customer_records scr - ON cp.internal_customer_id = scr.internal_id - WHERE cp.internal_entity_id IS NOT NULL - ${statusFilter} - ), - - entity_cus_prices AS ( - SELECT cpr.* - FROM customer_prices cpr - WHERE cpr.customer_product_id IN (SELECT id FROM entity_distinct_cus_products) - ), - - ${entityOptionsAggregateFragments.ctes}, - - entity_balance_rows AS ( - SELECT - COALESCE(ce.external_id, ce.id) AS api_id, - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - COALESCE(ent.allowance, 0)::numeric AS allowance, - ce.balance::numeric AS balance, - ce.adjustment::numeric AS adjustment, - COALESCE(ce.additional_balance, 0)::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - cp.internal_entity_id AS entity_key, - ce.balance::numeric AS entity_balance, - COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment, - COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - JOIN entitlements ent ON ce.entitlement_id = ent.id - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - ${statusFilter} - - UNION ALL - - SELECT - COALESCE(ce.external_id, ce.id) AS api_id, - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - COALESCE(ent.allowance, 0)::numeric AS allowance, - 0::numeric AS balance, - 0::numeric AS adjustment, - 0::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - kv.entity_key AS entity_key, - (kv.entity_value->>'balance')::numeric AS entity_balance, - COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment, - COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - JOIN entitlements ent ON ce.entitlement_id = ent.id - CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - AND jsonb_typeof(ce.entities) = 'object' - ${statusFilter} - - UNION ALL - - SELECT - COALESCE(ce.external_id, ce.id) AS api_id, - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - COALESCE(ent.allowance, 0)::numeric AS allowance, - 0::numeric AS balance, - 0::numeric AS adjustment, - 0::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - kv.entity_key AS entity_key, - (kv.entity_value->>'balance')::numeric AS entity_balance, - COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment, - COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance - FROM customer_entitlements ce - JOIN entitlements ent ON ce.entitlement_id = ent.id - CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value) - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND jsonb_typeof(ce.entities) = 'object' - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - - UNION ALL - - SELECT - COALESCE(ce.external_id, ce.id) AS api_id, - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - COALESCE(ent.allowance, 0)::numeric AS allowance, - ce.balance::numeric AS balance, - COALESCE(ce.adjustment, 0)::numeric AS adjustment, - COALESCE(ce.additional_balance, 0)::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - ce.internal_entity_id AS entity_key, - ce.balance::numeric AS entity_balance, - COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment, - COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance - FROM customer_entitlements ce - JOIN entitlements ent ON ce.entitlement_id = ent.id - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ), - - ${buildEntityRolloverCtes({ statusFilter })}, - - ${buildEntityEntitiesCtes({ statusFilter })}, - - entity_aggregate_keys AS ( - SELECT - COALESCE(ebk.internal_feature_id, erk.internal_feature_id) AS internal_feature_id, - COALESCE(ebk.internal_customer_id, erk.internal_customer_id) AS internal_customer_id, - COALESCE(ebk.entity_key, erk.entity_key) AS entity_key, - COALESCE(ebk.balance, 0) AS balance, - COALESCE(ebk.adjustment, 0) AS adjustment, - COALESCE(ebk.additional_balance, 0) AS additional_balance, - COALESCE(erk.rollover_balance, 0) AS rollover_balance, - COALESCE(erk.rollover_usage, 0) AS rollover_usage - FROM ( - SELECT - internal_feature_id, - internal_customer_id, - entity_key, - SUM(entity_balance) AS balance, - SUM(entity_adjustment) AS adjustment, - SUM(entity_additional_balance) AS additional_balance - FROM entity_balance_rows - WHERE entity_key IS NOT NULL - GROUP BY internal_feature_id, internal_customer_id, entity_key - ) ebk - FULL OUTER JOIN entity_rollover_keys erk - ON erk.internal_feature_id = ebk.internal_feature_id - AND erk.internal_customer_id = ebk.internal_customer_id - AND erk.entity_key = ebk.entity_key - ), - - entity_aggregate_map AS ( - SELECT - ejk.internal_feature_id, - ejk.internal_customer_id, - jsonb_object_agg( - ejk.entity_key, - jsonb_build_object( - 'id', ejk.entity_key, - 'balance', ejk.balance, - 'adjustment', ejk.adjustment, - 'additional_balance', ejk.additional_balance, - 'rollover_balance', COALESCE(erk.rollover_balance, 0), - 'rollover_usage', COALESCE(erk.rollover_usage, 0) - ) - ) AS entities - FROM entity_entities_aggregate_keys ejk - LEFT JOIN entity_rollover_keys erk - ON erk.internal_feature_id = ejk.internal_feature_id - AND erk.internal_customer_id = ejk.internal_customer_id - AND erk.entity_key = ejk.entity_key - GROUP BY ejk.internal_feature_id, ejk.internal_customer_id - ), - - entity_aggregated_cus_entitlements AS ( - SELECT - MIN(ebr.api_id) AS api_id, - ebr.internal_feature_id, - ebr.internal_customer_id, - MIN(ebr.feature_id) AS feature_id, - SUM(ebr.allowance) AS allowance_total, - COALESCE(MAX(epgo.prepaid_grant_from_options), 0) AS prepaid_grant_from_options, - SUM(ebr.balance) AS balance, - SUM(ebr.adjustment) AS adjustment, - SUM(ebr.additional_balance) AS additional_balance, - COALESCE(MAX(erf.rollover_balance), 0) AS rollover_balance, - COALESCE(MAX(erf.rollover_usage), 0) AS rollover_usage, - BOOL_OR(ebr.unlimited) AS unlimited, - BOOL_OR(ebr.usage_allowed) AS usage_allowed, - COUNT(DISTINCT ebr.entity_key) FILTER (WHERE ebr.entity_key IS NOT NULL) AS entity_count, - eam.entities - FROM entity_balance_rows ebr - LEFT JOIN entity_aggregate_map eam - ON eam.internal_feature_id = ebr.internal_feature_id - AND eam.internal_customer_id = ebr.internal_customer_id - LEFT JOIN entity_rollover_feature erf - ON erf.internal_feature_id = ebr.internal_feature_id - AND erf.internal_customer_id = ebr.internal_customer_id - LEFT JOIN entity_prepaid_grant_from_options epgo - ON epgo.internal_feature_id = ebr.internal_feature_id - AND epgo.internal_customer_id = ebr.internal_customer_id - GROUP BY - ebr.internal_feature_id, - ebr.internal_customer_id, - eam.entities - ) - `; - - const productRefsUnion = sql` - UNION ALL - SELECT ecp.internal_customer_id, ecp.internal_product_id - FROM entity_distinct_cus_products ecp - `; - - const entitlementRefsUnion = sql` - UNION - SELECT DISTINCT - ce.internal_customer_id, - ce.entitlement_id - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - WHERE cp.internal_entity_id IS NOT NULL - ${statusFilter} - - UNION - SELECT DISTINCT - ce.internal_customer_id, - ce.entitlement_id - FROM customer_entitlements ce - WHERE ce.customer_product_id IS NULL - AND ce.internal_entity_id IS NOT NULL - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - `; - - const priceRefsUnion = sql` - UNION ALL - SELECT ecpr.price_id, ecp.internal_customer_id - FROM entity_cus_prices ecpr - JOIN entity_distinct_cus_products ecp - ON ecp.id = ecpr.customer_product_id - `; - - const freeTrialRefsUnion = sql` - UNION ALL - SELECT ecp.free_trial_id, ecp.internal_customer_id - FROM entity_distinct_cus_products ecp - WHERE ecp.free_trial_id IS NOT NULL - `; - - const selectColumns = sql`, - - json_build_object( - 'aggregated_customer_products', COALESCE( - ( - SELECT json_agg(row_to_json(ecp)) - FROM entity_distinct_cus_products ecp - WHERE ecp.internal_customer_id = scr.internal_id - ), - '[]'::json - ), - 'aggregated_customer_entitlements', COALESCE( - ( - SELECT json_agg(row_to_json(eace)) - FROM entity_aggregated_cus_entitlements eace - WHERE eace.internal_customer_id = scr.internal_id - ), - '[]'::json - ) - ) AS entity_aggregations - `; - - return { - ctes, - productRefsUnion, - entitlementRefsUnion, - priceRefsUnion, - freeTrialRefsUnion, - selectColumns, - }; -}; diff --git a/server/src/internal/entities/actions/updateEntityDbAndCache.ts b/server/src/internal/entities/actions/updateEntityDbAndCache.ts deleted file mode 100644 index a6c5a412e..000000000 --- a/server/src/internal/entities/actions/updateEntityDbAndCache.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Entity } from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { EntityService } from "@/internal/api/entities/EntityService.js"; - -export const updateEntityDbAndCache = async ({ - ctx, - entity, - updates, -}: { - ctx: AutumnContext; - entity: Entity; - updates: Partial< - Pick - >; -}) => { - const filteredUpdates = Object.fromEntries( - Object.entries(updates).filter(([, value]) => value !== undefined), - ) as Partial< - Pick - >; - - if (Object.keys(filteredUpdates).length === 0) { - return entity; - } - - return EntityService.update({ - db: ctx.db, - internalId: entity.internal_id, - update: filteredUpdates, - }); -}; diff --git a/server/src/internal/misc/debug/debugRouter.ts b/server/src/internal/misc/debug/debugRouter.ts deleted file mode 100644 index 91a0e0182..000000000 --- a/server/src/internal/misc/debug/debugRouter.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { sql } from "drizzle-orm"; -import { Hono } from "hono"; -import { dbCritical, dbGeneral } from "@/db/initDrizzle.js"; -import { - forceDegraded, - forceHealthy, - getPgHealthState, -} from "@/db/pgHealthMonitor.js"; -import { redis } from "@/external/redis/initRedis.js"; -import { - disconnectPrimary, - getFailoverState, - reconnectPrimary, -} from "@/external/redis/redisFailover.js"; -import { orgConfigMiddleware } from "@/honoMiddlewares/orgConfigMiddleware.js"; -import { secretKeyMiddleware } from "@/honoMiddlewares/secretKeyMiddleware.js"; -import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; - -const ALLOWED_ORG_IDS = new Set([ - "org_2sWv2S8LJ9iaTjLI6UtNsfL88Kt", - "org_2rzkkRh7r5dBSaBC101QHG9KDgt", - "org_2vwdxwTdqxRrLEdUYddcynMv3n3", -]); - -export const debugRouter = new Hono(); - -debugRouter.use("*", secretKeyMiddleware); -debugRouter.use("*", orgConfigMiddleware); - -debugRouter.get("/memory", async (c) => { - const ctx = c.get("ctx"); - - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - const mem = process.memoryUsage(); - - return c.json({ - ok: true, - pid: process.pid, - timestamp: new Date().toISOString(), - memory: { - rssMB: +(mem.rss / 1024 / 1024).toFixed(1), - heapUsedMB: +(mem.heapUsed / 1024 / 1024).toFixed(1), - heapTotalMB: +(mem.heapTotal / 1024 / 1024).toFixed(1), - externalMB: +(mem.external / 1024 / 1024).toFixed(1), - arrayBuffersMB: +(mem.arrayBuffers / 1024 / 1024).toFixed(1), - }, - }); -}); - -/** Check what statement_timeout the DB connections actually see. */ -debugRouter.get("/statement-timeout", async (c) => { - if (process.env.NODE_ENV === "production") { - return c.json({ error: "Not available in production" }, 403); - } - - const ctx = c.get("ctx"); - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - const criticalResult = await dbCritical.execute(sql`SHOW statement_timeout`); - const generalResult = await dbGeneral.execute(sql`SHOW statement_timeout`); - - return c.json({ - ok: true, - critical: criticalResult[0], - general: generalResult[0], - }); -}); - -/** - * Pool isolation test endpoint. Runs pg_sleep or SELECT 1 on a specific pool. - * Blocked in production. - */ -debugRouter.post("/pool-test", async (c) => { - if (process.env.NODE_ENV === "production") { - return c.json({ error: "Not available in production" }, 403); - } - - const ctx = c.get("ctx"); - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - if (process.env.DATABASE_URL?.includes("us-east-2")) { - return c.json({ error: "Not available against production database" }, 403); - } - - const body = await c.req.json<{ - action: "sleep" | "ping" | "cpu"; - pool: "general" | "critical"; - seconds?: number; - /** Row count for CPU burn (default 5_000_000). Higher = more CPU time. */ - rows?: number; - }>(); - - const { action, pool, seconds = 5, rows = 5_000_000 } = body; - const db = pool === "critical" ? dbCritical : dbGeneral; - const start = Date.now(); - - try { - if (action === "sleep") { - await db.execute(sql`SELECT pg_sleep(${seconds})`); - } else if (action === "cpu") { - // CPU-intensive: hash millions of rows. Burns real CPU on the DB. - await db.execute( - sql`SELECT count(*) FROM generate_series(1, ${rows}) AS s WHERE md5(s::text) IS NOT NULL`, - ); - } else { - await db.execute(sql`SELECT 1`); - } - - return c.json({ - ok: true, - pool, - action, - durationMs: Date.now() - start, - }); - } catch (error) { - return c.json({ - ok: false, - pool, - action, - durationMs: Date.now() - start, - error: error instanceof Error ? error.message : String(error), - }); - } -}); - -/** - * Redis failover test endpoints. Blocked in production. - */ -debugRouter.post("/redis-failover", async (c) => { - if (process.env.NODE_ENV === "production") { - return c.json({ error: "Not available in production" }, 403); - } - - const ctx = c.get("ctx"); - - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - const body = await c.req.json<{ - action: "status" | "kill-primary" | "recover-primary" | "ping"; - }>(); - - if (body.action === "status") { - return c.json({ ok: true, ...getFailoverState() }); - } - - if (body.action === "kill-primary") { - disconnectPrimary(); - return c.json({ ok: true, message: "Primary disconnected" }); - } - - if (body.action === "recover-primary") { - reconnectPrimary(); - return c.json({ ok: true, message: "Primary reconnect triggered" }); - } - - if (body.action === "ping") { - const start = Date.now(); - try { - await redis.ping(); - return c.json({ - ok: true, - durationMs: Date.now() - start, - ...getFailoverState(), - }); - } catch (error) { - return c.json({ - ok: false, - durationMs: Date.now() - start, - error: error instanceof Error ? error.message : String(error), - ...getFailoverState(), - }); - } - } - - return c.json({ error: "Unknown action" }, 400); -}); - -/** - * PG health monitor test endpoints. Blocked in production. - */ -debugRouter.post("/pg-health", async (c) => { - if (process.env.NODE_ENV === "production") { - return c.json({ error: "Not available in production" }, 403); - } - - const ctx = c.get("ctx"); - if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { - return c.json({ error: "Forbidden" }, 403); - } - - const body = await c.req.json<{ - action: "status" | "force-degraded" | "force-healthy"; - }>(); - - if (body.action === "status") { - return c.json({ ok: true, ...getPgHealthState() }); - } - - if (body.action === "force-degraded") { - forceDegraded(); - return c.json({ - ok: true, - message: "Forced DEGRADED", - ...getPgHealthState(), - }); - } - - if (body.action === "force-healthy") { - forceHealthy(); - return c.json({ - ok: true, - message: "Forced HEALTHY", - ...getPgHealthState(), - }); - } - - return c.json({ error: "Unknown action" }, 400); -}); - -/** Write a V8 heap snapshot to disk. Requires secret key auth + dev-only. */ -// debugRouter.get("/heap-snapshot", async (c) => { -// if (process.env.NODE_ENV === "production") { -// return c.json({ error: "Not available in production" }, 403); -// } - -// const ctx = c.get("ctx"); -// if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) { -// return c.json({ error: "Forbidden" }, 403); -// } - -// const snapshotDir = new URL("../../../perf/snapshots/", import.meta.url) -// .pathname; -// const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); -// const filename = `heap-${timestamp}-pid${process.pid}.heapsnapshot`; -// const filepath = `${snapshotDir}${filename}`; - -// writeHeapSnapshot(filepath); - -// return c.json({ ok: true, file: filename, path: filepath }); -// }); diff --git a/server/src/internal/products/planRouter.ts b/server/src/internal/products/planRouter.ts deleted file mode 100644 index fc194853c..000000000 --- a/server/src/internal/products/planRouter.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Hono } from "hono"; -import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js"; -import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js"; -import { handleCreatePlan } from "./handlers/handleCreateProduct/handleCreatePlan.js"; -import { handleCreatePlanV2 } from "./handlers/handleCreateProduct/handleCreatePlanV2.js"; -import { handleDeletePlanV1 } from "./handlers/handleDeletePlan/handleDeletePlanV1.js"; -import { handleDeletePlanV2 } from "./handlers/handleDeletePlan/handleDeletePlanV2.js"; -import { handleGetPlanV1 } from "./handlers/handleGetPlan/handleGetPlanV1.js"; -import { handleGetPlanV2 } from "./handlers/handleGetPlan/handleGetPlanV2.js"; -import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js"; -import { handleListPlansV2 } from "./handlers/handleListPlans/handleListPlansV2.js"; -import { handleListPlans } from "./handlers/handleListPlans.js"; -import { handleMigrateProductV2 } from "./handlers/handleMigrateProductV2.js"; -import { handleUpdatePlanV1 } from "./handlers/handleUpdatePlan/handleUpdatePlanV1.js"; -import { handleUpdatePlanV2 } from "./handlers/handleUpdatePlan/handleUpdatePlanV2.js"; - -export const honoProductBetaRouter = new Hono(); -honoProductBetaRouter.get("", ...handleListPlans); - -// Create a Hono app for products -export const honoProductRouter = new Hono(); -export const migrationRouter = new Hono(); - -// Migrations -migrationRouter.post("/migrations", ...handleMigrateProductV2); - -// CRUD -honoProductRouter.get("", ...handleListPlans); -honoProductRouter.post("", ...handleCreatePlan); -honoProductRouter.get("/:product_id", ...handleGetPlanV1); -honoProductRouter.post("/:product_id", ...handleUpdatePlanV1); // will be deprecated -honoProductRouter.patch("/:product_id", ...handleUpdatePlanV1); // will be deprecated -honoProductRouter.delete("/:product_id", ...handleDeletePlanV1); - -// Others -honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2); - -// Info before deleting plan -honoProductRouter.get( - "/:product_id/has_customers", - ...handlePlanHasCustomersV2, -); -honoProductRouter.post( - "/:product_id/has_customers", - ...handlePlanHasCustomersV2, -); -honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo); - -// RPC -export const plansRpcRouter = new Hono(); -plansRpcRouter.post("/plans.get", ...handleGetPlanV2); -plansRpcRouter.post("/plans.list", ...handleListPlansV2); -plansRpcRouter.post("/plans.create", ...handleCreatePlanV2); -plansRpcRouter.post("/plans.update", ...handleUpdatePlanV2); -plansRpcRouter.post("/plans.delete", ...handleDeletePlanV2); diff --git a/server/src/utils/importUtils/updateUsages.ts b/server/src/utils/importUtils/updateUsages.ts deleted file mode 100644 index a5c9bdba4..000000000 --- a/server/src/utils/importUtils/updateUsages.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - type FullCustomer, - fullCustomerToCustomerEntitlements, -} from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; - -export const updateUsages = async ({ - ctx, - featureId, - usage, - fullCus, -}: { - ctx: AutumnContext; - featureId: string; - usage: number; - fullCus: FullCustomer; -}) => { - const cusEnts = fullCustomerToCustomerEntitlements({ - fullCustomer: fullCus, - inStatuses: RELEVANT_STATUSES, - featureId, - }); - if (cusEnts.length === 0) { - throw new Error(`No cus ent for ${featureId}`); - } - - const cusEnt = cusEnts[0]; - const newBalance = cusEnt.balance! - usage; - - await CusEntService.update({ - ctx, - id: cusEnt.id, - updates: { - balance: newBalance, - }, - }); -}; diff --git a/server/src/utils/scriptUtils/clearOrg.ts b/server/src/utils/scriptUtils/clearOrg.ts deleted file mode 100644 index 85851a970..000000000 --- a/server/src/utils/scriptUtils/clearOrg.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - AppEnv, - customers, - features, - type Organization, - products, -} from "@autumn/shared"; -import { and, eq, inArray } from "drizzle-orm"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; - -export const clearCustomersInBatches = async ({ - db, - org, - batchSize = 450, -}: { - db: DrizzleCli; - org: Organization; - batchSize?: number; -}) => { - let deletedCount = 0; - - while (true) { - // Get a batch of customer IDs to delete - const customerBatch = await db - .select({ internalId: customers.internal_id }) - .from(customers) - .where( - and(eq(customers.org_id, org.id), eq(customers.env, AppEnv.Sandbox)), - ) - .limit(batchSize); - - if (customerBatch.length === 0) { - break; // No more customers to delete - } - - // Delete the batch - const customerIds = customerBatch - .map((c) => c.internalId) - .filter((id) => id !== null); - - console.log("Deleting customers:", customerIds); - - await db - .delete(customers) - .where(inArray(customers.internal_id, customerIds)); - - deletedCount += customerBatch.length; - console.log( - `Deleted ${customerBatch.length} customers (total: ${deletedCount})`, - ); - } - - return deletedCount; -}; - -export const clearOrg = async ({ - db, - org, -}: { - db: DrizzleCli; - org: Organization; -}) => { - const deletedCount = await clearCustomersInBatches({ db, org }); - console.log(`Cleared ${deletedCount} customers`); - - await db - .delete(products) - .where(and(eq(products.org_id, org.id), eq(products.env, AppEnv.Sandbox))); - - console.log("Cleared products"); - - await db - .delete(features) - .where(and(eq(features.org_id, org.id), eq(features.env, AppEnv.Sandbox))); - - console.log("Cleared features"); -}; diff --git a/server/src/utils/scriptUtils/genScriptUtils.ts b/server/src/utils/scriptUtils/genScriptUtils.ts deleted file mode 100644 index b367b5d25..000000000 --- a/server/src/utils/scriptUtils/genScriptUtils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import csv from "csv-parser"; -import fs from "fs"; - -export const parseCsv = ({ - path, - delimiter = ",", -}: { - path: string; - delimiter?: string; -}) => { - return new Promise((resolve, reject) => { - const stream = fs.createReadStream(path); - const results: any[] = []; - const headers: string[] = []; - stream - .pipe(csv({ separator: delimiter })) - .on("data", (data) => { - results.push(data); - // if (headers.length === 0) { - // headers = Object.keys(data); - // } else { - // results.push(data); - // } - }) - .on("end", () => resolve(results)) - .on("error", (error) => reject(error)); - }) as Promise; -}; diff --git a/server/src/utils/scriptUtils/getAll/getAllCusProds.ts b/server/src/utils/scriptUtils/getAll/getAllCusProds.ts deleted file mode 100644 index 6c550394a..000000000 --- a/server/src/utils/scriptUtils/getAll/getAllCusProds.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { AppEnv, type FullCusProduct } from "@autumn/shared"; -import { sql } from "drizzle-orm"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; - -const cusProductsQuery = ({ - lastProductId, - internalProductId, - pageSize = 250, -}: { - lastProductId?: string; - internalProductId?: string; - pageSize?: number; -}) => { - // const withStatusFilter = () => { - // return inStatuses - // ? sql`AND cp.status = ANY(ARRAY[${sql.join( - // inStatuses.map((status) => sql`${status}`), - // sql`, `, - // )}])` - // : sql``; - // }; - - return sql` - SELECT - cp.*, - row_to_json(prod) AS product, - - -- Spread customer_prices fields + add price field - COALESCE( - json_agg(DISTINCT ( - to_jsonb(cpr.*) || jsonb_build_object('price', to_jsonb(p.*)) - )) FILTER (WHERE cpr.id IS NOT NULL), - '[]'::json - ) AS customer_prices, - - -- Spread customer_entitlements fields + add entitlement and replaceables - COALESCE( - json_agg(DISTINCT ( - to_jsonb(ce.*) || jsonb_build_object( - 'entitlement', ( - SELECT row_to_json(ent_with_feature) - FROM ( - SELECT e.*, row_to_json(f) AS feature - FROM entitlements e - JOIN features f ON e.internal_feature_id = f.internal_id - WHERE e.id = ce.entitlement_id - ) AS ent_with_feature - ), - 'replaceables', ( - SELECT COALESCE( - json_agg(row_to_json(r)) FILTER (WHERE r.id IS NOT NULL), - '[]'::json - ) - FROM replaceables r - WHERE r.cus_ent_id = ce.id - ) - ) - )) FILTER (WHERE ce.id IS NOT NULL), - '[]'::json - ) AS customer_entitlements, - - -- free_trial - ( - SELECT row_to_json(ft) - FROM free_trials ft - WHERE ft.id = cp.free_trial_id - ) AS free_trial - - FROM customer_products cp - JOIN products prod ON cp.internal_product_id = prod.internal_id - LEFT JOIN customer_prices cpr ON cpr.customer_product_id = cp.id - LEFT JOIN prices p ON cpr.price_id = p.id - LEFT JOIN customer_entitlements ce ON ce.customer_product_id = cp.id - WHERE cp.internal_product_id = ${internalProductId} - ${lastProductId ? sql`AND cp.id < ${lastProductId}` : sql``} - GROUP BY cp.id, prod.* - ORDER BY cp.id DESC - LIMIT ${pageSize} - `; -}; - -export const getAllFullCusProducts = async ({ - db, - internalProductId, -}: { - db: DrizzleCli; - internalProductId: string; -}) => { - let lastProductId = ""; - const allData: any[] = []; - const pageSize = 500; - - while (true) { - const data = await db.execute( - cusProductsQuery({ - lastProductId, - pageSize, - internalProductId, - }), - ); - - if (data.length === 0) break; - - console.log(`Fetched ${data.length} customer products`); - allData.push(...data); - lastProductId = data[data.length - 1].id as string; - } - - return allData as FullCusProduct[]; -}; diff --git a/server/src/utils/scriptUtils/getAll/getAllStripeSubs.ts b/server/src/utils/scriptUtils/getAll/getAllStripeSubs.ts deleted file mode 100644 index 5bd1c2b0e..000000000 --- a/server/src/utils/scriptUtils/getAll/getAllStripeSubs.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { Stripe } from "stripe"; -import { timeout } from "@/utils/genUtils.js"; - -export const getAllStripeSubscriptions = async ({ - numPages, - limit = 100, - stripeCli, - waitForSeconds, -}: { - numPages?: number; - limit?: number; - stripeCli: Stripe; - waitForSeconds?: number; -}) => { - let hasMore = true; - let startingAfter: string | null = null; - const allSubscriptions: any[] = []; - - let pageCount = 0; - while (hasMore) { - const response: any = await stripeCli.subscriptions.list({ - limit, - starting_after: startingAfter || undefined, - expand: ["data.discounts.coupon"], - }); - - if (response.data.length === 0) { - break; - } - - allSubscriptions.push(...response.data); - - hasMore = response.has_more; - startingAfter = response.data[response.data.length - 1].id; - - pageCount++; - if (numPages && pageCount >= numPages) { - break; - } - - console.log("Fetched", allSubscriptions.length, "subscriptions"); - if (waitForSeconds) { - await timeout(1000); - } - } - - return { - subscriptions: allSubscriptions, - total: allSubscriptions.length, - }; -}; -export const getAllStripeSchedules = async ({ - numPages, - limit = 100, - stripeCli, - waitForSeconds, -}: { - numPages?: number; - limit?: number; - stripeCli: Stripe; - waitForSeconds?: number; -}) => { - let hasMore = true; - let startingAfter: string | null = null; - const allSchedules: any[] = []; - - let pageCount = 0; - while (hasMore) { - const response: any = await stripeCli.subscriptionSchedules.list({ - limit, - starting_after: startingAfter || undefined, - expand: ["data.phases.items.price"], - }); - - if (response.data.length === 0) { - break; - } - - allSchedules.push(...response.data); - - hasMore = response.has_more; - startingAfter = response.data[response.data.length - 1].id; - - pageCount++; - if (numPages && pageCount >= numPages) { - break; - } - - console.log("Fetched", allSchedules.length, "schedules"); - if (waitForSeconds) { - await timeout(1000); - } - } - - return { - schedules: allSchedules, - total: allSchedules.length, - }; -}; diff --git a/server/src/utils/scriptUtils/getAll/getAllUsers.ts b/server/src/utils/scriptUtils/getAll/getAllUsers.ts deleted file mode 100644 index 90feab40c..000000000 --- a/server/src/utils/scriptUtils/getAll/getAllUsers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { user } from "@autumn/shared"; -import { desc } from "drizzle-orm"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; - -export const getAllUsers = async (db: DrizzleCli) => { - const users = []; - let offset = 0; - const limit = 200; - - while (true) { - const batch = await db - .select() - .from(user) - .limit(limit) - .offset(offset) - .orderBy(desc(user.createdAt)); - users.push(...batch); - - if (batch.length < limit) { - break; - } - - offset += limit; - console.log(`Fetched ${users.length} users`); - } - return users; -}; diff --git a/server/src/utils/scriptUtils/logUtils/logSubItems.ts b/server/src/utils/scriptUtils/logUtils/logSubItems.ts deleted file mode 100644 index 07dc09a92..000000000 --- a/server/src/utils/scriptUtils/logUtils/logSubItems.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { stripeToAtmnAmount } from "@autumn/shared"; -import { subItemToAutumnInterval } from "@tests/utils/stripeUtils"; -import type Stripe from "stripe"; -import type { Logger } from "../../../external/logtail/logtailUtils"; - -export const logSubItems = ({ - sub, - subItems, - withPriceId = false, - withItemId = false, - logger, -}: { - sub?: Stripe.Subscription; - subItems?: Stripe.SubscriptionItem[]; - withPriceId?: boolean; - withItemId?: boolean; - logger?: Logger | Console; -}) => { - const finalSubItems = subItems || sub!.items.data; - - if (!logger) { - logger = console; - } - - for (const item of finalSubItems) { - const isMetered = item.price.recurring?.usage_type === "metered"; - - const atmnPrice = stripeToAtmnAmount({ - amount: item.price.unit_amount || 0, - currency: item.price.currency, - }); - - if (isMetered) { - logger.info(`Usage price`); - } else { - const price = atmnPrice; - const subInterval = subItemToAutumnInterval(item); - logger.info( - `${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval} ${withPriceId ? `(${item.price.id})` : ""} ${withItemId ? `(${item.id})` : ""}`, - ); - } - } -}; diff --git a/server/src/utils/workerUtils/jobTypes/HandleCustomerCreatedData.ts b/server/src/utils/workerUtils/jobTypes/HandleCustomerCreatedData.ts deleted file mode 100644 index a5a93900e..000000000 --- a/server/src/utils/workerUtils/jobTypes/HandleCustomerCreatedData.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AppEnv } from "@autumn/shared"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; - -export interface HandleCustomerCreatedData { - req: Partial; - orgId: string; - env: AppEnv; - internalCustomerId: string; -} diff --git a/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts b/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts index 7bfcae858..9cd4d1b77 100644 --- a/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts +++ b/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts @@ -61,7 +61,10 @@ mock.module("@/internal/balances/track/v3/runRedisTrackV3.js", () => ({ }, })); -import { runTrackV3 } from "@/internal/balances/track/v3/runTrackV3.js"; +const { runTrackV3 } = await import( + // @ts-expect-error - Bun test cache-busting import query isolates module mocks. + "@/internal/balances/track/v3/runTrackV3.js?runTrackV3Idempotency" +); const ctx = { apiVersion: new ApiVersionClass(ApiVersion.V2_1), diff --git a/server/tinybird/pipes/aggregate_groupable.pipe b/server/tinybird/pipes/aggregate_groupable.pipe index 2202a3263..4e3da59f5 100644 --- a/server/tinybird/pipes/aggregate_groupable.pipe +++ b/server/tinybird/pipes/aggregate_groupable.pipe @@ -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 != '' diff --git a/shared/api/balances/track/changes/V0.2_TrackChange.ts b/shared/api/balances/track/changes/V0.2_TrackChange.ts deleted file mode 100644 index 2cf3f058c..000000000 --- a/shared/api/balances/track/changes/V0.2_TrackChange.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; -import { - AffectedResource, - defineVersionChange, -} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; -import type { z } from "zod/v4"; -import { - TrackResponseV0Schema, - TrackResponseV1Schema, -} from "../prevVersions/trackResponseV1.js"; -import { - type TrackLegacyData, - TrackLegacyDataSchema, -} from "../trackLegacyData.js"; - -/** - * V0_2_CheckChange: Transforms check response TO V0_2 format - * - * Applied when: targetVersion <= V0_2 - * - * Breaking changes introduced in V0.2 (that we reverse here): - * - * 1. Structure: Single check result object → balances array format - * - V0.2+: Single object with allowed, feature_id, balance, unlimited, etc. - * - V0_2: { allowed, balances: [{ feature_id, required, balance, unlimited, usage_allowed }] } - * - * 2. Boolean features: No balance fields → balance: null - * 3. Unlimited features: Return unlimited: true, usage_allowed based on overage_allowed - * 4. Metered features: Return required_balance and balance - * - * Input: CheckResult (V0.2+ format) - * Output: CheckResponseV0 (V0_2 balances array format) - */ - -export const V0_2_CheckChange = defineVersionChange({ - name: "V0.2 Check Change", - newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1 - oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2 - description: [ - "Check response transformed to balances array format", - "Single check result object → { allowed, balances: [...] }", - ], - affectedResources: [AffectedResource.Check], - newSchema: TrackResponseV1Schema, - oldSchema: TrackResponseV0Schema, - legacyDataSchema: TrackLegacyDataSchema, - affectsResponse: true, - - // Response: V1.1+ (CheckResult) → V0_2 (CheckResponseV0) - transformResponse: ({ - input, - legacyData, - }: { - input: z.infer; - legacyData?: TrackLegacyData; - }): z.infer => { - return { - success: true, - }; - }, -}); diff --git a/shared/api/billing/openCustomerPortalParams.ts b/shared/api/billing/openCustomerPortalParams.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/shared/api/customers/cusFeatures/utils/convert/balancesToCheckFeature.ts b/shared/api/customers/cusFeatures/utils/convert/balancesToCheckFeature.ts deleted file mode 100644 index 9f0f341f0..000000000 --- a/shared/api/customers/cusFeatures/utils/convert/balancesToCheckFeature.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ApiBalanceInput } from "@api/customers/cusFeatures/utils/convert/apiBalanceToAllowed"; - -export const balancesToCheckFeature = ({ - balances, -}: { - balances: Record; -}) => { - return balances.map((balance) => { - return { - featureId: balance.featureId, - }; - }); -}; diff --git a/shared/api/customers/cusPlans/changes/V2.0_ApiSubscriptionChange.ts b/shared/api/customers/cusPlans/changes/V2.0_ApiSubscriptionChange.ts deleted file mode 100644 index bfc269261..000000000 --- a/shared/api/customers/cusPlans/changes/V2.0_ApiSubscriptionChange.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { planV1ToV0 } from "@api/products/mappers/planV1ToV0"; -import type { ApiSubscription } from "../apiSubscription"; -import type { ApiSubscriptionV1 } from "../apiSubscriptionV1"; - -export function transformApiSubscriptionV1ToV0({ - input, -}: { - input: ApiSubscriptionV1; -}): ApiSubscription { - return { - plan: input.plan ? planV1ToV0(input.plan) : undefined, - plan_id: input.plan_id, - default: input.auto_enable, - add_on: input.add_on, - status: input.status, - past_due: input.past_due, - canceled_at: input.canceled_at, - expires_at: input.expires_at, - trial_ends_at: input.trial_ends_at, - started_at: input.started_at, - current_period_start: input.current_period_start, - current_period_end: input.current_period_end, - quantity: input.quantity, - }; -} diff --git a/shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts b/shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts deleted file mode 100644 index 9395ab0ba..000000000 --- a/shared/api/customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { planV1ToV0 } from "@api/products/mappers/planV1ToV0"; -import type { SharedContext } from "../../../../types/sharedContext"; -import type { ApiSubscription } from "../apiSubscription"; -import type { ApiPurchaseV0 } from "../apiSubscriptionV1"; - -/** - * Converts an ApiPurchaseV0 to an ApiSubscription (V0) for backwards compatibility. - * Purchases are represented as subscriptions with sensible defaults for missing fields. - */ -export function apiPurchaseV0ToSubscriptionV0({ - ctx, - input, -}: { - ctx: SharedContext; - input: ApiPurchaseV0; -}): ApiSubscription { - return { - plan: input.plan ? planV1ToV0({ ctx, plan: input.plan }) : undefined, - plan_id: input.plan_id, - default: false, - add_on: true, - status: "active", - past_due: false, - canceled_at: null, - expires_at: input.expires_at, - trial_ends_at: null, - started_at: input.started_at, - current_period_start: null, - current_period_end: null, - quantity: input.quantity, - }; -} diff --git a/shared/api/features/utils/findCreditSystemsByFeatureId.ts b/shared/api/features/utils/findCreditSystemsByFeatureId.ts deleted file mode 100644 index b53a89ecf..000000000 --- a/shared/api/features/utils/findCreditSystemsByFeatureId.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ApiFeatureV1 } from "@api/features/apiFeatureV1"; - -export const findCreditSystemsByFeatureId = ({ - featureId, - creditSystems, -}: { - featureId: string; - creditSystems: ApiFeatureV1[]; -}) => { - return creditSystems.filter((creditSystem) => - creditSystem.credit_schema?.some( - (schema) => schema.metered_feature_id === featureId, - ), - ); -}; diff --git a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts deleted file mode 100644 index cfebb2ec4..000000000 --- a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { FeatureNotFoundError } from "@api/errors/classes/featureErrClasses.js"; -import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js"; -import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1.js"; -import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js"; -import { featureUtils } from "@utils/index"; -import { subtractIncludedFromTiers } from "@utils/productV2Utils/productItemUtils/tierUtils.js"; -import type { SharedContext } from "../../../../types/sharedContext.js"; - -/** - * Converts V1 plan item params (CreatePlanItemParamsV1) to V0 response format (ApiPlanItemV0) - */ -export function planItemParamsV1ToPlanItemV0({ - ctx, - item, -}: { - ctx: SharedContext; - item: CreatePlanItemParamsV1; -}): ApiPlanItemV0 { - const { features } = ctx; - - const feature = features.find((f) => f.id === item.feature_id); - if (!feature) { - throw new FeatureNotFoundError({ featureId: item.feature_id }); - } - - const isAllocatedFeature = featureUtils.isAllocated(feature); - - const included = item.included ?? 0; - - // V1 API: tier `to` values INCLUDE included usage. - // Internal: tier `to` values do NOT include included usage. - const internalTiers = item.price?.tiers - ? subtractIncludedFromTiers({ tiers: item.price.tiers, included }) - : undefined; - - return { - feature_id: item.feature_id, - granted_balance: included, - unlimited: item.unlimited ?? false, - - reset: item.reset - ? { - interval: item.reset.interval, - interval_count: item.reset.interval_count, - reset_when_enabled: !isAllocatedFeature, - } - : null, - - price: item.price - ? { - amount: item.price.amount, - tiers: internalTiers, - tier_behavior: item.price.tier_behavior, - interval: item.price.interval, - interval_count: item.price.interval_count, - billing_units: item.price.billing_units ?? 1, - usage_model: billingMethodToUsageModel(item.price.billing_method), - max_purchase: item.price.max_purchase ?? null, - } - : null, - - rollover: item.rollover - ? { - max: item.rollover.max ?? null, - max_percentage: item.rollover.max_percentage ?? null, - expiry_duration_type: item.rollover.expiry_duration_type, - expiry_duration_length: item.rollover.expiry_duration_length, - } - : undefined, - - proration: item.proration, - }; -} diff --git a/shared/api/products/mappers/planV0ToProductV2.ts b/shared/api/products/mappers/planV0ToProductV2.ts deleted file mode 100644 index 5153b42dd..000000000 --- a/shared/api/products/mappers/planV0ToProductV2.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems"; -import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0"; -import type { ProductV2 } from "@models/productV2Models/productV2Models"; -import type { SharedContext } from "../../../types/sharedContext"; - -export function planV0ToProductV2({ - ctx, - plan, -}: { - ctx: SharedContext; - plan: ApiPlan; -}): ProductV2 { - // Convert plan to items using shared utility - const items = planV0ToProductItems({ ctx, plan }); - - // Check if archived field exists on plan (it's on ApiPlan, not CreatePlanParams) - const archived = - "archived" in plan && plan.archived !== undefined - ? plan.archived - : undefined; - - return { - id: plan.id, - name: plan.name, - description: plan.description ?? null, - is_add_on: plan.add_on, - is_default: plan.default, - group: plan.group ?? "", - items, - free_trial: plan.free_trial - ? { - duration: plan.free_trial.duration_type, - length: plan.free_trial.duration_length, - unique_fingerprint: false, - card_required: plan.free_trial.card_required, - } - : null, - ...(archived !== undefined && { archived }), - - version: plan.version, - env: plan.env, - created_at: plan.created_at, - }; -} diff --git a/shared/models/cusModels/fullSubjectModel.ts b/shared/models/cusModels/fullSubjectModel.ts deleted file mode 100644 index 3c5bf4663..000000000 --- a/shared/models/cusModels/fullSubjectModel.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { z } from "zod/v4"; -import { FullAggregatedCustomerEntitlementSchema } from "../cusProductModels/cusEntModels/aggregatedCusEnt.js"; -import { - type FullCustomerEntitlement, - FullCustomerEntitlementSchema, -} from "../cusProductModels/cusEntModels/cusEntModels.js"; -import { CustomerPriceSchema } from "../cusProductModels/cusPriceModels/cusPriceModels.js"; -import { - type FullCusProduct, - FullCusProductSchema, -} from "../cusProductModels/cusProductModels.js"; -import { SubscriptionSchema } from "../subModels/subModels.js"; -import { type Customer, CustomerSchema } from "./cusModels.js"; -import { type Entity, EntitySchema } from "./entityModels/entityModels.js"; -import { InvoiceSchema } from "./invoiceModels/invoiceModels.js"; - -export const SubjectType = { - Customer: "customer", - Entity: "entity", -} as const; -export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType]; - -export const FullSubjectSchema = z.object({ - subjectType: z.enum(["customer", "entity"]), - - customerId: z.string(), - internalCustomerId: z.string(), - entityId: z.string().optional(), - internalEntityId: z.string().optional(), - - customer: CustomerSchema, - entity: EntitySchema.optional(), - - customer_products: z.array(FullCusProductSchema), - extra_customer_entitlements: z.array(FullCustomerEntitlementSchema), - - subscriptions: z.array(SubscriptionSchema).optional(), - invoices: z.array(InvoiceSchema), - - aggregated_customer_products: z.array(FullCusProductSchema).optional(), - aggregated_customer_entitlements: z - .array(FullAggregatedCustomerEntitlementSchema) - .optional(), - aggregated_customer_prices: z.array(CustomerPriceSchema).optional(), -}); - -export type FullSubject = z.infer; - -/** Backward-compat type for entity DB layer files. */ -export type FullEntity = Entity & { - customer: Customer; - customer_products: FullCusProduct[]; - extra_customer_entitlements: FullCustomerEntitlement[]; -}; diff --git a/vite/src/components/forms/attach-product/attach-confirmation-info.tsx b/vite/src/components/forms/attach-product/attach-confirmation-info.tsx deleted file mode 100644 index 06f5adf86..000000000 --- a/vite/src/components/forms/attach-product/attach-confirmation-info.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { CheckoutResponseV0 } from "@autumn/shared"; -import type { ReactNode } from "react"; -import { useIsLatestVersion } from "@/hooks/stores/useProductStore"; -import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils"; -import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; - -export const AttachConfirmationInfo = ({ - previewData, -}: { - previewData?: CheckoutResponseV0 | null; -}) => { - const isLatestVersion = useIsLatestVersion(previewData?.product); - - const renderInfoBoxes = (): ReactNode[] => { - const boxes: ReactNode[] = []; - - if (!previewData) { - return boxes; - } - - if (!isLatestVersion) { - boxes.push( - - You're enabling a previous version (v{previewData.product.version}) of - this plan. - , - ); - } - - // Payment method required - if (previewData.url) { - let secondaryText = ""; - if (previewData.product.free_trial?.card_required === true) { - secondaryText = "to start this trial"; - } else { - secondaryText = "as this plan has prices"; - } - - boxes.push( - - A payment method is required {secondaryText} - , - ); - } - - if (previewData.product.free_trial) { - let secondaryText = ""; - if (previewData.product.free_trial?.card_required === true) { - secondaryText = " and the customer will be charged"; - } else { - secondaryText = " and this plan will expire"; - } - - boxes.push( - - Trial ends {formatUnixToDate(previewData.next_cycle?.starts_at)} - {secondaryText} - , - ); - } - - // Show scenario-based info if switching from another plan and not attaching add-on - if ( - previewData.current_product && - previewData.product && - !previewData.product.is_add_on - ) { - const scenario = previewData.product.scenario as - | "upgrade" - | "downgrade" - | "cancel" - | "new" - | string; - - switch (scenario) { - case "upgrade": - boxes.push( - - This upgrade will immediately replace the customer's current plan:{" "} - {previewData.current_product.name} - , - ); - break; - case "downgrade": - boxes.push( - - This downgrade will replace the customer's current plan:{" "} - {previewData.current_product.name} - , - ); - break; - case "cancel": - boxes.push( - - This will cancel the customer's current billing subscription:{" "} - {previewData.current_product.name} - , - ); - break; - case "new": - boxes.push( - - This will be enabled alongside existing plans{" "} - , - ); - } - } - - if ( - previewData.next_cycle?.starts_at && - previewData.product?.scenario === "downgrade" - ) { - const startsAtString = formatUnixToDate(previewData.next_cycle.starts_at); - - boxes.push( - - Plan change will take effect next cycle, on{" "} - {startsAtString} - , - ); - } - - // If switching products, show info about current product - - return boxes; - }; - - const infoBoxes = renderInfoBoxes(); - - if (infoBoxes.length === 0) { - return null; - } - - return ( -
- {infoBoxes.map((box, index) => ( -
{box}
- ))} -
- ); -}; diff --git a/vite/src/components/forms/attach-product/attach-product-actions.tsx b/vite/src/components/forms/attach-product/attach-product-actions.tsx deleted file mode 100644 index 587dec17d..000000000 --- a/vite/src/components/forms/attach-product/attach-product-actions.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared"; -import type { LucideIcon } from "lucide-react"; -import { ArrowUpRightFromSquare, CircleCheck } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; -import { useAttachProductMutation } from "@/components/forms/attach-product/use-attach-product-mutation"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/v2/buttons/Button"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { useEntity } from "@/hooks/stores/useSubscriptionStore"; -import { useEnv } from "@/utils/envUtils"; -import { openInNewTab } from "@/utils/genUtils"; -import { getStripeInvoiceLink } from "@/utils/linkUtils"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -function getAttachButtonConfig(isCheckout: boolean): { - text: string; - icon: LucideIcon; -} { - return isCheckout - ? { text: "Checkout", icon: ArrowUpRightFromSquare } - : { text: "Confirm", icon: CircleCheck }; -} - -interface AttachProductActionsProps { - form: UseAttachProductForm; - product: ProductV2; - customerId: string; - onSuccess?: () => void; - previewData?: CheckoutResponseV0 | null; - isPreviewLoading?: boolean; -} - -export function AttachProductActions({ - form, - product, - customerId, - onSuccess, - previewData, - isPreviewLoading, -}: AttachProductActionsProps) { - const { stripeAccount } = useOrgStripeQuery(); - const env = useEnv(); - const org = useOrg(); - const { entityId } = useEntity(); - const [activeAction, setActiveAction] = useState<"invoice" | "attach" | null>( - null, - ); - const { closeSheet } = useSheetStore(); - - const ownStripeAccount = org.org?.stripe_connection !== "default"; - - const attachMutation = useAttachProductMutation({ - customerId, - onSuccess: () => { - form.reset(); - setActiveAction(null); - onSuccess?.(); - }, - }); - - const handleAttach = async ({ - useInvoice, - enableProductImmediately, - action, - }: { - useInvoice: boolean; - enableProductImmediately?: boolean; - action: "invoice" | "attach"; - }) => { - const { prepaidOptions } = form.state.values; - setActiveAction(action); - - if (previewData?.url && action === "attach") { - window.open(previewData.url, "_blank"); - setActiveAction(null); - closeSheet(); - return; - } - - try { - const result = await attachMutation.mutateAsync({ - product, - prepaidOptions: prepaidOptions || {}, - useInvoice, - enableProductImmediately, - entityId: entityId ?? undefined, - }); - - // Handle checkout URLs and invoice links - if (result.data.checkout_url) { - openInNewTab({ url: result.data.checkout_url }); - } else if (result.data.invoice) { - const stripeInvoiceUrl = getStripeInvoiceLink({ - stripeInvoice: result.data.invoice, - env, - accountId: stripeAccount?.id, - }); - - openInNewTab({ url: stripeInvoiceUrl }); - toast.success("Redirected to Stripe to finalize the invoice"); - } - } catch (error) { - setActiveAction(null); - throw error; - } - }; - - const isLoading = attachMutation.isPending; - const isInvoiceLoading = isLoading && activeAction === "invoice"; - const isAttachLoading = isLoading && activeAction === "attach"; - - // Don't show buttons if preview is loading - if (isPreviewLoading || !product) { - return null; - } - - const isCheckout = !!previewData?.url; - const { text: attachText, icon: AttachIcon } = - getAttachButtonConfig(isCheckout); - - return ( -
- - - - - -
- - -
-
-
- - -
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-form-schema.ts b/vite/src/components/forms/attach-product/attach-product-form-schema.ts deleted file mode 100644 index 4eae52b28..000000000 --- a/vite/src/components/forms/attach-product/attach-product-form-schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod/v4"; - -export const AttachProductFormSchema = z.object({ - productId: z.string(), - prepaidOptions: z.record(z.string(), z.number().optional()), -}); - -export type AttachProductForm = z.infer; diff --git a/vite/src/components/forms/attach-product/attach-product-form.tsx b/vite/src/components/forms/attach-product/attach-product-form.tsx deleted file mode 100644 index fcdd9c108..000000000 --- a/vite/src/components/forms/attach-product/attach-product-form.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import type { - Entity, - FrontendProduct, - FullCustomer, - ProductV2, -} from "@autumn/shared"; -import { useStore } from "@tanstack/react-form"; -import { FormWrapper } from "@/components/general/form/form-wrapper"; -import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { usePrepaidItems } from "@/hooks/stores/useProductStore"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { useEntity } from "@/hooks/stores/useSubscriptionStore"; -import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; -import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; -import { AttachProductActions } from "./attach-product-actions"; -import { AttachProductPrepaidOptions } from "./attach-product-prepaid-options"; -import { AttachProductSelection } from "./attach-product-selection"; -import { AttachProductSummary } from "./attach-product-summary"; -import { useAttachPreview } from "./use-attach-preview"; -import type { UseAttachProductForm } from "./use-attach-product-form"; -import { useAttachProductForm } from "./use-attach-product-form"; - -interface FormContentProps { - products: ProductV2[]; - customerId: string; - form: UseAttachProductForm; - onSuccess?: () => void; -} - -function FormContent({ - products, - customerId, - form, - onSuccess, -}: FormContentProps) { - const sheetData = useSheetStore((s) => s.data); - const productId = useStore(form.store, (state) => state.values.productId); - const prepaidOptions = useStore( - form.store, - (state) => state.values.prepaidOptions, - ); - - // Use customized product from sheet data if available, otherwise find from products list - const customizedProduct = sheetData?.customizedProduct as - | FrontendProduct - | undefined; - const product = customizedProduct?.id - ? customizedProduct - : products.find((p) => p.id === productId && !p.archived); - - const { prepaidItems } = usePrepaidItems({ product }); - - const { entityId } = useEntity(); - - // Call preview once here and pass data down to children - const previewQuery = useAttachPreview({ - customerId, - product, - entityId: entityId ?? undefined, - prepaidOptions: prepaidOptions ?? undefined, - version: product?.version, - }); - - // Check if there are prepaid items and if any are not set (undefined/null) - // Note: 0 is a valid quantity value - if (prepaidItems.length > 0) { - const hasUnsetPrepaidQuantity = prepaidItems.some((item) => { - const quantity = prepaidOptions?.[item.feature_id as string]; - return quantity === undefined || quantity === null; - }); - - if (hasUnsetPrepaidQuantity) { - return null; - } - } - - if (!form.state.values.productId || !product) { - return null; - } - - return ( - <> - - - - - ); -} - -export function AttachProductForm({ - customerId, - onSuccess, -}: { - customerId: string; - onSuccess?: () => void; -}) { - const itemId = useSheetStore((s) => s.itemId); - const form = useAttachProductForm({ initialProductId: itemId || undefined }); - const { products, isLoading } = useProductsQuery(); - - const activeProducts = products.filter((p) => !p.archived); - - const { entityId } = useEntity(); - const { customer } = useCusQuery(); - - const entities = (customer as FullCustomer).entities || []; - - const fullEntity = entities.find( - (e: Entity) => e.id === entityId || e.internal_id === entityId, - ); - - if (isLoading) { - return
Loading products...
; - } - - return ( - - -
- - - - {entityId ? ( -
- - Attaching plan to entity{" "} - - {fullEntity?.name || fullEntity?.id} - - -
- ) : entities.length > 0 ? ( -
- - Attaching plan to customer - all entities will get access - -
- ) : null} -
-
- - -
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-line-items.tsx b/vite/src/components/forms/attach-product/attach-product-line-items.tsx deleted file mode 100644 index ce87810a0..000000000 --- a/vite/src/components/forms/attach-product/attach-product-line-items.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { CheckoutResponseV0 } from "@autumn/shared"; -import { - SheetAccordion, - SheetAccordionItem, -} from "@/components/v2/sheets/SheetAccordion"; - -export function AttachProductLineItems({ - previewData, -}: { - previewData?: CheckoutResponseV0 | null; -}) { - const lineItems = - previewData?.lines?.map((line) => { - return { - name: line.description || "Unknown", - total: line.amount, - }; - }) || []; - - if (lineItems.length === 0) { - return null; - } - - return ( - - -
- {lineItems.map((item, index) => ( -
- - {item.name} - - - ${item.total.toFixed(2)} - -
- ))} -
-
-
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-prepaid-options.tsx b/vite/src/components/forms/attach-product/attach-product-prepaid-options.tsx deleted file mode 100644 index ea42f3e31..000000000 --- a/vite/src/components/forms/attach-product/attach-product-prepaid-options.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { - type FrontendProductItem, - getFeaturePriceItemDisplay, -} from "@autumn/shared"; -import { useStore } from "@tanstack/react-form"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { - usePrepaidItems, - useProductStore, -} from "@/hooks/stores/useProductStore"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -interface PrepaidOptionsFieldProps { - form: UseAttachProductForm; -} - -export function AttachProductPrepaidOptions({ - form, -}: PrepaidOptionsFieldProps) { - const storeProduct = useProductStore((s) => s.product); - const { products = [] } = useProductsQuery(); - const selectedProductId = useStore( - form.store, - (state) => state.values.productId, - ); - const { org } = useOrg(); - const product = storeProduct?.id - ? storeProduct - : products.find((p) => p.id === selectedProductId && !p.archived); - - const { prepaidItems } = usePrepaidItems({ product }); - - if (prepaidItems.length === 0 || !selectedProductId) { - return null; - } - - return ( -
-
- {prepaidItems.map((item) => { - const display = getFeaturePriceItemDisplay({ - item: item as FrontendProductItem, - feature: item.feature, - currency: org?.default_currency || "USD", - fullDisplay: true, - amountFormatOptions: { - currencyDisplay: "narrowSymbol", - }, - }); - return ( -
- - {display.primary_text} - {display.secondary_text && ` ${display.secondary_text}`} - - - - {(quantityField) => ( - - )} - -
- ); - })} -
-
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-selection.tsx b/vite/src/components/forms/attach-product/attach-product-selection.tsx deleted file mode 100644 index 77ae309a1..000000000 --- a/vite/src/components/forms/attach-product/attach-product-selection.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { - isProductAlreadyEnabled, - isProductCurrentlyAttached, -} from "@autumn/shared"; -import { PencilSimpleIcon } from "@phosphor-icons/react"; -import { useNavigate } from "react-router"; -import { IconButton } from "@/components/v2/buttons/IconButton"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { useHasChanges } from "@/hooks/stores/useProductStore"; -import { useEntity } from "@/hooks/stores/useSubscriptionStore"; -import { pushPage } from "@/utils/genUtils"; -import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; -import { - type UseAttachProductForm, - useResetPrepaidOnProductChange, -} from "./use-attach-product-form"; - -interface AttachProductSelectionProps { - form: UseAttachProductForm; - customerId: string; -} - -export function AttachProductSelection({ - form, - customerId, -}: AttachProductSelectionProps) { - const { products } = useProductsQuery(); - const availableProducts = products.filter((p) => !p.archived); - const navigate = useNavigate(); - const productId = form.state.values.productId; - const hasChanges = useHasChanges(); - const { customer } = useCusQuery(); - const { entityId } = useEntity(); - - useResetPrepaidOnProductChange({ form }); - - const handleCustomize = ({ productId }: { productId: string }) => { - if (!productId || !customerId) { - return; - } - - pushPage({ - path: `/customers/${customerId}/${productId}`, - navigate, - }); - }; - - return ( -
-
- - {(field) => ( - { - const entityIdVal = entityId ?? undefined; - const alreadyEnabled = isProductAlreadyEnabled({ - productId: p.id, - customer, - entityId: entityIdVal, - }); - const currentlyAttached = - !alreadyEnabled && - isProductCurrentlyAttached({ - productId: p.id, - customer, - entityId: entityIdVal, - }); - - return { - label: p.name, - value: p.id, - disabledValue: alreadyEnabled ? "Already Enabled" : undefined, - badgeValue: currentlyAttached ? "Already Enabled" : undefined, - }; - })} - placeholder="Select Product" - hideFieldInfo - selectValueAfter={ - hasChanges && productId ? ( - - Custom - - ) : undefined - } - /> - )} - - -
- state.values.productId}> - {(productId) => ( - } - onClick={() => handleCustomize({ productId })} - disabled={!productId} - type="button" - > - Customize - - )} - -
-
-
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-summary.tsx b/vite/src/components/forms/attach-product/attach-product-summary.tsx deleted file mode 100644 index c964a41c3..000000000 --- a/vite/src/components/forms/attach-product/attach-product-summary.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { CheckoutResponseV0 } from "@autumn/shared"; -import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText"; -import { AttachConfirmationInfo } from "./attach-confirmation-info"; -import { AttachProductLineItems } from "./attach-product-line-items"; -import { AttachProductTotals } from "./attach-product-totals"; - -export function AttachProductSummary({ - previewData, - isLoading, -}: { - previewData?: CheckoutResponseV0 | null; - isLoading?: boolean; -}) { - if (isLoading) { - return ( - - ); - } - - return ( -
- - - {/* */} -
- - -
-
- ); -} diff --git a/vite/src/components/forms/attach-product/attach-product-totals.tsx b/vite/src/components/forms/attach-product/attach-product-totals.tsx deleted file mode 100644 index e6fafc5d4..000000000 --- a/vite/src/components/forms/attach-product/attach-product-totals.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { CheckoutResponseV0 } from "@autumn/shared"; -import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils"; - -export function AttachProductTotals({ - previewData, -}: { - previewData?: CheckoutResponseV0 | null; -}) { - const total = previewData?.total || 0; - const nextCycleTotal = previewData?.next_cycle?.total || 0; - const nextCycleStartsAt = formatUnixToDate( - previewData?.next_cycle?.starts_at || 0, - ); - - return ( -
-
-
Total
-
${total.toFixed(2)}
-
- {nextCycleStartsAt && ( -
-
- Next Cycle ({nextCycleStartsAt}) -
-
- ${nextCycleTotal.toFixed(2)} -
-
- )} -
- ); -} diff --git a/vite/src/components/forms/attach-product/update-product-actions.tsx b/vite/src/components/forms/attach-product/update-product-actions.tsx deleted file mode 100644 index e4418fdcc..000000000 --- a/vite/src/components/forms/attach-product/update-product-actions.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared"; -import type { LucideIcon } from "lucide-react"; -import { ArrowUpRightFromSquare, CircleCheck } from "lucide-react"; -import { useAttachProductMutation } from "@/components/forms/attach-product/use-attach-product-mutation"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/v2/buttons/Button"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; -import { useEnv } from "@/utils/envUtils"; -import { getStripeInvoiceLink } from "@/utils/linkUtils"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -function getUpdateButtonConfig(isCheckout: boolean): { - text: string; - icon: LucideIcon; -} { - return isCheckout - ? { text: "Checkout", icon: ArrowUpRightFromSquare } - : { text: "Confirm Update", icon: CircleCheck }; -} - -interface UpdateProductActionsProps { - product?: ProductV2; - customerId?: string; - entityId?: string; - onSuccess?: () => void; - previewData?: CheckoutResponseV0 | null; - isPreviewLoading?: boolean; - version?: number; - form: UseAttachProductForm; -} - -export function UpdateProductActions({ - form, - product, - customerId, - entityId, - onSuccess, - previewData, - isPreviewLoading, - version, -}: UpdateProductActionsProps) { - const { stripeAccount } = useOrgStripeQuery(); - const env = useEnv(); - const org = useOrg(); - - const isOwnStripeAccount = stripeAccount?.id === org.org?.stripe_connection; - const attachMutation = useAttachProductMutation({ - customerId: customerId ?? "", - successMessage: "Plan updated successfully", - onSuccess: () => { - onSuccess?.(); - }, - }); - - const handleUpdate = async ({ - useInvoice, - enableProductImmediately, - }: { - useInvoice: boolean; - enableProductImmediately?: boolean; - }) => { - // Only redirect to checkout URL for the "Checkout" button flow (useInvoice: false) - // When useInvoice is true, we always call the attach mutation to generate an invoice - if (previewData?.url && !useInvoice) { - window.open(previewData.url, "_blank"); - return; - } - - // Does the update - const result = await attachMutation.mutateAsync({ - product, - entityId, - useInvoice, - enableProductImmediately, - prepaidOptions: form.state.values.prepaidOptions ?? undefined, - version, - }); - - // Handle checkout URLs and invoice links - if (result.data.invoice) { - window.open( - getStripeInvoiceLink({ - stripeInvoice: result.data.invoice, - env, - accountId: stripeAccount?.id, - }), - "_blank", - ); - } - }; - - const isLoading = attachMutation.isPending; - - // Don't show buttons if preview is loading - if (isPreviewLoading || !product) { - return null; - } - - const isCheckout = !!previewData?.url; - const { text: updateText, icon: UpdateIcon } = - getUpdateButtonConfig(isCheckout); - - return ( -
- - - - - -
- - -
-
-
- - -
- ); -} diff --git a/vite/src/components/forms/attach-product/update-product-prepaid-options.tsx b/vite/src/components/forms/attach-product/update-product-prepaid-options.tsx deleted file mode 100644 index ce779bd27..000000000 --- a/vite/src/components/forms/attach-product/update-product-prepaid-options.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { - type FrontendProductItem, - getFeaturePriceItemDisplay, -} from "@autumn/shared"; -import { useOrg } from "@/hooks/common/useOrg"; -import { - usePrepaidItems, - useProductStore, -} from "@/hooks/stores/useProductStore"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -export function UpdateProductPrepaidOptions({ - form, -}: { - form: UseAttachProductForm; -}) { - const storeProduct = useProductStore((s) => s.product); - const itemId = useSheetStore((s) => s.itemId); - - const { org } = useOrg(); - const { productV2 } = useSubscriptionById({ itemId }); - - // Use store product if it has a real ID, otherwise use productV2 from subscription - const product = storeProduct?.id ? storeProduct : (productV2 ?? undefined); - - const { prepaidItems } = usePrepaidItems({ product }); - - if (prepaidItems.length === 0) { - return null; - } - - console.log("prepaidItems", prepaidItems); - - return ( -
-
- {prepaidItems.map((item) => { - const display = getFeaturePriceItemDisplay({ - item: item as FrontendProductItem, - feature: item.feature, - currency: org?.default_currency || "USD", - fullDisplay: true, - amountFormatOptions: { - currencyDisplay: "narrowSymbol", - }, - }); - - return ( -
- - {display.primary_text} - {display.secondary_text && ` ${display.secondary_text}`} - - - - {(quantityField) => ( - - )} - -
- ); - })} -
-
- ); -} diff --git a/vite/src/components/forms/attach-product/update-product-summary.tsx b/vite/src/components/forms/attach-product/update-product-summary.tsx deleted file mode 100644 index 7ffb06d20..000000000 --- a/vite/src/components/forms/attach-product/update-product-summary.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { - PreviewUpdateSubscriptionResponse, - ProductV2, -} from "@autumn/shared"; -import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText"; -import { UpdateConfirmationInfo } from "../update-subscription/update-confirmation-info"; -import { AttachProductLineItems } from "./attach-product-line-items"; -import { AttachProductTotals } from "./attach-product-totals"; -import type { UseAttachProductForm } from "./use-attach-product-form"; - -export function UpdateProductSummary({ - product, - previewData, - isLoading, - form, -}: { - product?: ProductV2; - previewData?: PreviewUpdateSubscriptionResponse | null; - isLoading?: boolean; - form: UseAttachProductForm; -}) { - if (isLoading) { - return ( - - ); - } - - return ( - <> - - -
- - -
- - ); -} diff --git a/vite/src/components/forms/attach-product/use-attach-body-builder.ts b/vite/src/components/forms/attach-product/use-attach-body-builder.ts deleted file mode 100644 index f5444a0cc..000000000 --- a/vite/src/components/forms/attach-product/use-attach-body-builder.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { AppEnv, type ProductV2 } from "@autumn/shared"; -import { useMemo } from "react"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore"; -import { useEntity } from "@/hooks/stores/useSubscriptionStore"; -import { convertPrepaidOptionsToFeatureOptions } from "@/utils/billing/prepaidQuantityUtils"; -import { useEnv } from "@/utils/envUtils"; -import { getRedirectUrl } from "@/utils/genUtils"; -import { getAttachBody } from "@/views/customers/customer/product/components/attachProductUtils"; - -interface AttachBodyBuilderParams { - customerId?: string; - productId?: string; - product?: ProductV2; - entityId?: string; - prepaidOptions?: Record; - version?: number; - useInvoice?: boolean; - enableProductImmediately?: boolean; - successUrl?: string; -} - -/** - * Shared hook to build attach body from explicit params - * Used by both useAttachPreview and useAttachProductMutation to keep logic DRY - */ -export function useAttachBodyBuilder(params: AttachBodyBuilderParams = {}) { - const { products } = useProductsQuery(); - const hasChanges = useHasChanges(); - const storeProduct = useProductStore((s) => s.product); - const { entityId: storeEntityId } = useEntity(); - const env = useEnv(); - const { org, isLoading: isOrgLoading, error: orgError } = useOrg(); - - // Memoized builder function that can be called with runtime params - const buildAttachBody = useMemo( - () => (runtimeParams?: AttachBodyBuilderParams) => { - const mergedParams = { ...params, ...runtimeParams }; - - const redirectUrl = getRedirectUrl( - `/customers/${mergedParams.customerId}`, - env, - ); - - // Resolve the product: use provided product or find by ID - const product = - mergedParams.product || - products.find((p) => p.id === mergedParams.productId); - - if (!product || !mergedParams.customerId) { - return null; - } - - // Determine if this is a custom product (from store with changes) - const isCustom = - hasChanges && !!storeProduct?.id && product === storeProduct - ? true - : undefined; - const version = storeProduct?.id ? storeProduct.version : undefined; - - // Convert prepaidOptions to options array - const options = mergedParams.prepaidOptions - ? convertPrepaidOptionsToFeatureOptions({ - prepaidOptions: mergedParams.prepaidOptions, - product, - }) - : undefined; - - // Build the attach body - return getAttachBody({ - customerId: mergedParams.customerId, - product, - entityId: mergedParams.entityId ?? storeEntityId ?? undefined, - optionsInput: options, - isCustom, - version, - useInvoice: mergedParams.useInvoice, - enableProductImmediately: mergedParams.enableProductImmediately, - successUrl: - // env === AppEnv.Sandbox - // ? `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}` - // : undefined, - org?.success_url && !isOrgLoading && !orgError - ? org.success_url - : env === AppEnv.Sandbox - ? `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}` - : undefined, - }); - }, - [ - products, - hasChanges, - storeProduct, - storeEntityId, - params, - org, - isOrgLoading, - orgError, - ], - ); - - // For simple usage, return the built body with current params - const attachBody = useMemo(() => buildAttachBody(), [buildAttachBody]); - - return { attachBody, buildAttachBody }; -} diff --git a/vite/src/components/forms/attach-product/use-attach-preview.ts b/vite/src/components/forms/attach-product/use-attach-preview.ts deleted file mode 100644 index 4c2296ff2..000000000 --- a/vite/src/components/forms/attach-product/use-attach-preview.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared"; -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; -import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useAttachBodyBuilder } from "./use-attach-body-builder"; - -interface AttachPreviewParams { - // Required params - no fallbacks - customerId?: string; - product?: ProductV2; - entityId?: string; - prepaidOptions?: Record; - version?: number; - - // Control behavior - enabled?: boolean; -} - -export function useAttachPreview(params: AttachPreviewParams = {}) { - const axiosInstance = useAxiosInstance(); - const buildKey = useQueryKeyFactory(); - - // Build attach body using shared hook with explicit params - const { attachBody } = useAttachBodyBuilder({ - customerId: params.customerId, - product: params.product, - entityId: params.entityId, - prepaidOptions: params.prepaidOptions, - version: params.version, - }); - - // Auto-enable if not explicitly set and all required data is present - const shouldEnable = - params.enabled !== undefined - ? params.enabled - : !!(params.customerId && params.product && attachBody); - - // Create a stable serialized key from attachBody (which already captures all dependencies) - const queryKeyDeps = useMemo(() => JSON.stringify(attachBody), [attachBody]); - - // Debounce the query key to delay API calls by 150ms - const [debouncedQueryKey, setDebouncedQueryKey] = useState(queryKeyDeps); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedQueryKey(queryKeyDeps); - }, 300); - return () => clearTimeout(timer); - }, [queryKeyDeps]); - - // Track if we're in a debouncing state (query key has changed but debounce hasn't completed) - const isDebouncing = queryKeyDeps !== debouncedQueryKey; - - const query = useQuery({ - queryKey: buildKey(["attach-checkout", debouncedQueryKey]), - queryFn: async () => { - if (!attachBody || !params.customerId) { - return null; - } - - const response = await axiosInstance.post( - "/v1/checkout", - attachBody, - ); - - return response.data; - }, - enabled: shouldEnable, - staleTime: 0, // Always fetch fresh pricing - }); - - // Override isLoading to include debouncing state - // This prevents showing stale data during the transition between diff plans in the selector - return { - ...query, - isLoading: query.isLoading || isDebouncing, - }; -} diff --git a/vite/src/components/forms/attach-product/use-attach-product-form.ts b/vite/src/components/forms/attach-product/use-attach-product-form.ts deleted file mode 100644 index ccc26e13b..000000000 --- a/vite/src/components/forms/attach-product/use-attach-product-form.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useAppForm } from "@/hooks/form/form"; -import { - type AttachProductForm, - AttachProductFormSchema, -} from "./attach-product-form-schema"; - -export function useAttachProductForm({ - initialProductId, - initialPrepaidOptions, -}: { - initialProductId?: string; - initialPrepaidOptions?: Record; -} = {}) { - return useAppForm({ - defaultValues: { - productId: initialProductId || "", - prepaidOptions: initialPrepaidOptions ?? {}, - } as AttachProductForm, - validators: { - onChange: AttachProductFormSchema, - onSubmit: AttachProductFormSchema, - }, - }); -} - -// Subscribe to form changes and clear prepaid options when productId changes -// Prevents stale prepaid options from causing "no prepaid price found" in the `checkout` call -export function useResetPrepaidOnProductChange({ - form, -}: { - form: UseAttachProductForm; -}) { - const previousProductIdRef = useRef(); - - useEffect(() => { - const subscription = form.store.subscribe(() => { - const currentProductId = form.store.state.values.productId; - - if ( - previousProductIdRef.current !== undefined && - previousProductIdRef.current !== currentProductId - ) { - form.setFieldValue("prepaidOptions", {}); - } - previousProductIdRef.current = currentProductId; - }); - - return () => subscription(); - }, [form.store, form.setFieldValue]); -} - -export type UseAttachProductForm = ReturnType; diff --git a/vite/src/components/forms/attach-product/use-attach-product-mutation.ts b/vite/src/components/forms/attach-product/use-attach-product-mutation.ts deleted file mode 100644 index da323c8f2..000000000 --- a/vite/src/components/forms/attach-product/use-attach-product-mutation.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { ProductV2 } from "@autumn/shared"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { AxiosError } from "axios"; -import { toast } from "sonner"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { CusService } from "@/services/customers/CusService"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useAttachBodyBuilder } from "./use-attach-body-builder"; - -interface AttachProductParams { - // Product selection (provide one of these) - productId?: string; - product?: ProductV2; - - // Optional overrides - entityId?: string; - prepaidOptions?: Record; - version?: number; - - // Invoice options - useInvoice?: boolean; - enableProductImmediately?: boolean; -} - -export function useAttachProductMutation({ - customerId, - onSuccess, - onError, - successMessage = "Successfully attached product", -}: { - customerId: string; - onSuccess?: (data: unknown) => void | Promise; - onError?: (error: unknown) => void; - successMessage?: string; -}) { - const axiosInstance = useAxiosInstance(); - const queryClient = useQueryClient(); - const { closeSheet } = useSheetStore(); - - // Get builder function from shared hook - const { buildAttachBody } = useAttachBodyBuilder({ customerId }); - - return useMutation({ - mutationFn: async (params: AttachProductParams) => { - // Build attach body using shared builder function - const attachBody = buildAttachBody({ - productId: params.productId, - product: params.product, - entityId: params.entityId, - prepaidOptions: params.prepaidOptions, - version: params.version, - useInvoice: params.useInvoice, - enableProductImmediately: params.enableProductImmediately, - }); - - if (!attachBody) { - throw new Error( - "Failed to build attach body - product not found or missing data", - ); - } - - return await CusService.attach(axiosInstance, attachBody); - }, - onSuccess: async (response) => { - // Don't show success toast if checkout_url is returned - product not attached yet - if (response.data.checkout_url) { - toast.success("Redirecting to checkout URL"); - closeSheet(); - return; - } - - toast.success(successMessage); - closeSheet(); - queryClient.invalidateQueries({ queryKey: ["customer", customerId] }); - - if (onSuccess) { - await onSuccess(response.data); - } - }, - onError: (error) => { - if (onError) { - onError(error); - } else { - toast.error( - (error as AxiosError<{ message: string }>)?.response?.data?.message ?? - "Failed to attach product", - ); - console.error(error); - } - }, - }); -} diff --git a/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx b/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx deleted file mode 100644 index 03bd75812..000000000 --- a/vite/src/components/forms/attach-v2/components/AttachPlanSkeleton.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { TimerIcon } from "@phosphor-icons/react"; -import { motion } from "motion/react"; -import { - STAGGER_CONTAINER, - STAGGER_ITEM, -} from "@/components/forms/update-subscription-v2/constants/animationConstants"; -import { Skeleton } from "@/components/ui/skeleton"; -import { IconButton } from "@/components/v2/buttons/IconButton"; -import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; - -export function AttachPlanSkeleton() { - return ( - - - {/* Section title - static content with disabled buttons */} - -

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

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

- Advanced Configuration -

-

Override default billing behavior

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

- {pricingTableSnippet.description} -

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

- {billingStateSnippet.description} -

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

- {checkoutSnippet.description} -

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