diff --git a/.bt/config.json b/.bt/config.json new file mode 100644 index 000000000..6b6278e63 --- /dev/null +++ b/.bt/config.json @@ -0,0 +1,6 @@ +{ + "profile": null, + "org": "autumn", + "project": "leaf", + "project_id": "b5592c45-a906-4b43-93b7-bd05a8172b0b" +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ca11626e..abd19bdad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,16 +14,24 @@ on: required: false default: 'manual' +# Cancel an older in-progress build on the same branch when a newer commit +# lands, so the latest commit is the one that builds + deploys. +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + env: - AWS_REGION: us-west-2 + # ECR region is selected per-branch in the "Extract metadata" step: + # prod repo (autumn) -> us-east-2 + # staging repo (autumn-staging) -> us-east-1 # Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging. # Add short-lived PR branches here when you need staging without merging to dev. - STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis + STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset jobs: checks: name: Type Check - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout code @@ -55,17 +63,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ env.AWS_REGION }} - - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 - - name: Extract metadata for Docker id: meta env: @@ -103,11 +100,13 @@ jobs: echo "sha=$COMMIT_SHA" >> $GITHUB_OUTPUT echo "deploy_staging_override=$DEPLOY_STAGING_OVERRIDE" >> $GITHUB_OUTPUT - # Select ECR repository based on branch + # Select ECR repository + region based on branch if [ "$BRANCH_NAME" = "dev" ] || [ "$DEPLOY_STAGING_OVERRIDE" = "true" ]; then echo "ecr_repo=autumn-staging" >> $GITHUB_OUTPUT + echo "region=us-east-1" >> $GITHUB_OUTPUT else echo "ecr_repo=autumn" >> $GITHUB_OUTPUT + echo "region=us-east-2" >> $GITHUB_OUTPUT fi # Set custom tag if provided @@ -115,8 +114,19 @@ jobs: echo "custom_tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT fi - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ steps.meta.outputs.region }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up Blacksmith Docker builder + uses: useblacksmith/setup-docker-builder@v1 - name: Build and push Docker image env: @@ -129,14 +139,13 @@ jobs: COMBINED_TAG="${IMAGE_TAG_BRANCH}-${IMAGE_TAG_SHA}" TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${COMBINED_TAG}" - # Build and push + # Layer + cache-mount persistence is handled by the Blacksmith sticky + # disk mounted at /var/lib/buildkit, so no registry cache-from/to. docker buildx build \ --platform linux/amd64 \ --push \ --provenance=false \ --sbom=false \ - --cache-from type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH} \ - --cache-to type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH},mode=max \ --tag ${TAGS//,/ --tag } \ -f docker/Dockerfile \ . @@ -153,31 +162,19 @@ jobs: echo "Repository: ${ECR_REGISTRY}/${ECR_REPOSITORY}" echo "Tag: ${COMBINED_TAG}" - - name: Auto-deploy fix branches + - name: Auto-deploy production if: github.ref == 'refs/heads/main' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEPLOY_URL: ${{ secrets.DEPLOY_URL }} DEPLOY_SECRET: ${{ secrets.DEPLOY_SECRET }} COMMIT_SHA: ${{ github.sha }} run: | - # Get the PR that was merged (if any) - PR_DATA=$(gh pr list --state merged --search "$COMMIT_SHA" --json headRefName --limit 1) - BRANCH_NAME=$(echo "$PR_DATA" | jq -r '.[0].headRefName // empty') - - if [[ "$BRANCH_NAME" == fix/* ]]; then - echo "Detected merged fix/ branch: $BRANCH_NAME" - echo "Triggering auto-deploy..." - - curl --fail-with-body -X POST "$DEPLOY_URL/api/deploy/github" \ - -H "Content-Type: application/json" \ - -H "x-deploy-secret: $DEPLOY_SECRET" \ - -d "{\"commitSha\": \"$COMMIT_SHA\", \"deploymentType\": \"server\"}" - - echo "Auto-deploy triggered!" - else - echo "Not a fix/ branch (branch: ${BRANCH_NAME:-direct push}), skipping auto-deploy" - fi + echo "Triggering production auto-deploy for $COMMIT_SHA..." + curl --fail-with-body -X POST "$DEPLOY_URL/api/deploy/github" \ + -H "Content-Type: application/json" \ + -H "x-deploy-secret: $DEPLOY_SECRET" \ + -d "{\"commitSha\": \"$COMMIT_SHA\"}" + echo "Auto-deploy triggered!" - name: Auto-deploy staging if: github.ref == 'refs/heads/dev' || steps.meta.outputs.deploy_staging_override == 'true' diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 056dd9c68..2d69fa49d 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -10,7 +10,7 @@ on: jobs: knip: name: Check for unused code - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout code diff --git a/.github/workflows/sdk-publish.yml b/.github/workflows/sdk-publish.yml index 9e19dca59..f92181116 100644 --- a/.github/workflows/sdk-publish.yml +++ b/.github/workflows/sdk-publish.yml @@ -46,7 +46,7 @@ env: jobs: publish: name: Build and Publish - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: id-token: write diff --git a/.github/workflows/server-typecheck.yml b/.github/workflows/server-typecheck.yml index a081ea20f..79ed97953 100644 --- a/.github/workflows/server-typecheck.yml +++ b/.github/workflows/server-typecheck.yml @@ -10,7 +10,7 @@ permissions: jobs: changes: name: Check changed files - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: server: ${{ steps.filter.outputs.server }} steps: @@ -30,7 +30,7 @@ jobs: name: Type Check needs: changes if: needs.changes.outputs.server == 'true' - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout code diff --git a/.github/workflows/server-unit-tests.yml b/.github/workflows/server-unit-tests.yml index 2c99faaec..8560a60e0 100644 --- a/.github/workflows/server-unit-tests.yml +++ b/.github/workflows/server-unit-tests.yml @@ -2,8 +2,6 @@ name: Server Unit Tests on: pull_request: - paths: - - "server/**" permissions: contents: read @@ -12,7 +10,7 @@ permissions: jobs: changes: name: Check changed files - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: server: ${{ steps.filter.outputs.server }} steps: @@ -32,7 +30,7 @@ jobs: name: Unit Tests needs: changes if: needs.changes.outputs.server == 'true' - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout code @@ -41,11 +39,11 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version: 1.3.14 - name: Install dependencies run: bun install - name: Run unit tests - run: bun test tests/unit + run: bun test --isolate tests/unit working-directory: server diff --git a/.github/workflows/validate-schema.yml b/.github/workflows/validate-schema.yml index 291f9e806..47fd90454 100644 --- a/.github/workflows/validate-schema.yml +++ b/.github/workflows/validate-schema.yml @@ -11,7 +11,7 @@ on: jobs: changes: name: Detect schema-relevant changes - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: schema: ${{ steps.filter.outputs.schema }} steps: @@ -32,7 +32,7 @@ jobs: validate-schema: name: Validate Schema needs: changes - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Skip (no schema-relevant changes) diff --git a/.github/workflows/vite-build.yml b/.github/workflows/vite-build.yml index 35917b017..4e63aebb0 100644 --- a/.github/workflows/vite-build.yml +++ b/.github/workflows/vite-build.yml @@ -10,7 +10,7 @@ permissions: jobs: changes: name: Check changed files - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: vite: ${{ steps.filter.outputs.vite }} steps: @@ -28,11 +28,11 @@ jobs: - "bun.lock" - ".github/workflows/vite-build.yml" - typecheck: - name: Type Check + build: + name: Build & Type Check needs: changes if: needs.changes.outputs.vite == 'true' - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout code @@ -49,23 +49,5 @@ jobs: - name: Run TypeScript type check run: cd vite && bunx tsc --noEmit - build: - name: Build - needs: changes - if: needs.changes.outputs.vite == 'true' - 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: Build Vite run: cd vite && bunx vite build diff --git a/.github/workflows/vite-unit-tests.yml b/.github/workflows/vite-unit-tests.yml index cdae25ad9..ae29531ac 100644 --- a/.github/workflows/vite-unit-tests.yml +++ b/.github/workflows/vite-unit-tests.yml @@ -2,8 +2,6 @@ name: Vite Unit Tests on: pull_request: - paths: - - "vite/**" permissions: contents: read @@ -12,7 +10,7 @@ permissions: jobs: changes: name: Check changed files - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: vite: ${{ steps.filter.outputs.vite }} steps: @@ -34,7 +32,7 @@ jobs: name: Unit Tests needs: changes if: needs.changes.outputs.vite == 'true' - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index 7840e7c55..9e04e6a10 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ supabase.sh **/.env* tests/ !server/tests +!packages/mcp/tests +!packages/ai-sdk/tests +!apps/leaf/tests !vite/tests .secrets diff --git a/.plans/stripe-subscription-item-consolidation.md b/.plans/stripe-subscription-item-consolidation.md new file mode 100644 index 000000000..8f26f4ecc --- /dev/null +++ b/.plans/stripe-subscription-item-consolidation.md @@ -0,0 +1,176 @@ +# Plan: Stripe Subscription Item Consolidation + +## Goal + +Reduce noisy duplicate Stripe subscription items when Autumn attaches multiple customer products with identical billing semantics. + +Today, multi-attach prepaid add-ons can create one Stripe price and one Stripe subscription item per customer product. In Stripe this looks like four separate lines even when the customer really bought two identical monthly packs and two identical annual packs: + +- prepaid credits `$10 / month` +- prepaid credits `$10 / month` +- prepaid workflows `$10 / year` +- prepaid workflows `$10 / year` + +The target shape is fewer Stripe subscription items with quantities or shared price resources, while Autumn still tracks each customer product, customer price, entitlement, cancellation, and renewal independently. + +## Current Problem + +Autumn often treats each attached customer product as its own concrete billing object. That is useful internally, but it leaks into Stripe: + +- duplicate prepaid add-ons produce separate Stripe subscription items +- customized or customer-specific prices produce separate Stripe price IDs even when the price shape is identical +- schedules become harder to reason about because future phases contain repeated inline prices +- Stripe dashboard pricing tables become noisy and hard to audit + +This also interacts with schedule updates. If each duplicate has its own subscription item, cancellation or phase replacement must preserve item identity carefully to avoid Stripe resetting item periods or creating unexpected prorations. + +## Proposed Model + +Introduce a Stripe-side aggregation layer between Autumn customer prices and Stripe subscription item specs. + +Autumn should still create and store one `customer_product` and one or more `customer_prices` per attached product. Stripe does not need to receive one subscription item for every `customer_price` when multiple prices are billing-equivalent. + +Group billable specs by a strict `StripeSubscriptionItemAggregationKey`: + +- Stripe product identity +- currency +- recurring interval and interval count +- unit amount decimal or tier shape +- billing scheme, tiers mode, transform quantity, usage type +- tax behavior and tax code if present +- discount eligibility or attached discounts +- collection behavior relevant to invoice calculation +- any metadata that must appear on the Stripe line item + +For each group: + +- create one Stripe subscription item +- set quantity to the total billable quantity for the group +- persist an Autumn allocation map that records which customer prices are represented by that Stripe item + +## Allocation Map + +Collapsing requires an explicit mapping because Stripe item metadata cannot safely represent many customer prices forever. + +Candidate storage options: + +- add a table mapping `stripe_subscription_item_id -> customer_price_id` +- extend an existing customer price billing reference table if one exists +- store only current mappings in Autumn DB, not in Stripe metadata + +The map should support: + +- cancel one attached copy by decrementing Stripe quantity +- cancel all attached copies by deleting the Stripe item +- distinguish two customer products that share the same underlying `autumn_price_id` +- rebuild the mapping during sync or migration +- explain an invoice line item back to all represented customer prices + +Stripe metadata can keep summary fields for debugging, but should not be the source of truth for many-to-one identity. + +## Zero-Dollar Items + +Do not create Stripe subscription items for `$0` recurring prices when another subscription item with the same billing interval can safely carry the period. + +This should be conservative: + +- omit only if the zero-dollar item has no Stripe-visible invoice effect +- omit only if Autumn can derive its period from another same-interval item +- keep the item if it is the only Stripe object establishing that interval +- keep the item if Stripe item identity is needed for schedule transitions, tax, discounts, trials, or external reporting + +Zero-dollar omission should be a separate branch from paid-item consolidation. It is related, but riskier because the Stripe item disappears entirely. + +## Non-Goals + +Do not collapse items that merely look similar in the dashboard. They must be identical for Stripe invoice math. + +Do not collapse across different intervals, currencies, Stripe products, tax behavior, discount behavior, or tier shapes. + +Do not use `autumn_price_id` alone as the aggregation key. Two customer products can share the same Autumn price but still require separate Autumn lifecycle tracking. + +Do not rely on a JSON blob in Stripe metadata as the durable allocation source. Metadata limits and sync behavior make that brittle. + +## Implementation Shape + +Add a dedicated aggregation step before converting Autumn billing specs into Stripe subscription items. + +Likely areas: + +- `server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/` +- `server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/` +- `server/src/internal/billing/v2/providers/stripe/utils/matchUtils/` + +Candidate helpers: + +```ts +buildStripeSubscriptionItemAggregationKey(spec) +collapseStripeItemSpecs(specs) +expandStripeInvoiceLineAllocations(lineItem) +``` + +The aggregation key should reuse the same price-shape comparison utilities we use for inline price matching. If the shape comparison says two inline prices are not identical, aggregation must not collapse them. + +## Migration Strategy + +Start with new subscriptions only. + +For existing subscriptions: + +1. Detect groups of active Stripe subscription items that are aggregation-equivalent. +2. Preview the Stripe update that would replace them with one item and a higher quantity. +3. Apply only if the preview has no invoice impact. +4. Persist the allocation map. +5. Leave ambiguous subscriptions untouched. + +No migration should merge items if it would create prorations, change periods, or lose invoice-line attribution. + +## Schedule Behavior + +Schedule phases should collapse independently. A Stripe item can represent many Autumn customer prices within the same phase, but future phases must not accidentally reuse the same allocation map if membership changes. + +Examples: + +- Phase 1 has two identical monthly prepaid credit packs: one Stripe item, quantity `2`. +- Phase 2 cancels one pack: same Stripe item, quantity `1`. +- Phase 3 adds another identical pack: same billing key, quantity `2`, allocation map updated for that phase. + +For inline prices, reuse the existing Stripe subscription item only when the collapsed group still matches the current item shape and period semantics. + +## Test Matrix + +Add tests before implementing: + +- two identical monthly prepaid add-ons collapse to one Stripe item with quantity `2` +- two identical annual prepaid add-ons collapse to one Stripe item with quantity `2` +- cancel one of two identical monthly add-ons decrements quantity and invoices one renewal +- cancel one of two identical annual add-ons decrements quantity without charging annual renewal +- monthly and annual identical-looking add-ons do not collapse together +- same interval but different amount does not collapse +- same amount but different feature or Stripe product does not collapse unless explicitly allowed by product identity +- customized inline prices collapse only when exact Stripe price shape matches +- entity-scoped add-ons preserve allocation and entitlement ownership +- invoice line item matching can explain a collapsed Stripe line back to all represented customer prices +- checkout sessions either use the same aggregation rule or explicitly opt out +- subscription schedules preserve correct quantities across phase changes +- zero-dollar recurring item is omitted only when a same-interval carrier item exists +- zero-dollar-only interval keeps a Stripe item or an explicit Autumn period source + +## Acceptance Criteria + +- Stripe dashboard shows one line per billing-equivalent group, not one line per Autumn customer product. +- Autumn can still cancel, renew, migrate, and explain each customer product independently. +- Invoice totals are unchanged compared with the uncollapsed representation. +- Schedule phase transitions do not create extra prorations from aggregation changes alone. +- Sync and restore can rebuild or validate the allocation map. +- Existing multi-attach, checkout, invoice-line-item, schedule, and migration test groups pass. + +## Risks + +The main risk is losing one-to-one identity. Stripe has one subscription item, but Autumn may have many customer prices behind it. Every path that currently assumes `customer_price -> stripe_subscription_item` is one-to-one must be audited. + +Changing quantity can have different Stripe proration behavior than deleting one item and keeping another. This must be covered with invoice previews and test clocks. + +Zero-dollar omission can break period derivation if Autumn currently depends on a Stripe subscription item to know renewal timing. That needs a clear replacement source before removing those items. + +This is a cleanup and correctness project, not just a dashboard polish. The implementation should be gated behind tests and probably a feature flag. diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5e93cff6a..3c02be40f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,21 +4,21 @@ { "label": "Run Test Pattern", "type": "shell", - "command": "infisical run --env=dev --recursive -- bun test ${relativeFile} -t \"${input:testPattern}\"", + "command": "./run.sh \"${file}\" -t \"${input:testPattern}\"", "options": { "env": { "NODE_ENV": "development" } }, "problemMatcher": [] }, { "label": "Run Describe at Cursor", "type": "shell", - "command": "infisical run --env=dev --recursive -- bun test ${relativeFile} --timeout 0 -t \"$(bun scripts/testScripts/getDescribeAtCursor.ts ${file} ${lineNumber})\"", + "command": "./run.sh \"${file}\" ${lineNumber}", "options": { "env": { "NODE_ENV": "development" } }, "problemMatcher": [] }, { "label": "Run Current Test File", "type": "shell", - "command": "infisical run --env=dev --recursive -- bun test ${relativeFile}", + "command": "./run.sh \"${file}\"", "options": { "env": { "NODE_ENV": "development" } }, "problemMatcher": [] } diff --git a/ai b/ai index 0d561b174..bca809a30 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 0d561b1747e8f47190a01d7a9bff7d8fcb42c9dd +Subproject commit bca809a3078696361300cc65dc201fc077f91915 diff --git a/apps/docs/mintlify/api-reference/billing/attach.mdx b/apps/docs/mintlify/api-reference/billing/attach.mdx index b42b46664..00910d02e 100644 --- a/apps/docs/mintlify/api-reference/billing/attach.mdx +++ b/apps/docs/mintlify/api-reference/billing/attach.mdx @@ -413,6 +413,14 @@ This is useful for attaching custom metadata to the Stripe subscription created If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + + ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + + + + Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + + diff --git a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx index 2920bc5ed..a6f4eca75 100644 --- a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx @@ -380,6 +380,14 @@ const response = await autumn.billing.update({ If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + + ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + + + + Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + + diff --git a/apps/docs/mintlify/api-reference/billing/createSchedule.mdx b/apps/docs/mintlify/api-reference/billing/createSchedule.mdx index 57c734db5..2e3554a43 100644 --- a/apps/docs/mintlify/api-reference/billing/createSchedule.mdx +++ b/apps/docs/mintlify/api-reference/billing/createSchedule.mdx @@ -32,6 +32,28 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + + ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + + + + Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + + + + + + + List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. + + + The ID of the reward to apply as a discount. + + + + The promotion code to apply as a discount. + + diff --git a/apps/docs/mintlify/api-reference/billing/multiAttach.mdx b/apps/docs/mintlify/api-reference/billing/multiAttach.mdx index f180181ff..e96a497a7 100644 --- a/apps/docs/mintlify/api-reference/billing/multiAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/multiAttach.mdx @@ -226,6 +226,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + + ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + + + + Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + + diff --git a/apps/docs/mintlify/api-reference/billing/previewAttach.mdx b/apps/docs/mintlify/api-reference/billing/previewAttach.mdx index c522bed63..87fc7abde 100644 --- a/apps/docs/mintlify/api-reference/billing/previewAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewAttach.mdx @@ -346,6 +346,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + + ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + + + + Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + + diff --git a/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx b/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx index 2626d115a..a3ae73470 100644 --- a/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewMultiAttach.mdx @@ -226,6 +226,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + + ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + + + + Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + + diff --git a/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx b/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx index 579224c53..c42cc86d4 100644 --- a/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx @@ -346,6 +346,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + + ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + + + + Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + + diff --git a/apps/docs/mintlify/api-reference/customers/getCustomer.mdx b/apps/docs/mintlify/api-reference/customers/getCustomer.mdx index 1e81b4096..3f6235e26 100644 --- a/apps/docs/mintlify/api-reference/customers/getCustomer.mdx +++ b/apps/docs/mintlify/api-reference/customers/getCustomer.mdx @@ -512,6 +512,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units of this subscription (for per-seat plans). + + Whether this subscription is attached at the customer level or entity level. + + @@ -827,6 +831,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units purchased. + + Whether this purchase is attached at the customer level or entity level. + + diff --git a/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx b/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx index 069e0c05c..54592ceea 100644 --- a/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx +++ b/apps/docs/mintlify/api-reference/customers/getOrCreateCustomer.mdx @@ -666,6 +666,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units of this subscription (for per-seat plans). + + Whether this subscription is attached at the customer level or entity level. + + @@ -981,6 +985,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units purchased. + + Whether this purchase is attached at the customer level or entity level. + + diff --git a/apps/docs/mintlify/api-reference/customers/listCustomers.mdx b/apps/docs/mintlify/api-reference/customers/listCustomers.mdx index a716c10ce..9236a6afa 100644 --- a/apps/docs/mintlify/api-reference/customers/listCustomers.mdx +++ b/apps/docs/mintlify/api-reference/customers/listCustomers.mdx @@ -537,6 +537,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units of this subscription (for per-seat plans). + + Whether this subscription is attached at the customer level or entity level. + + @@ -852,6 +856,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units purchased. + + Whether this purchase is attached at the customer level or entity level. + + diff --git a/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx b/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx index b6eccebe0..a4541e8c9 100644 --- a/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx +++ b/apps/docs/mintlify/api-reference/customers/updateCustomer.mdx @@ -654,6 +654,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units of this subscription (for per-seat plans). + + Whether this subscription is attached at the customer level or entity level. + + @@ -969,6 +973,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units purchased. + + Whether this purchase is attached at the customer level or entity level. + + diff --git a/apps/docs/mintlify/api-reference/entities/createEntity.mdx b/apps/docs/mintlify/api-reference/entities/createEntity.mdx index 20224e42d..adae7ab69 100644 --- a/apps/docs/mintlify/api-reference/entities/createEntity.mdx +++ b/apps/docs/mintlify/api-reference/entities/createEntity.mdx @@ -619,6 +619,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units of this subscription (for per-seat plans). + + Whether this subscription is attached at the customer level or entity level. + + @@ -933,6 +937,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units purchased. + + Whether this purchase is attached at the customer level or entity level. + + diff --git a/apps/docs/mintlify/api-reference/entities/getEntity.mdx b/apps/docs/mintlify/api-reference/entities/getEntity.mdx index 888771e08..e06e7e00c 100644 --- a/apps/docs/mintlify/api-reference/entities/getEntity.mdx +++ b/apps/docs/mintlify/api-reference/entities/getEntity.mdx @@ -391,6 +391,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units of this subscription (for per-seat plans). + + Whether this subscription is attached at the customer level or entity level. + + @@ -705,6 +709,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units purchased. + + Whether this purchase is attached at the customer level or entity level. + + diff --git a/apps/docs/mintlify/api-reference/entities/listEntities.mdx b/apps/docs/mintlify/api-reference/entities/listEntities.mdx index 51eedc0bb..445b15b79 100644 --- a/apps/docs/mintlify/api-reference/entities/listEntities.mdx +++ b/apps/docs/mintlify/api-reference/entities/listEntities.mdx @@ -420,6 +420,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units of this subscription (for per-seat plans). + + Whether this subscription is attached at the customer level or entity level. + + @@ -734,6 +738,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units purchased. + + Whether this purchase is attached at the customer level or entity level. + + diff --git a/apps/docs/mintlify/api-reference/entities/updateEntity.mdx b/apps/docs/mintlify/api-reference/entities/updateEntity.mdx index fea80a3f7..e447dfcc8 100644 --- a/apps/docs/mintlify/api-reference/entities/updateEntity.mdx +++ b/apps/docs/mintlify/api-reference/entities/updateEntity.mdx @@ -455,6 +455,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units of this subscription (for per-seat plans). + + Whether this subscription is attached at the customer level or entity level. + + @@ -769,6 +773,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; Number of units purchased. + + Whether this purchase is attached at the customer level or entity level. + + diff --git a/apps/docs/mintlify/api-reference/platform/getRevenueCatKeys.mdx b/apps/docs/mintlify/api-reference/platform/getRevenueCatKeys.mdx new file mode 100644 index 000000000..308c4bec7 --- /dev/null +++ b/apps/docs/mintlify/api-reference/platform/getRevenueCatKeys.mdx @@ -0,0 +1,79 @@ +--- +title: "Get Revenue Cat Keys" +openapi: "openapi POST /v1/platform.get_revenuecat_keys" +--- + +import { DynamicParamField } from "/snippets/dynamic-param-field.jsx"; +import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx"; +import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + +### Body Parameters + + + + + "test" and "sandbox" both target the sandbox environment + + + +### Response + + + + + + + RevenueCat store type, e.g. test_store / app_store / play_store + + + + + + + + + + The public SDK API key value + + + + e.g. "production" / "sandbox" + + + + + + + + + + + + + + Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token. + + + + +```json 200 +{ + "apps": [ + { + "app_id": "app1a2b3c4d", + "app_type": "test_store", + "name": "Acme (Test Store)", + "api_keys": [ + { + "id": "apikey12345", + "key": "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ", + "environment": "production", + "app_id": "app1a2b3c4" + } + ] + } + ], + "oauth_access_token": "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ" +} +``` + diff --git a/apps/docs/mintlify/api-reference/platform/linkRevenueCat.mdx b/apps/docs/mintlify/api-reference/platform/linkRevenueCat.mdx new file mode 100644 index 000000000..4c891cc98 --- /dev/null +++ b/apps/docs/mintlify/api-reference/platform/linkRevenueCat.mdx @@ -0,0 +1,32 @@ +--- +title: "Link Revenue Cat" +openapi: "openapi POST /v1/platform.link_revenuecat" +--- + +import { DynamicParamField } from "/snippets/dynamic-param-field.jsx"; +import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx"; +import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + +### Body Parameters + + + + + + + + + + +### Response + + + + + +```json 200 +{ + "oauth_url": "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write" +} +``` + diff --git a/apps/docs/mintlify/api-reference/platform/syncRevenueCat.mdx b/apps/docs/mintlify/api-reference/platform/syncRevenueCat.mdx new file mode 100644 index 000000000..2f3016390 --- /dev/null +++ b/apps/docs/mintlify/api-reference/platform/syncRevenueCat.mdx @@ -0,0 +1,77 @@ +--- +title: "Sync Revenue Cat" +openapi: "openapi POST /v1/platform.sync_revenuecat" +--- + +import { DynamicParamField } from "/snippets/dynamic-param-field.jsx"; +import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx"; +import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx"; + +### Body Parameters + + + + + "test" and "sandbox" both target the sandbox environment + + + + Plans to push. Omit to sync every plan in the org/env. + + + +### Response + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +```json 200 +{ + "results": [ + { + "plan_id": "pro", + "status": "synced", + "store_identifier": "autumn.sandbox.org_123.pro", + "apps": [ + { + "app_id": "app_test", + "app_type": "test_store", + "product": "created", + "store_push": "skipped", + "price": "set" + } + ] + } + ] +} +``` + diff --git a/apps/docs/mintlify/api/openapi.yml b/apps/docs/mintlify/api/openapi.yml index c46d1c1a9..683707a8b 100644 --- a/apps/docs/mintlify/api/openapi.yml +++ b/apps/docs/mintlify/api/openapi.yml @@ -482,6 +482,13 @@ components: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -519,6 +526,13 @@ components: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -2234,6 +2248,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -2272,6 +2293,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -3114,6 +3142,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -3152,6 +3187,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -3972,6 +4014,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -4010,6 +4059,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -8313,6 +8369,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -8628,10 +8695,37 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. + discounts: + type: array + items: + type: object + properties: + reward_id: + type: string + description: The ID of the reward to apply as a discount. + promotion_code: + type: string + description: The promotion code to apply as a discount. + title: AttachDiscount + description: A discount to apply. Can be either a reward ID or a promotion code. + description: List of discounts to apply to the immediate phase. Each discount + can be an Autumn reward ID, Stripe coupon ID, or Stripe + promotion code. success_url: type: string description: URL to redirect to after successful checkout. @@ -9654,6 +9748,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -10420,6 +10525,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -11327,6 +11443,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -12418,6 +12545,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -13104,6 +13242,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -17247,6 +17396,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -17283,6 +17439,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -17737,6 +17900,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -17773,6 +17943,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -18272,6 +18449,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -18308,6 +18492,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -18843,6 +19034,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -18879,6 +19077,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -19539,6 +19744,384 @@ paths: code="REWARD10", customer_id="cus_456", ) + /v1/platform.link_revenuecat: + post: + operationId: linkRevenueCat + description: Generate a RevenueCat OAuth URL for linking a project to an organization. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - live + type: string + project_name: + type: string + minLength: 1 + maxLength: 255 + redirect_url: + type: string + format: uri + required: + - organization_slug + - env + - project_name + - redirect_url + title: LinkRevenueCatParams + examples: + - &a77 + organization_slug: acme + env: test + project_name: acme-mobile + redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat + example: *a77 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + oauth_url: + type: string + required: + - oauth_url + title: LinkRevenueCatResponse + examples: + - &a78 + oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write + example: *a78 + x-speakeasy-name-override: linkRevenueCat + parameters: + - *a5 + x-codeSamples: + - lang: typescript + label: Typescript (SDK) + source: |- + import { Autumn } from 'autumn-js' + + const autumn = new Autumn() + + const result = await autumn.platform.linkRevenueCat({ + organizationSlug: "acme", + env: "test", + projectName: "acme-mobile", + redirectUrl: "https://dashboard.useautumn.com/dev?tab=revenuecat", + }); + - lang: python + label: Python (SDK) + source: >- + from autumn_sdk import Autumn + + + autumn = Autumn(secret_key="am_sk_test...") + + + res = autumn.platform.link_revenue_cat( + organization_slug="acme", + env="test", + project_name="acme-mobile", + redirect_url="https://dashboard.useautumn.com/dev?tab=revenuecat", + ) + /v1/platform.sync_revenuecat: + post: + operationId: syncRevenueCat + description: Push an organization's plans into RevenueCat as products (creating + or renaming them across the project's apps) and set test-store prices + from each plan's price. Requires the org to have linked RevenueCat via + OAuth. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - sandbox + - live + type: string + description: '"test" and "sandbox" both target the sandbox environment' + product_ids: + type: array + items: + type: string + description: Plans to push. Omit to sync every plan in the org/env. + required: + - organization_slug + - env + title: SyncRevenueCatParams + examples: + - &a79 + organization_slug: acme + env: test + product_ids: + - pro + - premium + example: *a79 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + plan_id: + type: string + status: + enum: + - synced + - skipped + - error + type: string + store_identifier: + type: string + apps: + type: array + items: + type: object + properties: + app_id: + type: string + app_type: + type: string + product: + enum: + - created + - updated + - exists + type: string + store_push: + enum: + - pushed + - failed + - skipped + type: string + price: + enum: + - set + - skipped + - failed + type: string + message: + type: string + required: + - app_id + - app_type + - product + message: + type: string + required: + - plan_id + - status + required: + - results + title: SyncRevenueCatResponse + examples: + - &a80 + results: + - plan_id: pro + status: synced + store_identifier: autumn.sandbox.org_123.pro + apps: + - app_id: app_test + app_type: test_store + product: created + store_push: skipped + price: set + example: *a80 + x-speakeasy-name-override: syncRevenueCat + parameters: + - *a5 + x-codeSamples: + - lang: typescript + label: Typescript (SDK) + source: |- + import { Autumn } from 'autumn-js' + + const autumn = new Autumn() + + const result = await autumn.platform.syncRevenueCat({ + organizationSlug: "acme", + env: "test", + productIds: [ + "pro", + "premium", + ], + }); + - lang: python + label: Python (SDK) + source: |- + from autumn_sdk import Autumn + + autumn = Autumn(secret_key="am_sk_test...") + + res = autumn.platform.sync_revenue_cat( + organization_slug="acme", + env="test", + product_ids=[ + "pro", + "premium", + ], + ) + /v1/platform.get_revenuecat_keys: + post: + operationId: getRevenueCatKeys + description: Retrieve a managed organization's RevenueCat public (SDK) API keys, + grouped by app — for the test store, App Store, and Google Play Store. + Use these to configure the RevenueCat SDK in the org's mobile app. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - sandbox + - live + type: string + description: '"test" and "sandbox" both target the sandbox environment' + required: + - organization_slug + - env + title: GetRevenueCatKeysParams + examples: + - &a81 + organization_slug: acme + env: test + example: *a81 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + apps: + type: array + items: + type: object + properties: + app_id: + type: string + app_type: + type: string + description: RevenueCat store type, e.g. test_store / app_store / play_store + name: + type: string + api_keys: + type: array + items: + type: object + properties: + id: + type: string + key: + type: string + description: The public SDK API key value + environment: + anyOf: + - type: string + - type: "null" + description: e.g. "production" / "sandbox" + app_id: + anyOf: + - type: string + - type: "null" + created_at: + type: number + required: + - id + - key + additionalProperties: {} + required: + - app_id + - app_type + - name + - api_keys + oauth_access_token: + anyOf: + - type: string + - type: "null" + description: Freshly-refreshed RevenueCat OAuth access token for the org (null + for api-key orgs). The refresh token is never exposed — + call this endpoint again for a new access token. + required: + - apps + - oauth_access_token + title: GetRevenueCatKeysResponse + examples: + - &a82 + apps: + - app_id: app1a2b3c4d + app_type: test_store + name: Acme (Test Store) + api_keys: + - id: apikey12345 + key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ + environment: production + app_id: app1a2b3c4 + oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ + example: *a82 + x-speakeasy-name-override: getRevenueCatKeys + parameters: + - *a5 + x-codeSamples: + - lang: typescript + label: Typescript (SDK) + source: |- + import { Autumn } from 'autumn-js' + + const autumn = new Autumn() + + const result = await autumn.platform.getRevenueCatKeys({ + organizationSlug: "acme", + env: "test", + }); + - lang: python + label: Python (SDK) + source: |- + from autumn_sdk import Autumn + + autumn = Autumn(secret_key="am_sk_test...") + + res = autumn.platform.get_revenue_cat_keys( + organization_slug="acme", + env="test", + ) security: - secretKey: [] x-speakeasy-globals: diff --git a/apps/docs/mintlify/changelog/changelog.mdx b/apps/docs/mintlify/changelog/changelog.mdx index 302d6381d..ff0bdc9e0 100644 --- a/apps/docs/mintlify/changelog/changelog.mdx +++ b/apps/docs/mintlify/changelog/changelog.mdx @@ -4,6 +4,54 @@ mode: "center" description: "Some new things we've shipped at Autumn HQ" --- + + ## Autumn MCP server + + Autumn now ships an official **Model Context Protocol (MCP) server**, so AI assistants like Claude Desktop and Cursor can read and act on your Autumn data directly. The server is generated against the public API and respects your existing API key scopes (`customers:read`, `plans:read`, `billing:read`, `billing:write`) — no new permission model to learn. + + Install it as a Claude Desktop extension or wire it into Cursor with a single deeplink. Use it to look up customers, inspect plans, preview attaches, and run billing updates from your assistant. + + ## Org-level usage alerts + + Usage alerts can now be configured **once at the org level** and applied across every customer, instead of being attached plan by plan. Set a threshold and channel from settings, and Autumn fires alerts whenever any customer crosses the bar. The [`balances.usage_alert.triggered` webhook](/api-reference/webhooks/balancesUsageAlertTriggered) fires for both per-plan and org-level alerts. + + ## Vercel Marketplace: invoice mode and resource logs + + The Vercel integration now supports **invoice-mode billing** end to end and surfaces **per-resource logs** in the dashboard, so you can debug a Vercel customer's provisioning, auto top-ups, and invoice flow without leaving Autumn. The frontend was rebuilt around the new resource model, with fallbacks for identity edge cases. + + See the [Vercel Marketplace guide](/documentation/external-providers/vercel-marketplace). + + + + - Official Autumn MCP server for Claude Desktop, Cursor, and other MCP clients — [#1715](https://github.com/useautumn/autumn/pull/1715), [#1740](https://github.com/useautumn/autumn/pull/1740), [#1741](https://github.com/useautumn/autumn/pull/1741) + - Org-level usage alerts with shared thresholds across customers — [#1707](https://github.com/useautumn/autumn/pull/1707) + - Vercel Marketplace invoice mode, resource logs, and refreshed frontend — [#1711](https://github.com/useautumn/autumn/pull/1711) + - Preflight tax-address check before charging customers in taxable regions — [#1699](https://github.com/useautumn/autumn/pull/1699) + - Automatically void uncollectible Stripe invoices instead of leaving them open — [#1732](https://github.com/useautumn/autumn/pull/1732) + - New plan-version filters for [custom-plan migrations](/documentation/customers/custom-plans) — [#1718](https://github.com/useautumn/autumn/pull/1718) + - Faster customer detail loads and smoother large entity lists — [#1712](https://github.com/useautumn/autumn/pull/1712) + - Customer list filter by entity ID — [#1688](https://github.com/useautumn/autumn/pull/1688) + - Refreshed dashboard theme and appearance controls — [#1679](https://github.com/useautumn/autumn/pull/1679) + - Optimised product counts in the customer table — [#1690](https://github.com/useautumn/autumn/pull/1690) + - Mobile fixes for the customer list view — [#1689](https://github.com/useautumn/autumn/pull/1689) + + + - Stripe checkout no longer drops carry-over balances on plan changes — [#1708](https://github.com/useautumn/autumn/pull/1708) + - Trial grants are now excluded from reward eligibility — [#1730](https://github.com/useautumn/autumn/pull/1730) + - RevenueCat sync no longer fails with "entities not found" — [#1729](https://github.com/useautumn/autumn/pull/1729) + - Tax IDs now render correctly in the attach preview — [#1728](https://github.com/useautumn/autumn/pull/1728) + - Invalid email addresses are now rejected with a clearer error — [#1722](https://github.com/useautumn/autumn/pull/1722) + - Sandbox usage alerts now respect their configured threshold — [#1706](https://github.com/useautumn/autumn/pull/1706) + - Proration toggle now shows even when the customer has a past trial — [#1704](https://github.com/useautumn/autumn/pull/1704) + - One-off prepaid items upgrade correctly without double carry-over — [#1676](https://github.com/useautumn/autumn/pull/1676), [#1695](https://github.com/useautumn/autumn/pull/1695) + - Subscription metadata is preserved through checkout — [#1719](https://github.com/useautumn/autumn/pull/1719) + - Price dropdown no longer loses its selected value when reopened — [#1720](https://github.com/useautumn/autumn/pull/1720) + - Vercel auto top-ups no longer fail to process the resulting invoice — multiple fixes ([#1725](https://github.com/useautumn/autumn/pull/1725)) + + + + + ## Batch track and async track diff --git a/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx b/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx index bb33e85f0..e9bb2b92a 100644 --- a/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx +++ b/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx @@ -33,16 +33,16 @@ bun add @useautumn/ai-sdk #### 2. Wrap your model -Use `withTokenTracking` to wrap any AI SDK language model. It intercepts generate and stream calls, reads the token usage from the response, and reports it to Autumn automatically. +Use `withAutumn` to wrap any AI SDK language model. It intercepts generate and stream calls, reads the token usage from the response, and reports it to Autumn automatically. ```typescript import { Autumn } from "autumn-js"; import { anthropic } from "@ai-sdk/anthropic"; -import { withTokenTracking } from "@useautumn/ai-sdk"; +import { withAutumn } from "@useautumn/ai-sdk"; const autumn = new Autumn({ secretKey: "am_sk_test_1234" }); -const model = withTokenTracking({ +const model = withAutumn({ autumn, model: anthropic("claude-sonnet-4-5-20250514"), customerId: "user_123", @@ -89,7 +89,7 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider"; const openrouter = createOpenRouter(); -const model = withTokenTracking({ +const model = withAutumn({ autumn, model: openrouter("anthropic/claude-opus-4-6"), customerId: "user_123", @@ -116,12 +116,12 @@ const model = withTokenTracking({ import { Autumn } from "autumn-js"; import { openai } from "@ai-sdk/openai"; import { generateText } from "ai"; -import { withTokenTracking } from "@useautumn/ai-sdk"; +import { withAutumn } from "@useautumn/ai-sdk"; const autumn = new Autumn({ secretKey: process.env.AUTUMN_SECRET_KEY! }); async function chat(customerId: string, message: string) { - const model = withTokenTracking({ + const model = withAutumn({ autumn, model: openai("gpt-4o"), customerId, diff --git a/apps/leaf/README.md b/apps/leaf/README.md new file mode 100644 index 000000000..5db168b3c --- /dev/null +++ b/apps/leaf/README.md @@ -0,0 +1,53 @@ +# Autumn Leaf + +Autumn's AI service: the Slack chat bot plus the hosted MCP routes (`src/mcp/mcpRouter.ts`). + +Local Slack testing uses a normal Slack app in a development workspace. Keep the app undistributed while testing. + +## Slack test app + +1. Start the stable local tunnel for the server. It forwards Slack requests to the chat app in local development: + +```sh +bun run chat:tunnel +``` + +2. Start Autumn with the same public URL: + +```sh +NGROK_URL=https://c.autumn.ngrok.app bun d +``` + +`bun d` derives `CHAT_URL`, `SLACK_BOT_URL`, and `SLACK_REDIRECT_URI` from +`NGROK_URL`, so the Slack OAuth redirect becomes +`https://c.autumn.ngrok.app/slack/oauth/callback`. This exact URL must be in +the Slack app's OAuth redirect URLs. + +The chat SDK stores its own subscriptions, locks, and queues in Postgres. By +default it uses the same `DATABASE_URL` host with the database name changed to +`chat`; set `CHAT_STATE_DATABASE_URL` to override this. `bun dev:services up` +creates the local `chat` database. The `@chat-adapter/state-pg` package creates +its state tables automatically on connect, so there is no separate migration +command for the chat state database. + +3. Create a Slack app at https://api.slack.com/apps using `slack-manifest.example.json`. + +For production, use `slack-manifest.prod.json`. It points Slack at `https://api.useautumn.com/slack/*`, which the API proxies to the chat service. + +4. Copy the Slack app credentials into the local environment: + +```sh +SLACK_CLIENT_ID=... +SLACK_CLIENT_SECRET=... +SLACK_SIGNING_SECRET=... +``` + +5. In Autumn, open Settings -> Integrations and click Add Slack. + +6. Test in Slack by DMing the app: + +```txt +list customers +``` + +For billing changes, the app should post a preview with Approve and Cancel buttons before calling a write tool. diff --git a/apps/leaf/package.json b/apps/leaf/package.json new file mode 100644 index 000000000..cd18b7f4c --- /dev/null +++ b/apps/leaf/package.json @@ -0,0 +1,42 @@ +{ + "name": "@autumn/leaf", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "eval": "zsh -lc 'braintrust eval tests/evals/**/*.eval.ts --external-packages @mastra/mcp @mastra/core @mastra/braintrust @mastra/observability \"$@\"' --", + "eval:mcp": "zsh -lc 'braintrust eval tests/evals/mcp/**/*.eval.ts --external-packages @mastra/mcp @mastra/core @mastra/braintrust @mastra/observability \"$@\"' --", + "start": "bun src/index.ts", + "test": "bun test tests/unit", + "ts": "tsc --noEmit" + }, + "dependencies": { + "@autumn/auth": "workspace:*", + "@autumn/logging": "workspace:*", + "@autumn/mcp": "workspace:*", + "@autumn/shared": "workspace:*", + "@chat-adapter/slack": "^4.29.0", + "@chat-adapter/state-pg": "^4.29.0", + "@hono/node-server": "^1.19.5", + "@mastra/braintrust": "^1.1.3", + "@mastra/core": "^1.36.0", + "@mastra/mcp": "^1.8.0", + "@mastra/observability": "^1.14.1", + "@mendable/firecrawl-js": "^4.25.1", + "autoevals": "^0.0.132", + "braintrust": "^3.14.0", + "chat": "^4.29.0", + "date-fns": "^4.1.0", + "drizzle-orm": "catalog:", + "e2b": "^2.8.4", + "hono": "4.12.7", + "postgres": "catalog:", + "zod": "^3.25.23" + }, + "devDependencies": { + "@types/bun": "^1.3.1", + "@types/node": "^25.0.7", + "typescript": "^5.7.3" + } +} diff --git a/apps/leaf/slack-manifest.example.json b/apps/leaf/slack-manifest.example.json new file mode 100644 index 000000000..3dc123e0a --- /dev/null +++ b/apps/leaf/slack-manifest.example.json @@ -0,0 +1,53 @@ +{ + "display_information": { + "name": "Autumn Chat Local" + }, + "features": { + "bot_user": { + "display_name": "Autumn", + "always_online": false + } + }, + "oauth_config": { + "redirect_urls": ["https://c.autumn.ngrok.app/slack/oauth/callback"], + "scopes": { + "bot": [ + "app_mentions:read", + "assistant:write", + "channels:history", + "channels:read", + "chat:write", + "files:read", + "groups:history", + "groups:read", + "im:history", + "im:read", + "im:write", + "mpim:history", + "mpim:read", + "users:read" + ] + } + }, + "settings": { + "event_subscriptions": { + "request_url": "https://c.autumn.ngrok.app/slack/events", + "bot_events": [ + "app_mention", + "assistant_thread_started", + "assistant_thread_context_changed", + "message.channels", + "message.groups", + "message.im", + "message.mpim" + ] + }, + "interactivity": { + "is_enabled": true, + "request_url": "https://c.autumn.ngrok.app/slack/interactions" + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "is_hosted": false + } +} diff --git a/apps/leaf/slack-manifest.prod.json b/apps/leaf/slack-manifest.prod.json new file mode 100644 index 000000000..a44d5e58d --- /dev/null +++ b/apps/leaf/slack-manifest.prod.json @@ -0,0 +1,53 @@ +{ + "display_information": { + "name": "Autumn" + }, + "features": { + "bot_user": { + "display_name": "Autumn", + "always_online": false + } + }, + "oauth_config": { + "redirect_urls": ["https://api.useautumn.com/slack/oauth/callback"], + "scopes": { + "bot": [ + "app_mentions:read", + "assistant:write", + "channels:history", + "channels:read", + "chat:write", + "files:read", + "groups:history", + "groups:read", + "im:history", + "im:read", + "im:write", + "mpim:history", + "mpim:read", + "users:read" + ] + } + }, + "settings": { + "event_subscriptions": { + "request_url": "https://api.useautumn.com/slack/events", + "bot_events": [ + "app_mention", + "assistant_thread_started", + "assistant_thread_context_changed", + "message.channels", + "message.groups", + "message.im", + "message.mpim" + ] + }, + "interactivity": { + "is_enabled": true, + "request_url": "https://api.useautumn.com/slack/interactions" + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "is_hosted": false + } +} diff --git a/apps/leaf/src/agent/agent.ts b/apps/leaf/src/agent/agent.ts new file mode 100644 index 000000000..7bde85c7f --- /dev/null +++ b/apps/leaf/src/agent/agent.ts @@ -0,0 +1,246 @@ +import type { AutumnLogger } from "@autumn/logging"; +import { AppEnv } from "@autumn/shared"; +import { Agent } from "@mastra/core/agent"; +import type { MessageListInput } from "@mastra/core/agent/message-list"; +import { Mastra } from "@mastra/core/mastra"; +import { InMemoryStore } from "@mastra/core/storage"; +import { z } from "zod"; +import { createLeafTracingOptions } from "../internal/observability/leafTracingOptions.js"; +import { env as chatEnv } from "../lib/env.js"; +import { logger as rootLogger } from "../lib/logger.js"; +import { createMastraBraintrustObservability } from "../providers/braintrust/index.js"; +import { createE2bSandboxProvider } from "../providers/e2b/e2bSandboxProvider.js"; +import type { ChatContextMessage } from "../types.js"; +import { createFirecrawlTools } from "./firecrawl.js"; +import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js"; +import { sandboxConfig } from "./sandbox/config.js"; +import { createSandboxTools } from "./sandbox/createSandboxTools.js"; +import { agentDocUris, createAutumnChatAgent } from "./chatAgent.js"; + +export { agentDocUris, createAutumnChatAgent } from "./chatAgent.js"; + +const envSelectionSchema = z.strictObject({ + env: z.nativeEnum(AppEnv), +}); + +export const getDefaultChatEnv = () => + process.env.NODE_ENV === "production" ? AppEnv.Live : AppEnv.Sandbox; + +const recentMessageContext = (messages: ChatContextMessage[] = []) => + messages.map((message) => ({ + role: message.isBot === true ? ("assistant" as const) : ("user" as const), + content: `${message.author}${message.isBot === true ? " (bot)" : ""}: ${message.text}`, + })); + +export const selectChatEnv = async ({ + logger = rootLogger, + message, + recentMessages, + select, +}: { + logger?: AutumnLogger; + message: string; + recentMessages?: ChatContextMessage[]; + select?: () => Promise | unknown; +}) => { + if (select) { + const env = envSelectionSchema.parse(await select()).env; + logger.debug("Selected chat environment from override", { + event: "leaf.chat_env_selected", + context: { env }, + data: { source: "override" }, + }); + return env; + } + + const agent = new Agent({ + id: "autumn-chat-env", + name: "Autumn Chat Env", + instructions: `Choose the Autumn environment for the latest user request. Default to ${getDefaultChatEnv()}. Use the other environment only when the user clearly asks for it.`, + model: chatEnv.CHAT_MODEL, + }); + const output = await agent.generate(message, { + maxSteps: 1, + structuredOutput: { + schema: envSelectionSchema, + instructions: `Return ${getDefaultChatEnv()} unless the latest user request clearly asks to use the other environment.`, + }, + context: [...recentMessageContext(recentMessages)], + }); + logger.debug("Selected chat environment from model", { + event: "leaf.chat_env_selected", + context: { env: output.object.env }, + data: { source: "model" }, + }); + return output.object.env; +}; + +const readDocs = async (mcp: ReturnType) => { + const resources = await Promise.allSettled( + agentDocUris.map((uri) => mcp.resources.read("autumn", uri)), + ); + return resources + .flatMap((result) => + result.status === "fulfilled" + ? result.value.contents.flatMap((content) => + "text" in content ? [content.text] : [], + ) + : [], + ) + .join("\n\n"); +}; + +export const runChatAgent = async ({ + token, + env, + logger = rootLogger, + message, + channelId, + threadId, + resourceId, + onAction, + provider, + workspaceId, + recentMessages, + agentRunId, + orgSlug, +}: { + token: string; + env: AppEnv; + logger?: AutumnLogger; + message: MessageListInput; + channelId: string; + onAction?: (message: string) => Promise | void; + threadId: string; + resourceId: string; + provider: string; + workspaceId: string; + agentRunId?: string; + orgSlug?: string | null; + recentMessages?: ChatContextMessage[]; +}) => { + const mcp = createAutumnMcpClient({ + token, + appEnv: env, + options: { requireApproval: true }, + }); + let previewApproval: + | { + toolName: string; + toolArgs: Record; + preview: unknown; + } + | undefined; + try { + logger.info("Starting chat agent", { + event: "leaf.agent_started", + context: { + env, + org_id: resourceId, + provider, + }, + data: { + thread_id: threadId, + }, + }); + await onAction?.("Loading Autumn tools and guidance"); + const [tools, docsText] = await Promise.all([ + getAutumnMcpTools({ + mcp, + options: { + applyApprovalPolicy: true, + logger, + onToolCall: onAction, + onPreview: (approval) => { + previewApproval = approval; + }, + }, + }), + readDocs(mcp), + ]); + const firecrawlTools = createFirecrawlTools({ + apiKey: chatEnv.FIRECRAWL_API_KEY, + onAction, + }); + const sandboxTools = + sandboxConfig.enabled && chatEnv.E2B_API_KEY + ? createSandboxTools({ + logger, + onAction, + provider: createE2bSandboxProvider({ + apiKey: chatEnv.E2B_API_KEY, + context: { + channelId, + env, + orgId: resourceId, + provider, + threadId, + workspaceId, + }, + sessionTimeoutMs: sandboxConfig.sessionTimeoutMs, + }), + }) + : {}; + if (sandboxConfig.enabled && !chatEnv.E2B_API_KEY) { + logger.warn("Sandbox is enabled without an E2B API key", { + event: "leaf.sandbox_disabled", + }); + } + await onAction?.("Reasoning over the request"); + const agent = createAutumnChatAgent({ + docsText, + env, + model: chatEnv.CHAT_MODEL, + tools: { ...tools, ...firecrawlTools, ...sandboxTools }, + }); + const mastra = new Mastra({ + agents: { chat: agent }, + environment: process.env.NODE_ENV, + logger: false, + observability: createMastraBraintrustObservability(), + storage: new InMemoryStore({ id: `leaf-chat-${crypto.randomUUID()}` }), + }); + const chatAgent = mastra.getAgent("chat"); + + const output = await chatAgent.generate(message, { + maxSteps: 8, + context: [ + { + role: "system", + content: [ + `${provider} thread: ${threadId}. Autumn resource: ${resourceId}.`, + "Answer the latest user message. Use prior thread messages only as context.", + ] + .filter(Boolean) + .join("\n\n"), + }, + ...recentMessageContext(recentMessages), + ], + tracingOptions: createLeafTracingOptions({ + agentRunId, + channelId, + env, + orgId: resourceId, + orgSlug, + provider, + source: "prod", + threadId, + workspaceId, + }), + }); + logger.info("Completed chat agent", { + event: "leaf.agent_completed", + context: { env }, + data: { + finish_reason: output.finishReason, + run_id: output.runId, + }, + }); + return { ...output, env, previewApproval }; + } finally { + await mcp.disconnect(); + logger.debug("Disconnected Autumn MCP client", { + event: "leaf.mcp_client_disconnected", + }); + } +}; diff --git a/apps/leaf/src/agent/attachments.ts b/apps/leaf/src/agent/attachments.ts new file mode 100644 index 000000000..629dfd081 --- /dev/null +++ b/apps/leaf/src/agent/attachments.ts @@ -0,0 +1,139 @@ +import type { AutumnLogger } from "@autumn/logging"; +import type { MessageListInput } from "@mastra/core/agent/message-list"; +import type { Attachment } from "chat"; +import { logger as rootLogger } from "../lib/logger.js"; + +const MAX_ATTACHMENTS = 4; +const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; +const SUPPORTED_MIME_TYPES = new Set([ + "application/pdf", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); + +type AttachmentFetchFallback = ({ + attachment, +}: { + attachment: Attachment; +}) => Promise; + +const getAttachmentLabel = (attachment: Attachment) => + attachment.name ?? attachment.mimeType ?? "unnamed attachment"; + +const isSupportedAttachment = (attachment: Attachment) => + attachment.mimeType ? SUPPORTED_MIME_TYPES.has(attachment.mimeType) : false; + +const fetchAttachmentData = async ({ + attachment, + fetchFallback, +}: { + attachment: Attachment; + fetchFallback?: AttachmentFetchFallback; +}) => { + if (attachment.data) { + return attachment.data instanceof Blob + ? Buffer.from(await attachment.data.arrayBuffer()) + : Buffer.from(attachment.data); + } + if (attachment.fetchData) return attachment.fetchData(); + return fetchFallback?.({ attachment }) ?? null; +}; + +export const prepareAttachmentMessage = async ({ + attachments = [], + fetchFallback, + logger = rootLogger, + text, +}: { + attachments?: Attachment[]; + fetchFallback?: AttachmentFetchFallback; + logger?: AutumnLogger; + text: string; +}) => { + const notes: string[] = []; + const parts: Array<{ + data: Buffer; + filename?: string; + mediaType: string; + type: "file"; + }> = []; + + for (const attachment of attachments.slice(0, MAX_ATTACHMENTS)) { + const label = getAttachmentLabel(attachment); + if (!isSupportedAttachment(attachment)) { + notes.push(`Skipped ${label}: unsupported file type.`); + continue; + } + if (attachment.size && attachment.size > MAX_ATTACHMENT_BYTES) { + notes.push(`Skipped ${label}: file is too large.`); + continue; + } + + try { + const data = await fetchAttachmentData({ attachment, fetchFallback }); + if (!data) { + notes.push(`Skipped ${label}: file could not be downloaded.`); + continue; + } + if (data.byteLength > MAX_ATTACHMENT_BYTES) { + notes.push(`Skipped ${label}: downloaded file is too large.`); + continue; + } + parts.push({ + type: "file", + data, + filename: attachment.name, + mediaType: attachment.mimeType as string, + }); + } catch (error) { + logger.warn("Could not prepare Slack attachment", { + event: "leaf.slack_attachment_prepare_failed", + data: { + name: attachment.name, + mime_type: attachment.mimeType, + size: attachment.size, + }, + error, + }); + notes.push(`Skipped ${label}: file could not be processed.`); + } + } + + if (attachments.length > MAX_ATTACHMENTS) { + notes.push( + `Skipped ${attachments.length - MAX_ATTACHMENTS} extra attachment(s).`, + ); + } + + const userText = [ + text.trim() || "Please answer using the attached Slack file(s).", + notes.length ? `Attachment processing notes:\n${notes.join("\n")}` : null, + ] + .filter((line): line is string => Boolean(line)) + .join("\n\n"); + const message = [ + { + role: "user" as const, + content: [...parts, { type: "text" as const, text: userText }], + }, + ] satisfies MessageListInput; + + return { + attachmentCount: parts.length, + envSelectionText: [ + text, + attachments.length + ? `Slack attachments: ${attachments + .map((attachment) => getAttachmentLabel(attachment)) + .join(", ")}` + : null, + notes.length ? `Attachment notes: ${notes.join(" ")}` : null, + ] + .filter((line): line is string => Boolean(line)) + .join("\n\n"), + message, + notes, + }; +}; diff --git a/apps/leaf/src/agent/chatAgent.ts b/apps/leaf/src/agent/chatAgent.ts new file mode 100644 index 000000000..0cdee8281 --- /dev/null +++ b/apps/leaf/src/agent/chatAgent.ts @@ -0,0 +1,50 @@ +import type { AppEnv } from "@autumn/shared"; +import { Agent } from "@mastra/core/agent"; +import type { ToolsInput } from "@mastra/core/agent"; + +export const agentDocUris = [ + "autumn://docs/tool-composition", + "autumn://docs/feature-catalog", + "autumn://docs/querying-plans", + "autumn://docs/querying-customers", + "autumn://docs/schedules", + "autumn://docs/balances", + "autumn://docs/billing-safety", + "autumn://docs/request-logs", + "autumn://docs/request-log-customers", + "autumn://docs/request-log-balances", + "autumn://docs/request-log-billing", + "autumn://docs/request-log-stripe-webhooks", + "autumn://docs/request-log-analytics", +]; + +export const autumnChatInstructions = `You are Autumn Chat. +Use Autumn MCP tools for customer, plan, balance, schedule, and billing work. +Use web search only for current or external web context. Never use web search for Autumn customer, plan, billing, balance, or schedule state. +When web content influences the answer, cite the source URLs. +Prefer searchWeb first, then scrapeUrl only for the most relevant result. +Use listFeatures only when creating/customizing plan items or setting non-zero prepaid feature quantities and feature ids/types are not already known; never invent feature ids. +Use the sandbox only for short parsing, calculation, transformation, and file-analysis tasks. Never send secrets to the sandbox, never use it for Autumn writes, and treat sandbox output as advisory. +Preview billing-impacting changes first, summarize the preview in short Slack-friendly bullets, then call the matching write tool with the same request args. +When Autumn responses include epoch millisecond timestamps, use epochMillisecondsToDate before explaining those timestamps to a user. +Treat Slack PDFs and images attached to the latest message as part of the user's request. If an attachment was skipped or unavailable, say so briefly instead of pretending to have read it. +The runtime pauses destructive tools for approval before execution, so do not ask for confirmation in plain text.`; + +export const createAutumnChatAgent = ({ + docsText, + env, + model, + tools, +}: { + docsText: string; + env: AppEnv; + model: string; + tools: ToolsInput; +}) => + new Agent({ + id: "autumn-chat", + name: "Autumn Chat", + instructions: `${autumnChatInstructions}\n\nCurrent Autumn environment: ${env}.\n\n${docsText}`, + model, + tools, + }); diff --git a/apps/leaf/src/agent/firecrawl.ts b/apps/leaf/src/agent/firecrawl.ts new file mode 100644 index 000000000..8985d5c5d --- /dev/null +++ b/apps/leaf/src/agent/firecrawl.ts @@ -0,0 +1,89 @@ +import { createTool } from "@mastra/core/tools"; +import Firecrawl from "@mendable/firecrawl-js"; +import { z } from "zod"; + +type FirecrawlClient = { + search: ( + query: string, + options?: { limit?: number; sources?: ["web"] }, + ) => Promise<{ web?: unknown[] }>; + scrape: ( + url: string, + options?: { formats?: ["markdown"] }, + ) => Promise<{ + markdown?: string; + metadata?: { title?: string; sourceURL?: string }; + }>; +}; + +const maxSearchResults = 5; +const maxMarkdownLength = 12_000; + +const trimMarkdown = (markdown = "") => + markdown + .replace(/\n{3,}/g, "\n\n") + .trim() + .slice(0, maxMarkdownLength); + +const stringField = (value: unknown, field: string) => + value && typeof value === "object" + ? (value as Record)[field] + : undefined; + +export const createFirecrawlTools = ({ + apiKey, + client, + onAction, +}: { + apiKey: string; + client?: FirecrawlClient; + onAction?: (message: string) => Promise | void; +}): Record> => { + const firecrawl = client ?? new Firecrawl({ apiKey }); + + return { + searchWeb: createTool({ + id: "searchWeb", + description: + "Search the public web for current or external information. Use Autumn MCP tools instead for Autumn customer, plan, billing, balance, or schedule data.", + inputSchema: z + .object({ + query: z.string().min(1), + limit: z.number().int().positive().max(maxSearchResults).optional(), + }) + .strict(), + execute: async ({ query, limit = maxSearchResults }) => { + await onAction?.("Searching web"); + const results = await firecrawl.search(query, { + limit, + sources: ["web"], + }); + return { + results: (results.web ?? []).slice(0, limit).map((result) => ({ + title: + stringField(result, "title") ?? + stringField(result, "url") ?? + "Untitled", + url: stringField(result, "url"), + description: stringField(result, "description"), + })), + }; + }, + }), + scrapeUrl: createTool({ + id: "scrapeUrl", + description: + "Read one public web page as markdown after searchWeb identifies a relevant URL.", + inputSchema: z.object({ url: z.string().url() }).strict(), + execute: async ({ url }) => { + await onAction?.("Reading page"); + const page = await firecrawl.scrape(url, { formats: ["markdown"] }); + return { + title: page.metadata?.title, + url: page.metadata?.sourceURL ?? url, + markdown: trimMarkdown(page.markdown), + }; + }, + }), + }; +}; diff --git a/apps/leaf/src/agent/mcp.ts b/apps/leaf/src/agent/mcp.ts new file mode 100644 index 000000000..5bbbdeb12 --- /dev/null +++ b/apps/leaf/src/agent/mcp.ts @@ -0,0 +1,181 @@ +import { isSecretKeyPrefix } from "@autumn/auth"; +import type { AutumnLogger } from "@autumn/logging"; +import type { AppEnv } from "@autumn/shared"; +import { MCPClient } from "@mastra/mcp"; +import { env } from "../lib/env.js"; +import { logger as rootLogger } from "../lib/logger.js"; +import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js"; + +type AutumnTool = { + execute?: ( + args: Record, + ...rest: unknown[] + ) => Promise; + mcp?: { annotations?: { destructiveHint?: boolean } }; + requireApproval?: boolean; + needsApprovalFn?: unknown; +}; + +type ToolOptions = { + applyApprovalPolicy?: boolean; + logger?: AutumnLogger; + onToolCall?: (message: string) => Promise | void; + onPreview?: (approval: { + toolName: string; + toolArgs: Record; + preview: unknown; + }) => void; +}; + +const withAuthFetch = + ({ appEnv, token }: { appEnv: AppEnv; token: string }) => + (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${token}`); + headers.set("x-autumn-environment", appEnv); + if (isSecretKeyPrefix({ token })) { + headers.set("secret-key", token); + } + return fetch(input, { ...init, headers }); + }; + +export const createAutumnMcpClient = ({ + token, + appEnv, + options = {}, +}: { + token: string; + appEnv: AppEnv; + options?: { requireApproval?: boolean }; +}) => { + const fetchWithAuth = withAuthFetch({ appEnv, token }); + const headers: Record = { + Authorization: `Bearer ${token}`, + "x-autumn-environment": appEnv, + }; + if (isSecretKeyPrefix({ token })) { + headers["secret-key"] = token; + } + + return new MCPClient({ + id: `autumn-${token.slice(0, 14)}`, + servers: { + autumn: { + url: new URL("/mcp", env.MCP_SERVER_URL), + requestInit: { headers }, + eventSourceInit: { fetch: fetchWithAuth }, + fetch: fetchWithAuth, + requireToolApproval: options.requireApproval + ? ({ annotations }) => annotations?.destructiveHint === true + : false, + }, + }, + }); +}; + +const formatToolAction = ({ + toolName, + args, +}: { + toolName: string; + args: Record; +}) => { + const request = + args.request && typeof args.request === "object" + ? (args.request as Record) + : args; + const details = [ + ["customer", request.customer_id], + ["plan", request.plan_id], + ["entity", request.entity_id], + ["search", request.search], + ].flatMap(([label, value]) => + typeof value === "string" && value ? [`${label}: ${value}`] : [], + ); + + return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`; +}; + +export const getAutumnMcpTools = async ({ + mcp, + options = {}, +}: { + mcp: MCPClient; + options?: ToolOptions; +}) => { + const logger = options.logger ?? rootLogger; + const { toolsets, errors } = await mcp.listToolsetsWithErrors(); + if (Object.keys(errors).length) { + logger.error("Could not load Autumn MCP tools", { + event: "leaf.mcp_tools_load_failed", + data: { errors }, + }); + throw new Error( + `Could not load Autumn MCP tools: ${JSON.stringify(errors)}`, + ); + } + + const tools = (toolsets.autumn ?? {}) as Record; + logger.info("Loaded Autumn MCP tools", { + event: "leaf.mcp_tools_loaded", + data: { + tool_count: Object.keys(tools).length, + }, + }); + for (const [toolName, tool] of Object.entries(tools)) { + if (options.applyApprovalPolicy) { + tool.requireApproval = tool.mcp?.annotations?.destructiveHint === true; + if (!tool.requireApproval) tool.needsApprovalFn = undefined; + } + if (tool.execute && (options.onToolCall || options.onPreview)) { + const execute = tool.execute.bind(tool); + tool.execute = async (args, ...rest) => { + logger.info("Calling Autumn MCP tool", { + event: "leaf.mcp_tool_called", + tool: toolName, + }); + await options.onToolCall?.(formatToolAction({ toolName, args })); + const result = await execute(args, ...rest); + const writeTool = getWriteToolForPreview(toolName); + if (writeTool) { + logger.info("Captured Autumn MCP preview", { + event: "leaf.mcp_preview_captured", + tool: writeTool, + data: { + preview_tool: toolName, + }, + }); + options.onPreview?.({ + toolName: writeTool, + toolArgs: args, + preview: result, + }); + } + return result; + }; + } + } + return tools; +}; + +export const executeAutumnMcpTool = async ({ + env, + token, + toolName, + args, +}: { + env: AppEnv; + token: string; + toolName: string; + args: Record; +}) => { + const mcp = createAutumnMcpClient({ token, appEnv: env }); + try { + const tools = await getAutumnMcpTools({ mcp }); + const tool = tools[toolName.replace(/^autumn_/, "")]; + if (!tool?.execute) throw new Error(`Unknown Autumn MCP tool: ${toolName}`); + return await tool.execute(args); + } finally { + await mcp.disconnect(); + } +}; diff --git a/apps/leaf/src/agent/messages.ts b/apps/leaf/src/agent/messages.ts new file mode 100644 index 000000000..43f73f8ad --- /dev/null +++ b/apps/leaf/src/agent/messages.ts @@ -0,0 +1,72 @@ +import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js"; +import { logger as rootLogger } from "../lib/logger.js"; +import { agentOutputSchema, type BotMessage } from "../types.js"; +import { runChatAgent, selectChatEnv } from "./agent.js"; +import { prepareAttachmentMessage } from "./attachments.js"; + +const withTimeout = (promise: Promise, ms: number) => + new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Chat agent timed out")), + ms, + ); + promise.then(resolve, reject).finally(() => clearTimeout(timeout)); + }); + +export const runMessage = async ({ + agentRunId, + attachmentFetchFallback, + attachments, + installation, + logger = rootLogger, + onAction, + recentMessages, + text, + channelId, + threadId, +}: BotMessage) => + withTimeout( + (async () => { + const preparedMessage = await prepareAttachmentMessage({ + attachments, + fetchFallback: attachmentFetchFallback, + logger, + text, + }); + const env = await selectChatEnv({ + message: preparedMessage.envSelectionText, + recentMessages, + logger, + }); + logger.info("Selected chat environment", { + event: "leaf.chat_env_selected", + context: { + env, + org_id: installation.org_id, + provider: installation.provider, + }, + }); + const token = await getInstallationOAuthAccessToken({ + installation, + env, + }); + return agentOutputSchema.parse( + await runChatAgent({ + token, + env, + logger, + message: preparedMessage.message, + onAction, + channelId, + threadId, + agentRunId, + resourceId: installation.org_id, + orgSlug: installation.org_slug, + provider: installation.provider, + workspaceId: installation.workspace_id, + recentMessages, + }), + ); + })(), + 60_000, + ); diff --git a/apps/leaf/src/agent/sandbox/config.ts b/apps/leaf/src/agent/sandbox/config.ts new file mode 100644 index 000000000..eef58b7f5 --- /dev/null +++ b/apps/leaf/src/agent/sandbox/config.ts @@ -0,0 +1,4 @@ +export const sandboxConfig = { + enabled: true, + sessionTimeoutMs: 10 * 60 * 1000, +}; diff --git a/apps/leaf/src/agent/sandbox/createSandboxTools.ts b/apps/leaf/src/agent/sandbox/createSandboxTools.ts new file mode 100644 index 000000000..50e41c2e5 --- /dev/null +++ b/apps/leaf/src/agent/sandbox/createSandboxTools.ts @@ -0,0 +1,83 @@ +import type { AutumnLogger } from "@autumn/logging"; +import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; +import { logger as rootLogger } from "../../lib/logger.js"; +import { + assertSafeSandboxCommand, + sandboxLimits, + sanitizeReturnFiles, + sanitizeSandboxFiles, + truncateSandboxResult, +} from "./guardrails.js"; +import type { SandboxProvider } from "./types.js"; + +const sandboxFileSchema = z + .object({ + path: z.string().min(1), + content: z.string(), + }) + .strict(); + +export const createSandboxTools = ({ + logger = rootLogger, + onAction, + provider, +}: { + logger?: AutumnLogger; + onAction?: (message: string) => Promise | void; + provider: SandboxProvider; +}): Record> => ({ + runSandboxCommand: createTool({ + id: "runSandboxCommand", + description: + "Run a short command in an isolated sandbox for parsing, calculations, JSON/CSV transforms, and file analysis. Do not use it for Autumn writes, credentials, secrets, or direct API calls.", + inputSchema: z + .object({ + task: z.string().min(1), + command: z.string().min(1), + files: z + .array(sandboxFileSchema) + .max(sandboxLimits.maxFiles) + .optional(), + returnFiles: z + .array(z.string().min(1)) + .max(sandboxLimits.maxFiles) + .optional(), + }) + .strict(), + execute: async ({ task, command, files = [], returnFiles = [] }) => { + await onAction?.("Running sandbox analysis"); + const startedAt = Date.now(); + const safeCommand = assertSafeSandboxCommand(command); + const safeFiles = sanitizeSandboxFiles(files); + const safeReturnFiles = sanitizeReturnFiles(returnFiles); + logger.info("Calling sandbox tool", { + event: "leaf.sandbox_tool_called", + data: { + file_count: safeFiles.length, + return_file_count: safeReturnFiles.length, + command_length: safeCommand.length, + task_length: task.length, + }, + }); + const result = truncateSandboxResult( + await provider.run({ + command: safeCommand, + files: safeFiles, + returnFiles: safeReturnFiles, + timeoutMs: sandboxLimits.timeoutMs, + }), + ); + logger.info("Completed sandbox tool", { + event: "leaf.sandbox_tool_completed", + data: { + duration_ms: Date.now() - startedAt, + file_count: result.files.length, + exit_code: result.exitCode, + timed_out: result.timedOut, + }, + }); + return result; + }, + }), +}); diff --git a/apps/leaf/src/agent/sandbox/guardrails.ts b/apps/leaf/src/agent/sandbox/guardrails.ts new file mode 100644 index 000000000..0bca6deb3 --- /dev/null +++ b/apps/leaf/src/agent/sandbox/guardrails.ts @@ -0,0 +1,106 @@ +import path from "node:path"; +import type { SandboxFile, SandboxRunResult } from "./types.js"; + +export const sandboxLimits = { + maxFiles: 5, + maxInputBytes: 256 * 1024, + maxOutputBytes: 24 * 1024, + maxReturnedFileBytes: 24 * 1024, + timeoutMs: 20_000, + workDir: "/work", +}; + +const secretPatterns = [ + /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/i, + /\bxox[baprs]-[A-Za-z0-9-]{10,}/i, + /\bsk_(?:live|test|proj)?_[A-Za-z0-9]{12,}/i, + /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b/, + /^\s*[A-Z0-9_]*(?:SECRET|TOKEN|API_KEY|PASSWORD)[A-Z0-9_]*\s*=\s*\S+/im, +]; + +const byteLength = (value: string) => Buffer.byteLength(value, "utf8"); + +const assertNoSecrets = (value: string) => { + if (secretPatterns.some((pattern) => pattern.test(value))) { + throw new Error("Sandbox input appears to contain a secret or token"); + } +}; + +const normalizePath = (filePath: string) => { + const normalized = path.posix.normalize( + filePath.startsWith("/") + ? filePath + : path.posix.join(sandboxLimits.workDir, filePath), + ); + if ( + normalized !== sandboxLimits.workDir && + !normalized.startsWith(`${sandboxLimits.workDir}/`) + ) { + throw new Error("Sandbox files must stay under /work"); + } + if (path.posix.basename(normalized).toLowerCase() === ".env") { + throw new Error("Sandbox files cannot be named .env"); + } + return normalized; +}; + +export const sanitizeSandboxFiles = (files: SandboxFile[] = []) => { + if (files.length > sandboxLimits.maxFiles) { + throw new Error( + `Sandbox input cannot exceed ${sandboxLimits.maxFiles} files`, + ); + } + + let bytes = 0; + const sanitized = files.map((file) => { + assertNoSecrets(file.path); + assertNoSecrets(file.content); + bytes += byteLength(file.content); + if (bytes > sandboxLimits.maxInputBytes) { + throw new Error("Sandbox input is too large"); + } + return { + path: normalizePath(file.path), + content: file.content, + }; + }); + + const seen = new Set(); + for (const file of sanitized) { + if (seen.has(file.path)) + throw new Error(`Duplicate sandbox file: ${file.path}`); + seen.add(file.path); + } + return sanitized; +}; + +export const sanitizeReturnFiles = (returnFiles: string[] = []) => + returnFiles.map(normalizePath); + +export const assertSafeSandboxCommand = (command: string) => { + if (!command.trim()) throw new Error("Sandbox command cannot be empty"); + assertNoSecrets(command); + return command.trim(); +}; + +export const truncateText = (value: string, maxBytes: number) => { + if (byteLength(value) <= maxBytes) return value; + let output = ""; + for (const char of value) { + if (byteLength(`${output}${char}`) > maxBytes) break; + output += char; + } + return `${output}\n[truncated]`; +}; + +export const truncateSandboxResult = ( + result: SandboxRunResult, +): SandboxRunResult => ({ + ...result, + stdout: truncateText(result.stdout, sandboxLimits.maxOutputBytes), + stderr: truncateText(result.stderr, sandboxLimits.maxOutputBytes), + files: result.files.map((file) => ({ + path: file.path, + content: truncateText(file.content, sandboxLimits.maxReturnedFileBytes), + })), +}); diff --git a/apps/leaf/src/agent/sandbox/types.ts b/apps/leaf/src/agent/sandbox/types.ts new file mode 100644 index 000000000..ea85e61c9 --- /dev/null +++ b/apps/leaf/src/agent/sandbox/types.ts @@ -0,0 +1,32 @@ +export type SandboxFile = { + path: string; + content: string; +}; + +export type SandboxRunArgs = { + command: string; + files: SandboxFile[]; + returnFiles: string[]; + timeoutMs: number; +}; + +export type SandboxRunResult = { + stdout: string; + stderr: string; + exitCode?: number; + timedOut: boolean; + files: SandboxFile[]; +}; + +export type SandboxSessionContext = { + channelId: string; + env: string; + orgId: string; + provider: string; + threadId: string; + workspaceId: string; +}; + +export type SandboxProvider = { + run(args: SandboxRunArgs): Promise; +}; diff --git a/apps/leaf/src/agent/toolPolicy.ts b/apps/leaf/src/agent/toolPolicy.ts new file mode 100644 index 000000000..af27937a1 --- /dev/null +++ b/apps/leaf/src/agent/toolPolicy.ts @@ -0,0 +1,24 @@ +const labels: Record = { + attach: "Attach plan", + updateSubscription: "Update subscription", + createSchedule: "Create schedule", + createBalance: "Create balance", + createPlan: "Create plan", +}; + +const previewWriteTools: Record = { + previewAttach: "attach", + previewUpdateSubscription: "updateSubscription", + previewCreateSchedule: "createSchedule", + previewCreateBalance: "createBalance", +}; + +export const getWriteToolForPreview = (toolName: string) => + previewWriteTools[toolName.replace(/^autumn_/, "")]; + +export const toolLabel = (toolName: string) => + labels[toolName.replace(/^autumn_/, "")] ?? + toolName + .replace(/^autumn_/, "") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/^./, (char) => char.toUpperCase()); diff --git a/apps/leaf/src/approvals/errors.ts b/apps/leaf/src/approvals/errors.ts new file mode 100644 index 000000000..120b12105 --- /dev/null +++ b/apps/leaf/src/approvals/errors.ts @@ -0,0 +1,89 @@ +const MAX_ERROR_MESSAGE_LENGTH = 700; + +const cleanMessage = (message: string) => + message + .replace(/^Error:\s*/, "") + .replace(/\s+/g, " ") + .trim(); + +const truncateMessage = (message: string) => + message.length > MAX_ERROR_MESSAGE_LENGTH + ? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 1)}…` + : message; + +const parseAutumnApiErrorMessage = (message: string) => { + const match = message.match(/Autumn API request failed \(\d+\):\s*(.+)$/s); + if (!match) return null; + + try { + const parsed = JSON.parse(match[1] ?? ""); + return typeof parsed?.message === "string" ? parsed.message : null; + } catch { + return null; + } +}; + +const getMcpContentText = (value: Record): string | null => { + if (!Array.isArray(value.content)) return null; + const item = value.content.find((entry): entry is { text: string } => + Boolean( + entry && + typeof entry === "object" && + "text" in entry && + typeof entry.text === "string", + ), + ); + return item?.text ?? null; +}; + +const getObjectMessage = (value: Record): string | null => { + if (typeof value.message === "string") return value.message; + if (typeof value.error === "string") return value.error; + if ( + value.error && + typeof value.error === "object" && + typeof (value.error as { message?: unknown }).message === "string" + ) { + return (value.error as { message: string }).message; + } + if ( + value.details && + typeof value.details === "object" && + typeof (value.details as { errorMessage?: unknown }).errorMessage === + "string" + ) { + return (value.details as { errorMessage: string }).errorMessage; + } + const contentText = getMcpContentText(value); + if (!contentText) return null; + try { + const parsed = JSON.parse(contentText); + if (parsed && typeof parsed === "object") { + return getObjectMessage(parsed as Record) ?? contentText; + } + } catch { + return contentText; + } + return null; +}; + +export const approvalErrorResult = (error: unknown) => { + const rawMessage = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : error && typeof error === "object" + ? (getObjectMessage(error as Record) ?? + "The action failed.") + : "The action failed."; + const cleanedRawMessage = cleanMessage(rawMessage); + const message = cleanMessage( + parseAutumnApiErrorMessage(cleanedRawMessage) ?? cleanedRawMessage, + ); + + return { + error: true, + message: truncateMessage(message || "The action failed."), + }; +}; diff --git a/apps/leaf/src/approvals/flow.ts b/apps/leaf/src/approvals/flow.ts new file mode 100644 index 000000000..ed1d3f943 --- /dev/null +++ b/apps/leaf/src/approvals/flow.ts @@ -0,0 +1,204 @@ +import type { AutumnLogger } from "@autumn/logging"; +import type { ChatApproval, ChatInstallation } from "@autumn/shared"; +import type { ActionEvent } from "chat"; +import { toolLabel } from "../agent/toolPolicy.js"; +import { logger as rootLogger } from "../lib/logger.js"; +import type { AgentOutput } from "../types.js"; +import { approvalCard, approvalStatusCard } from "../ui/blocks.js"; +import { + finishLoading, + type LoadingState, + type ReplyTarget, +} from "../ui/progress.js"; +import { approvalErrorResult } from "./errors.js"; +import { approvalRequestFromOutput } from "./request.js"; +import { + approveAndRun, + cancelApproval, + createApproval, + getApproval, + isErrorResult, +} from "./store.js"; + +export const postApprovalRequest = async ({ + channelId, + installation, + loading, + logAction, + logger = rootLogger, + output, + providerUserId, + target, +}: { + channelId: string; + installation: ChatInstallation; + loading: LoadingState; + logAction: (message: string) => Promise | void; + logger?: AutumnLogger; + output: AgentOutput; + providerUserId: string; + target: ReplyTarget; +}) => { + const approval = approvalRequestFromOutput(output); + if (!approval) return false; + + const approvalId = await createApproval({ + orgId: installation.org_id, + provider: installation.provider, + workspaceId: installation.workspace_id, + channelId, + providerUserId, + ...approval, + }); + + await logAction(`Waiting for approval: ${toolLabel(approval.toolName)}`); + logger.info("Created approval request", { + event: "leaf.approval_created", + context: { + env: approval.env, + org_id: installation.org_id, + }, + approval_id: approvalId, + tool: approval.toolName, + }); + await finishLoading(target, loading, "Preview ready."); + await target.post( + approvalCard({ + id: approvalId, + env: approval.env, + toolName: approval.toolName, + toolArgs: approval.toolArgs, + preview: approval.preview, + }), + ); + return true; +}; + +const detailsFromApproval = (approval?: ChatApproval) => ({ + toolName: approval?.tool_name ?? "billing action", + toolArgs: + approval?.tool_args && typeof approval.tool_args === "object" + ? (approval.tool_args as Record) + : undefined, + preview: approval?.preview, + env: approval?.env, +}); + +const editActionMessage = async ( + event: ActionEvent, + content: Parameters>[2], +) => { + await event.adapter.editMessage?.(event.threadId, event.messageId, content); +}; + +type ApprovalActionDeps = { + approveAndRun: typeof approveAndRun; + cancelApproval: typeof cancelApproval; + editActionMessage: typeof editActionMessage; + getApproval: typeof getApproval; + logger: Pick; +}; + +const defaultApprovalActionDeps = { + approveAndRun, + cancelApproval, + editActionMessage, + getApproval, + logger: rootLogger, +} satisfies ApprovalActionDeps; + +const cardStatusForApproval = ( + status?: string, +): "approved" | "cancelled" | "failed" | "running" => { + if (status === "approved" || status === "cancelled" || status === "running") + return status; + return "failed"; +}; + +export const handleApprovalActionWithDeps = async ( + event: ActionEvent, + deps: ApprovalActionDeps = defaultApprovalActionDeps, +) => { + if (!event.value) return; + + try { + deps.logger.info("Received approval action", { + event: "leaf.approval_action_received", + approval_id: event.value, + action: event.actionId, + data: { + provider_user_id: event.user.userId, + }, + }); + const details = detailsFromApproval(await deps.getApproval(event.value)); + if (event.actionId === "cancel_billing_action") { + const cancelled = await deps.cancelApproval( + event.value, + event.user.userId, + ); + if (!cancelled) { + deps.logger.warn("Approval cancellation ignored", { + event: "leaf.approval_cancel_ignored", + approval_id: event.value, + }); + const current = await deps.getApproval(event.value); + await deps.editActionMessage( + event, + approvalStatusCard({ + status: cardStatusForApproval(current?.status), + ...details, + }), + ); + return; + } + await deps.editActionMessage( + event, + approvalStatusCard({ status: "cancelled", ...details }), + ); + deps.logger.info("Cancelled approval", { + event: "leaf.approval_cancelled", + approval_id: event.value, + tool: details.toolName, + }); + return; + } + + await deps.editActionMessage( + event, + approvalStatusCard({ status: "running", ...details }), + ); + const result = await deps.approveAndRun(event.value, event.user.userId); + deps.logger.info("Completed approval action", { + event: "leaf.approval_completed", + approval_id: event.value, + status: isErrorResult(result) ? "failed" : "approved", + tool: details.toolName, + }); + await deps.editActionMessage( + event, + approvalStatusCard({ + status: isErrorResult(result) ? "failed" : "approved", + ...details, + result, + }), + ); + } catch (error) { + deps.logger.error("[chat] Approval action failed", error, { + event: "leaf.approval_failed", + approval_id: event.value, + action: event.actionId, + }); + const current = await deps.getApproval(event.value); + await deps.editActionMessage( + event, + approvalStatusCard({ + status: cardStatusForApproval(current?.status), + ...detailsFromApproval(current), + result: approvalErrorResult(error), + }), + ); + } +}; + +export const handleApprovalAction = async (event: ActionEvent) => + handleApprovalActionWithDeps(event); diff --git a/apps/leaf/src/approvals/request.ts b/apps/leaf/src/approvals/request.ts new file mode 100644 index 000000000..ea6acd91a --- /dev/null +++ b/apps/leaf/src/approvals/request.ts @@ -0,0 +1,26 @@ +import type { AgentOutput } from "../types.js"; + +export const approvalRequestFromOutput = (output: AgentOutput) => { + if (output.finishReason === "suspended" && output.suspendPayload) { + return { + env: output.env, + runId: output.runId, + toolCallId: output.suspendPayload.toolCallId, + toolName: output.suspendPayload.toolName, + toolArgs: output.suspendPayload.args ?? {}, + preview: + output.text || + output.previewApproval?.preview || + output.suspendPayload.args, + }; + } + + if (output.previewApproval) { + return { + env: output.env, + toolName: output.previewApproval.toolName, + toolArgs: output.previewApproval.toolArgs, + preview: output.text || output.previewApproval.preview, + }; + } +}; diff --git a/apps/leaf/src/approvals/store.ts b/apps/leaf/src/approvals/store.ts new file mode 100644 index 000000000..47701b102 --- /dev/null +++ b/apps/leaf/src/approvals/store.ts @@ -0,0 +1,165 @@ +import crypto from "node:crypto"; +import { + type AppEnv, + type ChatProvider, + chatApprovals, + chatInstallations, +} from "@autumn/shared"; +import { addMinutes, isPast } from "date-fns"; +import { and, eq, gt } from "drizzle-orm"; +import { executeAutumnMcpTool } from "../agent/mcp.js"; +import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js"; +import { db } from "../lib/db.js"; +import { approvalErrorResult } from "./errors.js"; + +export const normalizeToolName = (toolName: string) => + toolName.replace(/^autumn_/, ""); + +export const isErrorResult = (result: unknown): boolean => + typeof result === "object" && + result !== null && + ("error" in result || + (result as { isError?: unknown }).isError === true || + (result as { id?: unknown }).id === "TOOL_EXECUTION_FAILED" || + (typeof (result as { code?: unknown }).code === "string" && + typeof (result as { message?: unknown }).message === "string")); + +export const createApproval = async ({ + orgId, + provider, + workspaceId, + channelId, + providerUserId, + env, + runId, + toolCallId, + toolName, + toolArgs, + preview, +}: { + orgId: string; + provider: ChatProvider; + workspaceId: string; + channelId: string; + providerUserId: string; + env: AppEnv; + runId?: string; + toolCallId?: string; + toolName: string; + toolArgs: Record; + preview?: unknown; +}) => { + const id = `chat_app_${crypto.randomUUID().replace(/-/g, "")}`; + await db.insert(chatApprovals).values({ + id, + org_id: orgId, + provider, + workspace_id: workspaceId, + channel_id: channelId, + provider_user_id: providerUserId, + env, + run_id: runId, + tool_call_id: toolCallId, + tool_name: normalizeToolName(toolName), + tool_args: toolArgs, + preview, + status: "pending", + created_at: Date.now(), + expires_at: addMinutes(Date.now(), 15).getTime(), + }); + return id; +}; + +export const cancelApproval = async (id: string, providerUserId: string) => { + const [claimed] = await db + .update(chatApprovals) + .set({ + status: "cancelled", + decided_at: Date.now(), + decided_by_provider_user_id: providerUserId, + }) + .where(and(eq(chatApprovals.id, id), eq(chatApprovals.status, "pending"))) + .returning(); + return claimed; +}; + +export const getApproval = async (id: string) => + await db.query.chatApprovals.findFirst({ + where: eq(chatApprovals.id, id), + }); + +export const approveAndRun = async (id: string, providerUserId: string) => { + const approval = await db.query.chatApprovals.findFirst({ + where: eq(chatApprovals.id, id), + }); + if ( + !approval || + approval.status !== "pending" || + isPast(approval.expires_at) + ) { + throw new Error("Approval is no longer pending"); + } + + const [claimed] = await db + .update(chatApprovals) + .set({ + status: "running", + decided_at: Date.now(), + decided_by_provider_user_id: providerUserId, + }) + .where( + and( + eq(chatApprovals.id, id), + eq(chatApprovals.status, "pending"), + gt(chatApprovals.expires_at, Date.now()), + ), + ) + .returning(); + if (!claimed) throw new Error("Approval is no longer pending"); + + try { + const installation = await db.query.chatInstallations.findFirst({ + where: and( + eq(chatInstallations.org_id, claimed.org_id), + eq(chatInstallations.provider, claimed.provider), + eq(chatInstallations.workspace_id, claimed.workspace_id), + ), + }); + if (!installation) throw new Error("Chat installation not found"); + + const token = await getInstallationOAuthAccessToken({ + installation, + env: claimed.env, + }); + + const rawResult = await executeAutumnMcpTool({ + token, + env: claimed.env, + toolName: claimed.tool_name, + args: claimed.tool_args, + }); + const result = isErrorResult(rawResult) + ? approvalErrorResult(rawResult) + : rawResult; + await db + .update(chatApprovals) + .set({ + status: isErrorResult(result) ? "failed" : "approved", + decided_at: Date.now(), + decided_by_provider_user_id: providerUserId, + }) + .where(eq(chatApprovals.id, id)); + return result; + } catch (error) { + const result = approvalErrorResult(error); + await db + .update(chatApprovals) + .set({ + status: "failed", + decided_at: Date.now(), + decided_by_provider_user_id: providerUserId, + }) + .where(eq(chatApprovals.id, id)); + return result; + } +}; diff --git a/apps/leaf/src/bot.ts b/apps/leaf/src/bot.ts new file mode 100644 index 000000000..25bc18215 --- /dev/null +++ b/apps/leaf/src/bot.ts @@ -0,0 +1,228 @@ +import { createSlackAdapter } from "@chat-adapter/slack"; +import { createPostgresState } from "@chat-adapter/state-pg"; +import type { Attachment, Message, Thread } from "chat"; +import { Chat } from "chat"; +import { runMessage } from "./agent/messages.js"; +import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.js"; +import { decrypt } from "./lib/crypto.js"; +import { env } from "./lib/env.js"; +import { + addLeafContext, + createLeafSessionContext, + logger as rootLogger, +} from "./lib/logger.js"; +import { getSlackWorkspaceId } from "./providers/slack/context.js"; +import { + fetchSlackAttachmentFallback, + getSlackFilesFromRaw, +} from "./providers/slack/files.js"; +import { findInstallationWithOrg } from "./providers/slack/installations.js"; +import { getRecentMessages } from "./providers/slack/threadContext.js"; +import type { ChatContextMessage } from "./types.js"; +import { + createActionLogger, + finishLoading, + type LoadingState, + type ReplyTarget, + startLoading, +} from "./ui/progress.js"; + +export const chatAdapterNames = ["slack"]; + +const getSlackAdminProvider = () => + `slack_admin:${env.SLACK_CLIENT_ID}` as const; + +const findSlackInstallationForWorkspace = async ({ + workspaceId, +}: { + workspaceId: string; +}) => { + return ( + (await findInstallationWithOrg(getSlackAdminProvider(), workspaceId)) ?? + (await findInstallationWithOrg("slack", workspaceId)) + ); +}; + +export const bot = new Chat({ + userName: env.CHAT_NAME, + adapters: { + slack: createSlackAdapter({ + clientId: env.SLACK_CLIENT_ID, + clientSecret: env.SLACK_CLIENT_SECRET, + installationProvider: { + getInstallation: async (workspaceId) => { + const installation = await findSlackInstallationForWorkspace({ + workspaceId, + }); + if (!installation) return null; + return { + botToken: decrypt(installation.bot_access_token), + botUserId: installation.bot_user_id ?? undefined, + teamName: installation.workspace_name, + }; + }, + }, + signingSecret: env.SLACK_SIGNING_SECRET, + userName: env.CHAT_NAME, + }), + }, + state: createPostgresState({ + keyPrefix: "chat", + url: env.CHAT_STATE_DATABASE_URL, + }), + concurrency: "queue", +}); + +const runAndReply = async ({ + channelId, + attachments, + providerUserId, + raw, + recentMessages, + target, + text, + threadId, +}: { + attachments?: Attachment[]; + channelId: string; + providerUserId: string; + raw: unknown; + recentMessages?: ChatContextMessage[]; + target: ReplyTarget; + text: string; + threadId: string; +}) => { + let loading: LoadingState = null; + let logger = rootLogger; + try { + const workspaceId = getSlackWorkspaceId(raw); + const installation = await findSlackInstallationForWorkspace({ + workspaceId, + }); + if (!installation) { + logger.warn("Slack installation not found", { + event: "leaf.slack_installation_missing", + }); + return; + } + + const session = createLeafSessionContext({ + channelId, + provider: installation.provider, + providerUserId, + threadId, + workspaceId, + }); + logger = addLeafContext(rootLogger, { + ...session.context, + agent_run_id: session.agentRunId, + org_id: installation.org_id, + org_slug: installation.org_slug, + }); + logger.info("Received Slack message", { + event: "leaf.slack_message_received", + data: { + attachment_count: attachments?.length ?? 0, + text_length: text.length, + }, + }); + if (!text.trim() && !attachments?.length) { + logger.info("Skipping empty Slack message", { + event: "leaf.slack_message_skipped", + data: { reason: "empty" }, + }); + return; + } + + loading = await startLoading(target); + const logAction = createActionLogger(loading); + const rawFiles = getSlackFilesFromRaw({ raw }); + const botToken = decrypt(installation.bot_access_token); + const output = await runMessage({ + agentRunId: session.agentRunId, + attachmentFetchFallback: ({ attachment }) => + fetchSlackAttachmentFallback({ + attachment, + botToken, + rawFiles, + }), + attachments, + installation, + logger, + onAction: logAction, + recentMessages, + text, + channelId, + threadId, + }); + + const postedApproval = await postApprovalRequest({ + channelId, + installation, + loading, + logAction, + logger, + output, + providerUserId, + target, + }); + if (postedApproval) return; + + await finishLoading(target, loading, "Done."); + await target.post({ markdown: output.text || "Done." }); + logger.info("Posted Slack response", { + event: "leaf.slack_response_posted", + data: { + has_text: Boolean(output.text), + }, + }); + } catch (error) { + logger.error("[chat] Message failed", error, { + event: "leaf.slack_message_failed", + }); + await finishLoading(target, loading, "Request failed."); + await target.post({ + markdown: "I could not complete that request. Please try again.", + }); + } +}; + +const handleMessage = async (thread: Thread, message: Message) => { + await runAndReply({ + target: thread, + attachments: message.attachments, + raw: message.raw, + text: message.text, + channelId: thread.channelId, + providerUserId: message.author.userId, + threadId: thread.id, + recentMessages: await getRecentMessages(thread, message), + }); +}; + +bot.onDirectMessage(handleMessage); + +bot.onNewMention(async (thread, message) => { + await thread.subscribe(); + await handleMessage(thread, message); +}); + +bot.onSubscribedMessage(handleMessage); + +bot.onSlashCommand(async (event) => { + await runAndReply({ + target: event.channel, + raw: event.raw, + text: event.text || event.command, + channelId: event.channel.id, + providerUserId: event.user.userId, + threadId: event.channel.id, + }); +}); + +bot.onAction( + ["approve_billing_action", "cancel_billing_action"], + handleApprovalAction, +); + +bot.registerSingleton(); diff --git a/apps/leaf/src/index.ts b/apps/leaf/src/index.ts new file mode 100644 index 000000000..960d7a31c --- /dev/null +++ b/apps/leaf/src/index.ts @@ -0,0 +1,4 @@ +import { initInfisical } from "@autumn/shared/utils/infisical"; + +await initInfisical(); +await import("./main.js"); diff --git a/apps/leaf/src/internal/installations/actions/getInstallationOAuthAccessToken.ts b/apps/leaf/src/internal/installations/actions/getInstallationOAuthAccessToken.ts new file mode 100644 index 000000000..fe9f5348c --- /dev/null +++ b/apps/leaf/src/internal/installations/actions/getInstallationOAuthAccessToken.ts @@ -0,0 +1,103 @@ +import type { AppEnv, ChatInstallation } from "@autumn/shared"; +import { decrypt, encrypt } from "../../../lib/crypto.js"; +import { db } from "../../../lib/db.js"; +import { env as leafEnv } from "../../../lib/env.js"; +import { + getChatOAuthCredentialByInstallationEnv, + updateChatOAuthCredentialTokens, +} from "../repos/chatOAuthCredentialsRepo.js"; +import { + parseOAuthScopeString, + parseOAuthTokenResponse, +} from "../utils/oauthTokenResponse.js"; +import { replaceInstallationOAuthCredentials } from "./replaceInstallationOAuthCredentials.js"; + +const TOKEN_EXPIRY_SKEW_MS = 60_000; + +const getTokenEndpoint = () => + new URL("/api/auth/oauth2/token", leafEnv.BETTER_AUTH_URL).href; + +const getDefaultExpiresAt = () => Date.now() + 60 * 60 * 1000; + +export const getInstallationOAuthAccessToken = async ({ + installation, + env, +}: { + installation: ChatInstallation; + env: AppEnv; +}) => { + let credential = await getChatOAuthCredentialByInstallationEnv({ + db, + chatInstallationId: installation.id, + env, + }); + + if ( + installation.provider.startsWith("slack_admin") && + (!credential || credential.org_id !== installation.org_id) + ) { + await db.transaction(async (tx) => { + await replaceInstallationOAuthCredentials({ + tx, + installation, + userId: installation.installed_by_user_id ?? "", + }); + }); + + credential = await getChatOAuthCredentialByInstallationEnv({ + db, + chatInstallationId: installation.id, + env, + }); + } + + if (!credential) { + throw new Error( + `Missing ${env} Autumn OAuth credentials for Slack install`, + ); + } + + if (credential.access_token_expires_at - TOKEN_EXPIRY_SKEW_MS > Date.now()) { + return decrypt(credential.access_token); + } + + const refreshToken = decrypt(credential.refresh_token); + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: credential.oauth_client_id, + }); + + const response = await fetch(getTokenEndpoint(), { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!response.ok) { + throw new Error( + `Could not refresh ${env} Autumn OAuth token for Slack install`, + ); + } + + const parsed = parseOAuthTokenResponse({ body: await response.json() }); + const accessTokenExpiresAt = parsed.expires_in + ? Date.now() + parsed.expires_in * 1000 + : getDefaultExpiresAt(); + const nextRefreshToken = parsed.refresh_token ?? refreshToken; + const scopes = parseOAuthScopeString({ scope: parsed.scope }); + + await updateChatOAuthCredentialTokens({ + db, + id: credential.id, + accessToken: encrypt(parsed.access_token), + refreshToken: encrypt(nextRefreshToken), + accessTokenExpiresAt, + scopes: scopes.length > 0 ? scopes : credential.scopes, + updatedAt: Date.now(), + }); + + return parsed.access_token; +}; diff --git a/apps/leaf/src/internal/installations/actions/replaceInstallationOAuthCredentials.ts b/apps/leaf/src/internal/installations/actions/replaceInstallationOAuthCredentials.ts new file mode 100644 index 000000000..49600fb03 --- /dev/null +++ b/apps/leaf/src/internal/installations/actions/replaceInstallationOAuthCredentials.ts @@ -0,0 +1,313 @@ +import crypto from "node:crypto"; +import { prefixOAuthToken } from "@autumn/auth"; +import { + AppEnv, + type ChatInstallation, + chatOAuthCredentials, + LEAF_OAUTH_SCOPES, + oauthAccessToken, + oauthClient, + oauthConsent, + oauthRefreshToken, +} from "@autumn/shared"; +import { and, eq, sql } from "drizzle-orm"; +import { encrypt } from "../../../lib/crypto.js"; +import type { db } from "../../../lib/db.js"; +import { + AUTUMN_ADMIN_OAUTH_CLIENT_ID, + AUTUMN_SLACK_OAUTH_CLIENT_ID, +} from "./upsertInstallationOAuthCredential.js"; + +type ChatTransaction = Parameters[0]>[0]; + +const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; +const REFRESH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1000; +const SLACK_ADMIN_CONSENT_KIND = "slack_admin"; + +type OAuthConsentMetadata = + | { + kind: typeof SLACK_ADMIN_CONSENT_KIND; + chatInstallationId: string; + createdByUserId: string; + } + | Record; + +const isSlackAdminInstallation = ({ + installation, +}: { + installation: ChatInstallation; +}) => installation.provider.startsWith("slack_admin"); + +const getSlackMcpOAuthClientId = ({ + installation, +}: { + installation: ChatInstallation; +}) => + isSlackAdminInstallation({ installation }) + ? AUTUMN_ADMIN_OAUTH_CLIENT_ID + : AUTUMN_SLACK_OAUTH_CLIENT_ID; + +const getSlackMcpOAuthClientName = ({ + installation, +}: { + installation: ChatInstallation; +}) => (isSlackAdminInstallation({ installation }) ? "Slack Admin" : "Slack"); + +const getOAuthClientMetadata = ({ + installation, +}: { + installation: ChatInstallation; +}) => ({ + kind: "mcp_client", + mcpClientType: isSlackAdminInstallation({ installation }) + ? "slack_admin" + : "slack", +}); + +const getOAuthConsentMetadata = ({ + installation, + userId, +}: { + installation: ChatInstallation; + userId: string; +}): OAuthConsentMetadata => + isSlackAdminInstallation({ installation }) + ? { + kind: SLACK_ADMIN_CONSENT_KIND, + chatInstallationId: installation.id, + createdByUserId: userId, + } + : {}; + +const tokenHash = ({ token }: { token: string }) => { + const hash = crypto.createHash("sha256").update(token).digest(); + return hash + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +}; + +const generateToken = () => crypto.randomBytes(48).toString("base64url"); + +const ensureSlackMcpOAuthClient = async ({ + tx, + installation, +}: { + tx: ChatTransaction; + installation: ChatInstallation; +}) => { + const now = new Date(); + const clientId = getSlackMcpOAuthClientId({ installation }); + const name = getSlackMcpOAuthClientName({ installation }); + const metadata = getOAuthClientMetadata({ installation }); + + await tx + .insert(oauthClient) + .values({ + id: `oauth_client_${crypto.randomUUID().replace(/-/g, "")}`, + clientId, + name, + redirectUris: ["slack://autumn-chat"], + scopes: [...LEAF_OAUTH_SCOPES], + tokenEndpointAuthMethod: "none", + grantTypes: ["authorization_code", "refresh_token"], + responseTypes: ["code"], + public: true, + type: "native", + metadata, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: oauthClient.clientId, + set: { + name, + scopes: [...LEAF_OAUTH_SCOPES], + tokenEndpointAuthMethod: "none", + grantTypes: ["authorization_code", "refresh_token"], + responseTypes: ["code"], + public: true, + type: "native", + metadata, + updatedAt: now, + }, + }); +}; + +const upsertOAuthConsent = async ({ + tx, + env, + orgId, + userId, + clientId, + metadata, +}: { + tx: ChatTransaction; + env: AppEnv; + orgId: string; + userId: string; + clientId: string; + metadata: OAuthConsentMetadata; +}) => { + const now = new Date(); + const [existingConsent] = await tx + .select({ id: oauthConsent.id }) + .from(oauthConsent) + .where( + and( + eq(oauthConsent.clientId, clientId), + eq(oauthConsent.userId, userId), + eq(oauthConsent.referenceId, orgId), + eq(oauthConsent.env, env), + metadata?.kind === SLACK_ADMIN_CONSENT_KIND + ? sql`${oauthConsent.metadata}->>'kind' = ${SLACK_ADMIN_CONSENT_KIND}` + : sql`COALESCE(${oauthConsent.metadata}->>'kind', '') != ${SLACK_ADMIN_CONSENT_KIND}`, + ), + ) + .limit(1); + + if (existingConsent) { + await tx + .update(oauthConsent) + .set({ + scopes: [...LEAF_OAUTH_SCOPES], + metadata, + updatedAt: now, + }) + .where(eq(oauthConsent.id, existingConsent.id)); + return existingConsent.id; + } + + const consentId = `oauth_consent_${crypto.randomUUID().replace(/-/g, "")}`; + await tx.insert(oauthConsent).values({ + id: consentId, + clientId, + userId, + referenceId: orgId, + scopes: [...LEAF_OAUTH_SCOPES], + env, + redirectUri: "slack://autumn-chat", + metadata, + createdAt: now, + updatedAt: now, + }); + + return consentId; +}; + +const createCredentialForEnv = async ({ + tx, + installation, + env, + userId, +}: { + tx: ChatTransaction; + installation: ChatInstallation; + env: AppEnv; + userId: string; +}) => { + const now = Date.now(); + const nowDate = new Date(now); + const rawAccessToken = generateToken(); + const rawRefreshToken = generateToken(); + const accessTokenExpiresAt = now + ACCESS_TOKEN_TTL_MS; + const refreshTokenExpiresAt = now + REFRESH_TOKEN_TTL_MS; + const refreshTokenId = `oauth_refresh_${crypto.randomUUID().replace(/-/g, "")}`; + const accessTokenId = `oauth_access_${crypto.randomUUID().replace(/-/g, "")}`; + const clientId = getSlackMcpOAuthClientId({ installation }); + const metadata = getOAuthConsentMetadata({ installation, userId }); + const consentId = await upsertOAuthConsent({ + tx, + env, + orgId: installation.org_id, + userId, + clientId, + metadata, + }); + + await tx.insert(oauthRefreshToken).values({ + id: refreshTokenId, + token: tokenHash({ token: rawRefreshToken }), + clientId, + userId, + referenceId: installation.org_id, + expiresAt: new Date(refreshTokenExpiresAt), + createdAt: nowDate, + authTime: nowDate, + scopes: [...LEAF_OAUTH_SCOPES], + }); + await tx.insert(oauthAccessToken).values({ + id: accessTokenId, + token: tokenHash({ token: rawAccessToken }), + clientId, + userId, + referenceId: installation.org_id, + refreshId: refreshTokenId, + expiresAt: new Date(accessTokenExpiresAt), + createdAt: nowDate, + scopes: [...LEAF_OAUTH_SCOPES], + }); + const credential = { + id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`, + chat_installation_id: installation.id, + org_id: installation.org_id, + env, + oauth_client_id: clientId, + oauth_consent_id: consentId, + access_token: encrypt(prefixOAuthToken({ token: rawAccessToken })), + refresh_token: encrypt(rawRefreshToken), + access_token_expires_at: accessTokenExpiresAt, + scopes: [...LEAF_OAUTH_SCOPES], + created_at: now, + updated_at: now, + }; + + await tx + .insert(chatOAuthCredentials) + .values(credential) + .onConflictDoUpdate({ + target: [ + chatOAuthCredentials.chat_installation_id, + chatOAuthCredentials.env, + ], + set: { + org_id: credential.org_id, + oauth_client_id: credential.oauth_client_id, + oauth_consent_id: credential.oauth_consent_id, + access_token: credential.access_token, + refresh_token: credential.refresh_token, + access_token_expires_at: credential.access_token_expires_at, + scopes: credential.scopes, + updated_at: credential.updated_at, + }, + }); +}; + +export const replaceInstallationOAuthCredentials = async ({ + tx, + installation, + userId, +}: { + tx: ChatTransaction; + installation: ChatInstallation; + userId: string; +}) => { + if (!userId) { + throw new Error("Missing user id for Slack MCP OAuth credentials"); + } + + await ensureSlackMcpOAuthClient({ tx, installation }); + await createCredentialForEnv({ + tx, + installation, + env: AppEnv.Sandbox, + userId, + }); + await createCredentialForEnv({ + tx, + installation, + env: AppEnv.Live, + userId, + }); +}; diff --git a/apps/leaf/src/internal/installations/actions/upsertInstallationOAuthCredential.ts b/apps/leaf/src/internal/installations/actions/upsertInstallationOAuthCredential.ts new file mode 100644 index 000000000..16c96d1c4 --- /dev/null +++ b/apps/leaf/src/internal/installations/actions/upsertInstallationOAuthCredential.ts @@ -0,0 +1,48 @@ +import crypto from "node:crypto"; +import type { AppEnv, ChatInstallation } from "@autumn/shared"; +import { encrypt } from "../../../lib/crypto.js"; +import { db } from "../../../lib/db.js"; +import { upsertChatOAuthCredential } from "../repos/chatOAuthCredentialsRepo.js"; + +export const AUTUMN_SLACK_OAUTH_CLIENT_ID = "autumn_mcp_slack"; +export const AUTUMN_ADMIN_OAUTH_CLIENT_ID = "autumn_admin"; + +export const upsertInstallationOAuthCredential = async ({ + installation, + env, + accessToken, + refreshToken, + accessTokenExpiresAt, + scopes, + oauthClientId = AUTUMN_SLACK_OAUTH_CLIENT_ID, + oauthConsentId, +}: { + installation: ChatInstallation; + env: AppEnv; + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: number; + scopes: string[]; + oauthClientId?: string; + oauthConsentId?: string | null; +}) => { + const now = Date.now(); + + return upsertChatOAuthCredential({ + db, + credential: { + id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`, + chat_installation_id: installation.id, + org_id: installation.org_id, + env, + oauth_client_id: oauthClientId, + oauth_consent_id: oauthConsentId ?? null, + access_token: encrypt(accessToken), + refresh_token: encrypt(refreshToken), + access_token_expires_at: accessTokenExpiresAt, + scopes, + created_at: now, + updated_at: now, + }, + }); +}; diff --git a/apps/leaf/src/internal/installations/repos/chatOAuthCredentialsRepo.ts b/apps/leaf/src/internal/installations/repos/chatOAuthCredentialsRepo.ts new file mode 100644 index 000000000..5ac48096b --- /dev/null +++ b/apps/leaf/src/internal/installations/repos/chatOAuthCredentialsRepo.ts @@ -0,0 +1,89 @@ +import { + type AppEnv, + type ChatOAuthCredential, + chatOAuthCredentials, +} from "@autumn/shared"; +import { and, eq } from "drizzle-orm"; +import type { ChatDb } from "../../../lib/db.js"; + +export type ChatOAuthCredentialInsert = + typeof chatOAuthCredentials.$inferInsert; + +export const getChatOAuthCredentialByInstallationEnv = async ({ + db, + chatInstallationId, + env, +}: { + db: ChatDb; + chatInstallationId: string; + env: AppEnv; +}) => + db.query.chatOAuthCredentials.findFirst({ + where: and( + eq(chatOAuthCredentials.chat_installation_id, chatInstallationId), + eq(chatOAuthCredentials.env, env), + ), + }); + +export const upsertChatOAuthCredential = async ({ + db, + credential, +}: { + db: ChatDb; + credential: ChatOAuthCredentialInsert; +}) => { + const [row] = await db + .insert(chatOAuthCredentials) + .values(credential) + .onConflictDoUpdate({ + target: [ + chatOAuthCredentials.chat_installation_id, + chatOAuthCredentials.env, + ], + set: { + org_id: credential.org_id, + oauth_client_id: credential.oauth_client_id, + oauth_consent_id: credential.oauth_consent_id, + access_token: credential.access_token, + refresh_token: credential.refresh_token, + access_token_expires_at: credential.access_token_expires_at, + scopes: credential.scopes, + updated_at: credential.updated_at, + }, + }) + .returning(); + + return row as ChatOAuthCredential; +}; + +export const updateChatOAuthCredentialTokens = async ({ + db, + id, + accessToken, + refreshToken, + accessTokenExpiresAt, + scopes, + updatedAt, +}: { + db: ChatDb; + id: string; + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: number; + scopes: string[]; + updatedAt: number; +}) => { + const [row] = await db + .update(chatOAuthCredentials) + .set({ + access_token: accessToken, + refresh_token: refreshToken, + access_token_expires_at: accessTokenExpiresAt, + scopes, + updated_at: updatedAt, + }) + .where(eq(chatOAuthCredentials.id, id)) + .returning(); + + return row as ChatOAuthCredential | undefined; +}; diff --git a/apps/leaf/src/internal/installations/utils/oauthTokenResponse.ts b/apps/leaf/src/internal/installations/utils/oauthTokenResponse.ts new file mode 100644 index 000000000..991371908 --- /dev/null +++ b/apps/leaf/src/internal/installations/utils/oauthTokenResponse.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +const oauthTokenPayloadSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + expires_in: z.number().optional(), + scope: z.string().optional(), +}); + +const oauthTokenResponseSchema = z.preprocess((value) => { + if (value && typeof value === "object" && "response" in value) { + return (value as { response?: unknown }).response; + } + + return value; +}, oauthTokenPayloadSchema); + +export const parseOAuthTokenResponse = ({ body }: { body: unknown }) => + oauthTokenResponseSchema.parse(body); + +export const parseOAuthScopeString = ({ scope }: { scope?: string }) => + scope?.split(/\s+/).filter(Boolean) ?? []; diff --git a/apps/leaf/src/internal/observability/leafTracingOptions.ts b/apps/leaf/src/internal/observability/leafTracingOptions.ts new file mode 100644 index 000000000..184014027 --- /dev/null +++ b/apps/leaf/src/internal/observability/leafTracingOptions.ts @@ -0,0 +1,49 @@ +import type { AppEnv } from "@autumn/shared"; +import type { TracingOptions } from "@mastra/core/observability"; + +const compact = (values: Array) => + values.filter((value): value is string => Boolean(value)); + +export const createLeafTracingOptions = ({ + agentRunId, + channelId, + env, + orgId, + orgSlug, + provider, + source, + threadId, + workspaceId, + setup, +}: { + agentRunId?: string; + channelId?: string; + env?: AppEnv | string; + orgId?: string; + orgSlug?: string | null; + provider?: string; + source: "eval" | "prod"; + threadId?: string; + workspaceId?: string; + setup?: string; +}): TracingOptions => ({ + metadata: { + agent_run_id: agentRunId, + autumn_env: env, + org_id: orgId, + org_slug: orgSlug, + provider, + setup, + slack_channel_id: channelId, + slack_thread_id: threadId, + slack_workspace_id: workspaceId, + source, + }, + tags: compact([ + source, + env ? `autumn:${env}` : undefined, + orgSlug ? `org:${orgSlug}` : undefined, + provider ? `provider:${provider}` : undefined, + setup ? `setup:${setup}` : undefined, + ]), +}); diff --git a/apps/leaf/src/lib/crypto.ts b/apps/leaf/src/lib/crypto.ts new file mode 100644 index 000000000..747828ba8 --- /dev/null +++ b/apps/leaf/src/lib/crypto.ts @@ -0,0 +1,37 @@ +import crypto from "node:crypto"; +import { env } from "./env.js"; + +const key = () => + crypto.createHash("sha256").update(env.ENCRYPTION_PASSWORD).digest(); + +export const encrypt = (data: string) => { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key(), iv); + const ciphertext = Buffer.concat([ + cipher.update(data, "utf8"), + cipher.final(), + ]); + return Buffer.concat([ + Buffer.from([1]), + iv, + cipher.getAuthTag(), + ciphertext, + ]).toString("base64"); +}; + +export const decrypt = (data: string) => { + const buffer = Buffer.from(data, "base64"); + if (buffer[0] !== 1) throw new Error("Unsupported encrypted payload"); + const iv = buffer.subarray(1, 13); + const authTag = buffer.subarray(13, 29); + const ciphertext = buffer.subarray(29); + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + key(), + iv, + ); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString( + "utf8", + ); +}; diff --git a/apps/leaf/src/lib/db.ts b/apps/leaf/src/lib/db.ts new file mode 100644 index 000000000..4bc3d582d --- /dev/null +++ b/apps/leaf/src/lib/db.ts @@ -0,0 +1,9 @@ +import * as schema from "@autumn/shared/db/schema"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { env } from "./env.js"; + +const client = postgres(env.DATABASE_URL, { max: 4 }); + +export const db = drizzle(client, { schema }); +export type ChatDb = typeof db; diff --git a/apps/leaf/src/lib/env.ts b/apps/leaf/src/lib/env.ts new file mode 100644 index 000000000..3de4c1257 --- /dev/null +++ b/apps/leaf/src/lib/env.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +const optionalString = z.preprocess( + (value) => (value === "" ? undefined : value), + z.string().min(1).optional(), +); + +const envSchema = z + .object({ + MCP_SERVER_URL: optionalString, + BETTER_AUTH_SECRET: optionalString, + BETTER_AUTH_URL: optionalString, + CHAT_MODEL: z.string().min(1).default("anthropic/claude-sonnet-4-6"), + CHAT_NAME: z.string().min(1).default("Autumn"), + CHAT_STATE_DATABASE_URL: optionalString, + CHAT_STATE_SECRET: optionalString, + CLIENT_URL: z.string().min(1).default("http://localhost:3000"), + DATABASE_URL: z.string().min(1), + E2B_API_KEY: optionalString, + ENCRYPTION_PASSWORD: z.string().min(1), + FIRECRAWL_API_KEY: z.string().min(1), + MCP_OAUTH_ENVIRONMENT: z.enum(["live", "sandbox"]).default("sandbox"), + PORT: z.coerce.number().int().positive().default(3099), + SLACK_CLIENT_ID: z.string().min(1), + SLACK_CLIENT_SECRET: z.string().min(1), + SLACK_REDIRECT_URI: optionalString, + SLACK_SIGNING_SECRET: z.string().min(1), + SLACK_STATE_SECRET: optionalString, + }) + .transform((values) => { + const databaseUrl = new URL(values.DATABASE_URL); + databaseUrl.pathname = "/chat"; + + return { + ...values, + MCP_SERVER_URL: + values.MCP_SERVER_URL ?? `http://localhost:${values.PORT}`, + BETTER_AUTH_URL: + values.BETTER_AUTH_URL ?? + (process.env.NODE_ENV === "production" + ? "https://api.useautumn.com" + : "http://localhost:8080"), + CHAT_STATE_DATABASE_URL: + values.CHAT_STATE_DATABASE_URL ?? databaseUrl.toString(), + CHAT_STATE_SECRET: + values.CHAT_STATE_SECRET ?? + values.SLACK_STATE_SECRET ?? + values.BETTER_AUTH_SECRET ?? + values.ENCRYPTION_PASSWORD, + }; + }); + +export const env = envSchema.parse(process.env); diff --git a/apps/leaf/src/lib/logger.ts b/apps/leaf/src/lib/logger.ts new file mode 100644 index 000000000..f831e6d16 --- /dev/null +++ b/apps/leaf/src/lib/logger.ts @@ -0,0 +1,60 @@ +import { + type AutumnLogger, + createAppLogger, + createSessionId, + createTraceId, +} from "@autumn/logging"; + +export const logger = createAppLogger({ + service: "leaf", + dataset: process.env.LEAF_LOG_DATASET ?? "leaf", + preset: "default", +}); + +export const createLeafSessionContext = ({ + channelId, + provider, + providerUserId, + threadId, + workspaceId, +}: { + channelId: string; + provider: string; + providerUserId: string; + threadId: string; + workspaceId: string; +}) => { + const traceId = createTraceId(); + const sessionId = createSessionId({ + parts: { + channelId, + provider, + threadId, + workspaceId, + }, + }); + return { + agentRunId: createTraceId(), + sessionId, + traceId, + context: { + provider, + provider_user_id: providerUserId, + session_id: sessionId, + trace_id: traceId, + slack_channel_id: channelId, + slack_thread_id: threadId, + slack_workspace_id: workspaceId, + }, + }; +}; + +export const addLeafContext = ( + baseLogger: AutumnLogger, + context: Record, +): AutumnLogger => + baseLogger.child({ + context: { + context, + }, + }); diff --git a/apps/leaf/src/main.ts b/apps/leaf/src/main.ts new file mode 100644 index 000000000..fb00cd9e2 --- /dev/null +++ b/apps/leaf/src/main.ts @@ -0,0 +1,49 @@ +import type { HttpBindings } from "@hono/node-server"; +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { chatAdapterNames } from "./bot.js"; +import { env } from "./lib/env.js"; +import { logger } from "./lib/logger.js"; +import { createMcpRouter } from "./mcp/mcpRouter.js"; +import { slackRoutes } from "./providers/slack/routes.js"; + +const app = new Hono<{ Bindings: HttpBindings }>(); + +app.use("*", async (c, next) => { + c.header("Access-Control-Allow-Origin", "*"); + c.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + c.header("Access-Control-Allow-Headers", "*"); + return c.req.method === "OPTIONS" ? c.body(null, 204) : next(); +}); + +app.get("/health", (c) => c.json({ ok: true })); + +app.route( + "", + createMcpRouter({ + "oauth-enabled": true, + "oauth-environment": env.MCP_OAUTH_ENVIRONMENT, + "server-url": env.BETTER_AUTH_URL, + logger, + resourceUrl: new URL("/mcp", env.MCP_SERVER_URL).href, + }), +); + +app.route("/slack", slackRoutes); + +serve( + { + fetch: app.fetch, + hostname: "0.0.0.0", + port: env.PORT, + }, + ({ address, port }) => { + logger.info("Chat listening", { + event: "leaf.server_started", + data: { + host: `${address}:${port}`, + adapters: chatAdapterNames, + }, + }); + }, +); diff --git a/apps/leaf/src/mcp/auth/protectedResourceMetadata.ts b/apps/leaf/src/mcp/auth/protectedResourceMetadata.ts new file mode 100644 index 000000000..0fa6e57e6 --- /dev/null +++ b/apps/leaf/src/mcp/auth/protectedResourceMetadata.ts @@ -0,0 +1,30 @@ +import { getOAuthIssuerUrl } from "@autumn/auth/oauth"; +import { DEFAULT_AUTUMN_API_URL } from "@autumn/mcp"; +import { LEAF_OAUTH_SCOPES } from "@autumn/shared"; + +export class OAuthHttpError extends Error { + constructor( + readonly status: number, + message: string, + readonly error = "invalid_token", + readonly wwwAuthenticate?: string, + ) { + super(message); + } +} + +export const getProtectedResourceMetadata = ({ + resourceUrl, + serverURL, +}: { + resourceUrl: string; + serverURL?: string; +}) => ({ + resource: resourceUrl, + authorization_servers: [ + getOAuthIssuerUrl({ baseUrl: serverURL ?? DEFAULT_AUTUMN_API_URL }), + ], + scopes_supported: [...LEAF_OAUTH_SCOPES], + bearer_methods_supported: ["header"], + resource_name: "Autumn MCP", +}); diff --git a/apps/leaf/src/mcp/auth/resolveRequestAuth.ts b/apps/leaf/src/mcp/auth/resolveRequestAuth.ts new file mode 100644 index 000000000..639ed028a --- /dev/null +++ b/apps/leaf/src/mcp/auth/resolveRequestAuth.ts @@ -0,0 +1,184 @@ +import { createHash } from "node:crypto"; +import { getBearerToken, isOAuthToken, isSecretKeyPrefix } from "@autumn/auth"; +import { + getProtectedResourceMetadataUrl, + getWwwAuthenticateHeader, +} from "@autumn/auth/oauth"; +import { + type AutumnMcpAuth, + DEFAULT_API_VERSION, + environmentSchema, + type MCPServerFlags, + type OAuthEnvironment, +} from "@autumn/mcp"; +import { LEAF_OAUTH_SCOPES } from "@autumn/shared"; +import * as z from "zod/v4"; +import { OAuthHttpError } from "./protectedResourceMetadata.js"; + +type AuthLogger = { + warning: (message: string, data?: Record) => void; +}; + +export interface MCPOAuthFlags extends MCPServerFlags { + readonly "oauth-enabled"?: boolean | undefined; + readonly "oauth-environment"?: OAuthEnvironment | undefined; +} + +const xApiVersionSchema = z.string().default(DEFAULT_API_VERSION); +const secretKeySchema = z.string().min(1).optional(); +const failOpenSchema = z + .union([ + z.boolean(), + z.enum(["true", "false"]).transform((v) => v === "true"), + ]) + .default(true); + +const parseRequestOption = ({ + value, + schema, + message, +}: { + value: unknown; + schema: z.ZodType; + message: string; +}): T => { + const parsed = schema.safeParse(value); + if (parsed.success) return parsed.data; + + throw new OAuthHttpError(400, message, "invalid_request"); +}; + +const getEnvironment = ({ + headers, + flags, +}: { + headers: Headers; + flags: MCPOAuthFlags; +}): OAuthEnvironment => + parseRequestOption({ + value: + headers.get("x-autumn-environment") ?? + flags["oauth-environment"] ?? + "sandbox", + schema: environmentSchema, + message: "Invalid x-autumn-environment", + }); + +const getStaticApiKey = ({ + headers, + flags, +}: { + headers: Headers; + flags: MCPOAuthFlags; +}): string | undefined => { + const secretKey = headers.get("secret-key"); + if (secretKey && isSecretKeyPrefix({ token: secretKey })) return secretKey; + + const bearer = getBearerToken({ headers }); + if (bearer && isSecretKeyPrefix({ token: bearer })) return bearer; + + const fallbackSecretKey = flags["secret-key"]; + if ( + !flags["oauth-enabled"] && + fallbackSecretKey && + isSecretKeyPrefix({ token: fallbackSecretKey }) + ) { + return fallbackSecretKey; + } + + return undefined; +}; + +const principalFromSecret = ({ + kind, + value, +}: { + kind: string; + value: string; +}) => { + const digest = createHash("sha256").update(value).digest("hex").slice(0, 32); + return `${kind}:${digest}`; +}; + +export const buildAuthForRequest = async ({ + headers, + flags, + logger, + resourceUrl, +}: { + headers: Headers; + flags: MCPOAuthFlags; + logger: AuthLogger; + resourceUrl: string; +}): Promise => { + const env = getEnvironment({ headers, flags }); + const xApiVersion = parseRequestOption({ + value: headers.get("x-api-version") ?? flags["x-api-version"], + schema: xApiVersionSchema, + message: "Invalid x-api-version", + }); + const failOpen = parseRequestOption({ + value: headers.get("fail-open") ?? flags["fail-open"], + schema: failOpenSchema, + message: "Invalid fail-open", + }); + const apiKey = parseRequestOption({ + value: getStaticApiKey({ headers, flags }), + schema: secretKeySchema, + message: "Invalid secret-key", + }); + + if (apiKey) { + return { + apiKey, + authMethod: "secret-key", + env, + resource: resourceUrl, + principalId: principalFromSecret({ kind: "secret-key", value: apiKey }), + scopes: [...LEAF_OAUTH_SCOPES], + serverURL: flags["server-url"], + xApiVersion, + failOpen, + }; + } + + const bearer = getBearerToken({ headers }); + if (bearer && isOAuthToken({ token: bearer })) { + return { + apiKey: bearer, + authMethod: "oauth", + env, + resource: resourceUrl, + principalId: "oauth:unverified", + scopes: [...LEAF_OAUTH_SCOPES], + serverURL: flags["server-url"], + xApiVersion, + failOpen, + }; + } + + if (bearer) { + throw new OAuthHttpError( + 401, + "Invalid OAuth token prefix", + "invalid_token", + ); + } + + if (flags["oauth-enabled"]) { + throw new OAuthHttpError( + 401, + "Missing Autumn API key bearer token", + "invalid_token", + getWwwAuthenticateHeader({ + resourceMetadataUrl: getProtectedResourceMetadataUrl({ + resourceUrl, + }), + error: "invalid_token", + }), + ); + } + + logger.warning("Missing secret-key for MCP request"); + throw new OAuthHttpError(401, "Missing secret-key", "invalid_token"); +}; diff --git a/apps/leaf/src/mcp/constants.ts b/apps/leaf/src/mcp/constants.ts new file mode 100644 index 000000000..1615e9bc7 --- /dev/null +++ b/apps/leaf/src/mcp/constants.ts @@ -0,0 +1,3 @@ +export const MCP_PATH = "/mcp" as const; +export const PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource/mcp"; diff --git a/apps/leaf/src/mcp/handlers/handleMcp.ts b/apps/leaf/src/mcp/handlers/handleMcp.ts new file mode 100644 index 000000000..6b9e4a3fe --- /dev/null +++ b/apps/leaf/src/mcp/handlers/handleMcp.ts @@ -0,0 +1,71 @@ +import { randomUUID } from "node:crypto"; +import { + type createAutumnOperationsMCPServer, +} from "@autumn/mcp"; +import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; +import { + buildAuthForRequest, +} from "../auth/resolveRequestAuth.js"; +import { OAuthHttpError } from "../auth/protectedResourceMetadata.js"; +import type { LeafMcpContext, McpRouteOptions } from "../types.js"; + +type McpServer = ReturnType; +type McpAuth = Awaited>; + +const setIncomingAuth = ({ + c, + auth, +}: { + c: LeafMcpContext; + auth: McpAuth; +}) => { + (c.env.incoming as typeof c.env.incoming & { auth?: McpAuth }).auth = auth; +}; + +const oauthErrorResponse = (c: LeafMcpContext, error: OAuthHttpError) => { + if (error.wwwAuthenticate) { + c.header("WWW-Authenticate", error.wwwAuthenticate); + } + + return c.json( + { error: error.error, error_description: error.message }, + { status: error.status as 400 | 401 | 403 }, + ); +}; + +export const createHandleMcp = + ({ + options, + path, + server, + }: { + options: McpRouteOptions; + path: string; + server: McpServer; + }) => + async (c: LeafMcpContext) => { + let auth: McpAuth; + try { + auth = await buildAuthForRequest({ + headers: c.req.raw.headers, + flags: options, + logger: options.logger, + resourceUrl: options.resourceUrl, + }); + } catch (error) { + if (error instanceof OAuthHttpError) { + return oauthErrorResponse(c, error); + } + throw error; + } + + setIncomingAuth({ c, auth }); + await server.startHTTP({ + url: new URL(c.req.url), + httpPath: path, + req: c.env.incoming, + res: c.env.outgoing, + options: { sessionIdGenerator: randomUUID }, + }); + return RESPONSE_ALREADY_SENT; + }; diff --git a/apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts b/apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts new file mode 100644 index 000000000..4bd455c2f --- /dev/null +++ b/apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts @@ -0,0 +1,12 @@ +import { getProtectedResourceMetadata } from "../auth/protectedResourceMetadata.js"; +import type { LeafMcpContext, McpRouteOptions } from "../types.js"; + +export const createHandleProtectedResourceMetadata = + ({ options }: { options: McpRouteOptions }) => + (c: LeafMcpContext) => + c.json( + getProtectedResourceMetadata({ + resourceUrl: options.resourceUrl, + serverURL: options["server-url"], + }), + ); diff --git a/apps/leaf/src/mcp/mcpRouter.ts b/apps/leaf/src/mcp/mcpRouter.ts new file mode 100644 index 000000000..c8c2f5888 --- /dev/null +++ b/apps/leaf/src/mcp/mcpRouter.ts @@ -0,0 +1,29 @@ +import { createAutumnOperationsMCPServer } from "@autumn/mcp"; +import type { HttpBindings } from "@hono/node-server"; +import { Hono } from "hono"; +import { MCP_PATH, PROTECTED_RESOURCE_METADATA_PATH } from "./constants.js"; +import { createHandleMcp } from "./handlers/handleMcp.js"; +import { createHandleProtectedResourceMetadata } from "./handlers/handleProtectedResourceMetadata.js"; +import type { McpRouteOptions } from "./types.js"; + +export const createMcpRouter = (options: McpRouteOptions) => { + const router = new Hono<{ Bindings: HttpBindings }>(); + const mcpServer = createAutumnOperationsMCPServer(); + + router.get( + PROTECTED_RESOURCE_METADATA_PATH, + createHandleProtectedResourceMetadata({ + options, + }), + ); + router.all( + MCP_PATH, + createHandleMcp({ + options, + path: MCP_PATH, + server: mcpServer, + }), + ); + + return router; +}; diff --git a/apps/leaf/src/mcp/types.ts b/apps/leaf/src/mcp/types.ts new file mode 100644 index 000000000..71b420b98 --- /dev/null +++ b/apps/leaf/src/mcp/types.ts @@ -0,0 +1,15 @@ +import type { AutumnLogger } from "@autumn/logging"; +import type { MCPServerFlags, OAuthEnvironment } from "@autumn/mcp"; +import type { HttpBindings } from "@hono/node-server"; +import type { Context, Hono } from "hono"; + +export interface McpRouteOptions extends MCPServerFlags { + readonly "oauth-enabled": boolean; + readonly "oauth-environment": OAuthEnvironment; + readonly logger: AutumnLogger; + readonly resourceUrl: string; +} + +export type LeafMcpContext = Context<{ Bindings: HttpBindings }>; +export type LeafMcpRouter = Hono<{ Bindings: HttpBindings }>; +export type { MCPOAuthFlags } from "./auth/resolveRequestAuth.js"; diff --git a/apps/leaf/src/providers/braintrust/config.ts b/apps/leaf/src/providers/braintrust/config.ts new file mode 100644 index 000000000..9f6e2a93a --- /dev/null +++ b/apps/leaf/src/providers/braintrust/config.ts @@ -0,0 +1,5 @@ +export const braintrustConfig = { + enabled: true, + projectName: process.env.LEAF_BRAINTRUST_PROJECT ?? "leaf", + serviceName: process.env.LEAF_BRAINTRUST_SERVICE ?? "leaf", +}; diff --git a/apps/leaf/src/providers/braintrust/createBraintrustLogger.ts b/apps/leaf/src/providers/braintrust/createBraintrustLogger.ts new file mode 100644 index 000000000..5e2398f10 --- /dev/null +++ b/apps/leaf/src/providers/braintrust/createBraintrustLogger.ts @@ -0,0 +1,15 @@ +import { initLogger, type Logger } from "braintrust"; +import { braintrustConfig } from "./config.js"; + +export const createBraintrustLogger = ({ + apiKey = process.env.BRAINTRUST_API_KEY, + enabled = braintrustConfig.enabled, + projectName = braintrustConfig.projectName, +}: { + apiKey?: string; + enabled?: boolean; + projectName?: string; +} = {}): Logger | undefined => { + if (!enabled || !apiKey) return undefined; + return initLogger({ apiKey, projectName }); +}; diff --git a/apps/leaf/src/providers/braintrust/createMastraBraintrustObservability.ts b/apps/leaf/src/providers/braintrust/createMastraBraintrustObservability.ts new file mode 100644 index 000000000..7a121cf21 --- /dev/null +++ b/apps/leaf/src/providers/braintrust/createMastraBraintrustObservability.ts @@ -0,0 +1,49 @@ +import { BraintrustExporter } from "@mastra/braintrust"; +import { SpanType } from "@mastra/core/observability"; +import { Observability, SamplingStrategyType } from "@mastra/observability"; +import { currentSpan } from "braintrust"; +import { braintrustConfig } from "./config.js"; +import { createBraintrustLogger } from "./createBraintrustLogger.js"; + +export const createMastraBraintrustObservability = ({ + apiKey = process.env.BRAINTRUST_API_KEY, + enabled = braintrustConfig.enabled, + projectName = braintrustConfig.projectName, + serviceName = braintrustConfig.serviceName, + braintrustLogger = createBraintrustLogger({ + apiKey, + enabled, + projectName, + }), +}: { + apiKey?: string; + braintrustLogger?: unknown; + enabled?: boolean; + projectName?: string; + serviceName?: string; +} = {}): Observability | undefined => { + if (!enabled) return undefined; + const exporterConfig = { + apiKey, + braintrustLogger, + currentSpan: () => currentSpan(), + projectName, + } as unknown as ConstructorParameters[0]; + + return new Observability({ + configs: { + braintrust: { + excludeSpanTypes: [SpanType.MODEL_CHUNK], + exporters: [new BraintrustExporter(exporterConfig)], + sampling: { type: SamplingStrategyType.ALWAYS }, + serializationOptions: { + maxArrayLength: 50, + maxDepth: 6, + maxObjectKeys: 80, + maxStringLength: 8_000, + }, + serviceName, + }, + }, + }); +}; diff --git a/apps/leaf/src/providers/braintrust/index.ts b/apps/leaf/src/providers/braintrust/index.ts new file mode 100644 index 000000000..5be05d1ed --- /dev/null +++ b/apps/leaf/src/providers/braintrust/index.ts @@ -0,0 +1,3 @@ +export { braintrustConfig } from "./config.js"; +export { createBraintrustLogger } from "./createBraintrustLogger.js"; +export { createMastraBraintrustObservability } from "./createMastraBraintrustObservability.js"; diff --git a/apps/leaf/src/providers/e2b/e2bSandboxFiles.ts b/apps/leaf/src/providers/e2b/e2bSandboxFiles.ts new file mode 100644 index 000000000..343d9465f --- /dev/null +++ b/apps/leaf/src/providers/e2b/e2bSandboxFiles.ts @@ -0,0 +1,46 @@ +import type { SandboxFile } from "../../agent/sandbox/types.js"; +import type { E2bSandbox } from "./e2bSandboxLifecycle.js"; + +export const e2bWorkDir = "/work"; + +export const ensureE2bWorkDir = async ({ + sandbox, +}: { + sandbox: E2bSandbox; +}) => { + try { + await sandbox.files.makeDir(e2bWorkDir); + } catch (error) { + if (!(error instanceof Error) || !/exist/i.test(error.message)) throw error; + } +}; + +export const writeE2bSandboxFiles = async ({ + files, + sandbox, +}: { + files: SandboxFile[]; + sandbox: E2bSandbox; +}) => { + for (const file of files) { + await sandbox.files.write(file.path, file.content); + } +}; + +export const readRequestedE2bFiles = async ({ + returnFiles, + sandbox, +}: { + returnFiles: string[]; + sandbox: E2bSandbox; +}): Promise => { + const files: SandboxFile[] = []; + for (const filePath of returnFiles) { + if (!(await sandbox.files.exists(filePath))) continue; + files.push({ + path: filePath, + content: await sandbox.files.read(filePath), + }); + } + return files; +}; diff --git a/apps/leaf/src/providers/e2b/e2bSandboxLifecycle.ts b/apps/leaf/src/providers/e2b/e2bSandboxLifecycle.ts new file mode 100644 index 000000000..f6b61316a --- /dev/null +++ b/apps/leaf/src/providers/e2b/e2bSandboxLifecycle.ts @@ -0,0 +1,78 @@ +import { Sandbox } from "e2b"; +import type { SandboxSessionContext } from "../../agent/sandbox/types.js"; +import { + e2bSandboxLookupMetadata, + e2bSandboxMetadata, +} from "./e2bSandboxMetadata.js"; + +export type E2bSandbox = Awaited>; + +export const findE2bSandbox = async ({ + apiKey, + context, +}: { + apiKey: string; + context: SandboxSessionContext; +}) => { + const paginator = Sandbox.list({ + apiKey, + query: { + metadata: e2bSandboxLookupMetadata({ context }), + state: ["running", "paused"], + }, + }); + const matches = await paginator.nextItems(); + return matches[0]; +}; + +export const createE2bSandbox = ({ + apiKey, + context, + timeoutMs, +}: { + apiKey: string; + context: SandboxSessionContext; + timeoutMs: number; +}) => + Sandbox.create({ + allowInternetAccess: false, + apiKey, + metadata: e2bSandboxMetadata({ context }), + network: { allowPublicTraffic: false }, + timeoutMs, + }); + +export const connectE2bSandbox = ({ + apiKey, + sandboxId, + timeoutMs, +}: { + apiKey: string; + sandboxId: string; + timeoutMs: number; +}) => + Sandbox.connect(sandboxId, { + apiKey, + timeoutMs, + }); + +export const findOrCreateE2bSandbox = async ({ + apiKey, + context, + timeoutMs, +}: { + apiKey: string; + context: SandboxSessionContext; + timeoutMs: number; +}) => { + const existing = await findE2bSandbox({ apiKey, context }); + if (existing) { + return connectE2bSandbox({ + apiKey, + sandboxId: existing.sandboxId, + timeoutMs, + }); + } + + return createE2bSandbox({ apiKey, context, timeoutMs }); +}; diff --git a/apps/leaf/src/providers/e2b/e2bSandboxMetadata.ts b/apps/leaf/src/providers/e2b/e2bSandboxMetadata.ts new file mode 100644 index 000000000..fe594cf9f --- /dev/null +++ b/apps/leaf/src/providers/e2b/e2bSandboxMetadata.ts @@ -0,0 +1,40 @@ +import type { SandboxSessionContext } from "../../agent/sandbox/types.js"; + +const metadataApp = "leaf"; + +export const e2bThreadKey = ({ context }: { context: SandboxSessionContext }) => + [ + context.orgId, + context.env, + context.provider, + context.workspaceId, + context.channelId, + context.threadId, + ].join(":"); + +export const e2bSandboxMetadata = ({ + context, +}: { + context: SandboxSessionContext; +}) => ({ + app: metadataApp, + channelId: context.channelId, + env: context.env, + orgId: context.orgId, + provider: context.provider, + threadId: context.threadId, + threadKey: e2bThreadKey({ context }), + workspaceId: context.workspaceId, +}); + +export const e2bSandboxLookupMetadata = ({ + context, +}: { + context: SandboxSessionContext; +}) => { + const metadata = e2bSandboxMetadata({ context }); + return { + app: metadata.app, + threadKey: metadata.threadKey, + }; +}; diff --git a/apps/leaf/src/providers/e2b/e2bSandboxProvider.ts b/apps/leaf/src/providers/e2b/e2bSandboxProvider.ts new file mode 100644 index 000000000..68bd75388 --- /dev/null +++ b/apps/leaf/src/providers/e2b/e2bSandboxProvider.ts @@ -0,0 +1,55 @@ +import type { + SandboxProvider, + SandboxRunResult, + SandboxSessionContext, +} from "../../agent/sandbox/types.js"; +import { + e2bWorkDir, + ensureE2bWorkDir, + readRequestedE2bFiles, + writeE2bSandboxFiles, +} from "./e2bSandboxFiles.js"; +import { findOrCreateE2bSandbox } from "./e2bSandboxLifecycle.js"; + +export const createE2bSandboxProvider = ({ + apiKey, + context, + sessionTimeoutMs, +}: { + apiKey: string; + context: SandboxSessionContext; + sessionTimeoutMs: number; +}): SandboxProvider => ({ + run: async ({ command, files, returnFiles, timeoutMs }) => { + const sandbox = await findOrCreateE2bSandbox({ + apiKey, + context, + timeoutMs: sessionTimeoutMs, + }); + try { + await ensureE2bWorkDir({ sandbox }); + await writeE2bSandboxFiles({ files, sandbox }); + const result = await sandbox.commands.run(command, { + cwd: e2bWorkDir, + timeoutMs, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: result.exitCode, + timedOut: false, + files: await readRequestedE2bFiles({ returnFiles, sandbox }), + } satisfies SandboxRunResult; + } catch (error) { + const timedOut = + error instanceof Error && /timeout|timed out/i.test(error.message); + if (!timedOut) throw error; + return { + stdout: "", + stderr: "Sandbox command timed out", + timedOut: true, + files: await readRequestedE2bFiles({ returnFiles, sandbox }), + }; + } + }, +}); diff --git a/apps/leaf/src/providers/slack/context.ts b/apps/leaf/src/providers/slack/context.ts new file mode 100644 index 000000000..6e41da2ff --- /dev/null +++ b/apps/leaf/src/providers/slack/context.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +const slackWorkspaceSchema = z.preprocess( + (value) => { + const payload = + value && typeof value === "object" + ? (value as Record) + : {}; + return { + workspaceId: + payload.team_id ?? + (payload.team as Record | undefined)?.id ?? + (typeof payload.team === "string" ? payload.team : undefined) ?? + (payload.user as Record | undefined)?.team_id, + }; + }, + z.strictObject({ workspaceId: z.string() }), +); + +export const getSlackWorkspaceId = (raw: unknown) => + slackWorkspaceSchema.parse(raw).workspaceId; diff --git a/apps/leaf/src/providers/slack/files.ts b/apps/leaf/src/providers/slack/files.ts new file mode 100644 index 000000000..b63bf1ca9 --- /dev/null +++ b/apps/leaf/src/providers/slack/files.ts @@ -0,0 +1,105 @@ +import type { Attachment } from "chat"; + +const SLACK_FILES_INFO_URL = "https://slack.com/api/files.info"; + +type SlackRawFile = { + id?: string; + mimetype?: string; + name?: string; + size?: number; + url_private?: string; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const parseSlackFile = (value: unknown): SlackRawFile | null => { + if (!isRecord(value)) return null; + return { + id: typeof value.id === "string" ? value.id : undefined, + mimetype: typeof value.mimetype === "string" ? value.mimetype : undefined, + name: typeof value.name === "string" ? value.name : undefined, + size: typeof value.size === "number" ? value.size : undefined, + url_private: + typeof value.url_private === "string" ? value.url_private : undefined, + }; +}; + +export const getSlackFilesFromRaw = ({ raw }: { raw: unknown }) => { + if (!isRecord(raw) || !Array.isArray(raw.files)) return []; + return raw.files.flatMap((file) => { + const parsed = parseSlackFile(file); + return parsed ? [parsed] : []; + }); +}; + +const findRawFileForAttachment = ({ + attachment, + files, +}: { + attachment: Attachment; + files: SlackRawFile[]; +}) => + files.find( + (file) => + file.name === attachment.name && + file.mimetype === attachment.mimeType && + file.size === attachment.size, + ) ?? files.find((file) => file.name === attachment.name); + +const fetchSlackPrivateUrl = async ({ + botToken, + url, +}: { + botToken: string; + url: string; +}) => { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${botToken}` }, + }); + if (!response.ok) { + throw new Error(`Slack file download failed: ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); +}; + +const fetchSlackFileInfoUrl = async ({ + botToken, + fileId, +}: { + botToken: string; + fileId: string; +}) => { + const url = new URL(SLACK_FILES_INFO_URL); + url.searchParams.set("file", fileId); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${botToken}` }, + }); + if (!response.ok) + throw new Error(`Slack files.info failed: ${response.status}`); + const data = await response.json(); + if (!isRecord(data) || data.ok !== true || !isRecord(data.file)) return null; + return typeof data.file.url_private === "string" + ? data.file.url_private + : null; +}; + +export const fetchSlackAttachmentFallback = async ({ + attachment, + botToken, + rawFiles, +}: { + attachment: Attachment; + botToken: string; + rawFiles: SlackRawFile[]; +}) => { + const rawFile = findRawFileForAttachment({ attachment, files: rawFiles }); + if (!rawFile) return null; + const url = + rawFile.url_private ?? + (rawFile.id + ? await fetchSlackFileInfoUrl({ botToken, fileId: rawFile.id }) + : null); + if (!url) return null; + return fetchSlackPrivateUrl({ botToken, url }); +}; diff --git a/apps/leaf/src/providers/slack/installations.ts b/apps/leaf/src/providers/slack/installations.ts new file mode 100644 index 000000000..cda550b31 --- /dev/null +++ b/apps/leaf/src/providers/slack/installations.ts @@ -0,0 +1,149 @@ +import crypto from "node:crypto"; +import { + AppEnv, + apiKeys, + type ChatInstallation, + type ChatInstallState, + type ChatProvider, + chatInstallations, + organizations, +} from "@autumn/shared"; +import { and, eq, or } from "drizzle-orm"; +import { replaceInstallationOAuthCredentials } from "../../internal/installations/actions/replaceInstallationOAuthCredentials.js"; +import { decrypt, encrypt } from "../../lib/crypto.js"; +import { db } from "../../lib/db.js"; +import { env } from "../../lib/env.js"; + +type ChatTransaction = Parameters[0]>[0]; + +export const getStateSecret = () => env.CHAT_STATE_SECRET; + +export const findInstallation = (provider: ChatProvider, workspaceId: string) => + db.query.chatInstallations.findFirst({ + where: and( + eq(chatInstallations.provider, provider), + eq(chatInstallations.workspace_id, workspaceId), + ), + }); + +export type ChatInstallationWithOrg = ChatInstallation & { + org_slug?: string; +}; + +export const findInstallationWithOrg = async ( + provider: ChatProvider, + workspaceId: string, +): Promise => { + const [row] = await db + .select({ + installation: chatInstallations, + orgSlug: organizations.slug, + }) + .from(chatInstallations) + .innerJoin(organizations, eq(organizations.id, chatInstallations.org_id)) + .where( + and( + eq(chatInstallations.provider, provider), + eq(chatInstallations.workspace_id, workspaceId), + ), + ) + .limit(1); + + return row + ? { + ...row.installation, + org_slug: row.orgSlug, + } + : undefined; +}; + +export const getInstallationKey = ( + installation: ChatInstallation, + env: AppEnv, +) => { + const key = + env === AppEnv.Live + ? installation.live_api_key + : installation.sandbox_api_key; + if (!key) throw new Error(`Missing ${env} API key`); + return decrypt(key); +}; + +const deleteInstallationApiKeys = async ( + tx: ChatTransaction, + installation: ChatInstallation, +) => { + for (const id of [ + installation.sandbox_api_key_id, + installation.live_api_key_id, + ]) { + if (!id) continue; + await tx + .delete(apiKeys) + .where(and(eq(apiKeys.id, id), eq(apiKeys.org_id, installation.org_id))); + } +}; + +export const replaceInstallation = async ({ + state, + provider, + workspaceId, + workspaceName, + botUserId, + botAccessToken, + scopes, + installedByProviderUserId, +}: { + state: ChatInstallState; + provider: ChatProvider; + workspaceId: string; + workspaceName: string; + botUserId?: string; + botAccessToken: string; + scopes: string[]; + installedByProviderUserId?: string; +}) => { + const sameOrg = and( + eq(chatInstallations.org_id, state.orgId), + eq(chatInstallations.provider, provider), + ); + const sameWorkspace = and( + eq(chatInstallations.provider, provider), + eq(chatInstallations.workspace_id, workspaceId), + ); + + await db.transaction(async (tx) => { + const existingInstallations = await tx.query.chatInstallations.findMany({ + where: or(sameOrg, sameWorkspace), + }); + for (const installation of existingInstallations) { + await deleteInstallationApiKeys(tx, installation); + } + + await tx.delete(chatInstallations).where(or(sameOrg, sameWorkspace)); + const [installation] = await tx + .insert(chatInstallations) + .values({ + id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`, + org_id: state.orgId, + provider, + workspace_id: workspaceId, + workspace_name: workspaceName, + bot_user_id: botUserId, + bot_access_token: encrypt(botAccessToken), + scopes, + default_env: state.env, + installed_by_user_id: state.userId, + installed_by_provider_user_id: installedByProviderUserId, + created_at: Date.now(), + updated_at: Date.now(), + }) + .returning(); + + await replaceInstallationOAuthCredentials({ + tx, + installation, + userId: state.userId, + }); + }); +}; diff --git a/apps/leaf/src/providers/slack/oauth.ts b/apps/leaf/src/providers/slack/oauth.ts new file mode 100644 index 000000000..eb5325167 --- /dev/null +++ b/apps/leaf/src/providers/slack/oauth.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { env } from "../../lib/env.js"; + +const SLACK_OAUTH_URL = "https://slack.com/api/oauth.v2.access"; + +const slackOAuthBaseSchema = z + .object({ + ok: z.boolean(), + error: z.string().optional(), + }) + .passthrough(); + +const slackOAuthSuccessSchema = z.object({ + access_token: z.string(), + scope: z.string().optional(), + bot_user_id: z.string().optional(), + team: z.object({ id: z.string(), name: z.string() }), + authed_user: z.object({ id: z.string() }).optional(), +}); + +export const exchangeSlackCode = async (code: string) => { + const body = new URLSearchParams({ + client_id: env.SLACK_CLIENT_ID, + client_secret: env.SLACK_CLIENT_SECRET, + code, + }); + if (env.SLACK_REDIRECT_URI) { + body.set("redirect_uri", env.SLACK_REDIRECT_URI); + } + const response = await fetch(SLACK_OAUTH_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const data = slackOAuthBaseSchema.parse(await response.json()); + if (!data.ok) + throw new Error(`Slack OAuth failed: ${data.error ?? "unknown"}`); + return slackOAuthSuccessSchema.parse(data); +}; + +export const slackSuccessUrl = () => + `${env.CLIENT_URL}/settings?tab=integrations&chat=connected`; + +export const slackErrorUrl = (error: string) => + `${env.CLIENT_URL}/settings?tab=integrations&chat_error=${encodeURIComponent(error)}`; diff --git a/apps/leaf/src/providers/slack/routes.ts b/apps/leaf/src/providers/slack/routes.ts new file mode 100644 index 000000000..ff6caee90 --- /dev/null +++ b/apps/leaf/src/providers/slack/routes.ts @@ -0,0 +1,79 @@ +import { type ChatProvider, verifyChatInstallState } from "@autumn/shared"; +import { Hono } from "hono"; +import { z } from "zod"; +import { bot } from "../../bot.js"; +import { logger } from "../../lib/logger.js"; +import { getStateSecret, replaceInstallation } from "./installations.js"; +import { exchangeSlackCode, slackErrorUrl, slackSuccessUrl } from "./oauth.js"; + +const callbackQuery = z.strictObject({ + code: z.string(), + state: z.string(), +}); + +const isSlackInstallProvider = (provider: string): provider is ChatProvider => + provider === "slack" || + provider === "slack_admin" || + provider.startsWith("slack_admin:"); + +export const slackRoutes = new Hono(); + +slackRoutes.get("/oauth/callback", async (c) => { + try { + const { code, state } = callbackQuery.parse({ + code: c.req.query("code"), + state: c.req.query("state"), + }); + + const parsedState = verifyChatInstallState(state, getStateSecret()); + if (!parsedState || !isSlackInstallProvider(parsedState.provider)) + throw new Error("Invalid or expired Slack OAuth state"); + + const oauth = await exchangeSlackCode(code); + await replaceInstallation({ + state: parsedState, + provider: parsedState.provider, + workspaceId: oauth.team.id, + workspaceName: oauth.team.name, + botUserId: oauth.bot_user_id, + botAccessToken: oauth.access_token, + scopes: String(oauth.scope ?? "") + .split(",") + .filter(Boolean), + installedByProviderUserId: oauth.authed_user?.id, + }); + logger.info("[chat:slack] Installed", { + event: "leaf.slack_installed", + context: { + org_id: parsedState.orgId, + slack_workspace_id: oauth.team.id, + }, + data: { + workspace_name: oauth.team.name, + }, + }); + + return c.redirect(slackSuccessUrl()); + } catch (error) { + logger.error("[chat:slack] OAuth callback failed", error, { + event: "leaf.slack_oauth_failed", + }); + return c.redirect(slackErrorUrl("Slack install failed")); + } +}); + +slackRoutes.post("/events", (c) => { + logger.debug("Received Slack events request", { + event: "leaf.slack_events_request_received", + }); + if (!bot.webhooks.slack) return c.text("Slack is not configured", 503); + return bot.webhooks.slack(c.req.raw); +}); + +slackRoutes.post("/interactions", (c) => { + logger.debug("Received Slack interactions request", { + event: "leaf.slack_interactions_request_received", + }); + if (!bot.webhooks.slack) return c.text("Slack is not configured", 503); + return bot.webhooks.slack(c.req.raw); +}); diff --git a/apps/leaf/src/providers/slack/threadContext.ts b/apps/leaf/src/providers/slack/threadContext.ts new file mode 100644 index 000000000..1099d3eba --- /dev/null +++ b/apps/leaf/src/providers/slack/threadContext.ts @@ -0,0 +1,30 @@ +import type { Message, Thread } from "chat"; +import type { ChatContextMessage } from "../../types.js"; + +export const getRecentMessages = async ( + thread: Thread, + currentMessage: Message, +): Promise => { + try { + await thread.refresh(); + } catch (error) { + console.warn("[chat] Could not refresh thread context", error); + } + + const seen = new Set(); + return [...thread.recentMessages, currentMessage] + .filter((message) => { + if (seen.has(message.id) || !message.text.trim()) return false; + seen.add(message.id); + return true; + }) + .slice(-8) + .map((message) => ({ + author: + message.author.fullName || + message.author.userName || + message.author.userId, + isBot: message.author.isBot, + text: message.text, + })); +}; diff --git a/apps/leaf/src/types.ts b/apps/leaf/src/types.ts new file mode 100644 index 000000000..c8be65f68 --- /dev/null +++ b/apps/leaf/src/types.ts @@ -0,0 +1,88 @@ +import type { AutumnLogger } from "@autumn/logging"; +import { AppEnv, type ChatInstallation } from "@autumn/shared"; +import type { Attachment } from "chat"; +import { z } from "zod"; + +export type LeafChatInstallation = ChatInstallation & { + org_slug?: string; +}; + +export const agentOutputSchema = z.preprocess( + (value) => { + const payload = + value && typeof value === "object" + ? (value as Record) + : {}; + const suspendPayload = payload.suspendPayload as + | Record + | undefined; + const previewApproval = payload.previewApproval as + | Record + | undefined; + return { + text: payload.text, + env: payload.env, + finishReason: payload.finishReason, + runId: payload.runId, + suspendPayload: suspendPayload && { + toolCallId: suspendPayload.toolCallId, + toolName: suspendPayload.toolName, + args: suspendPayload.args, + }, + previewApproval: previewApproval && { + toolName: previewApproval.toolName, + toolArgs: previewApproval.toolArgs, + preview: previewApproval.preview, + }, + }; + }, + z.strictObject({ + text: z.string().optional(), + env: z.nativeEnum(AppEnv), + finishReason: z.string().optional(), + runId: z.string().optional(), + suspendPayload: z + .strictObject({ + toolCallId: z.string().optional(), + toolName: z.string(), + args: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + previewApproval: z + .strictObject({ + toolName: z.string(), + toolArgs: z.record(z.string(), z.unknown()), + preview: z.unknown(), + }) + .optional(), + }), +); + +export type AgentOutput = z.infer; + +export type SignatureArgs = { + body: string; + timestamp?: string | null; + signature?: string | null; +}; + +export type BotMessage = { + agentRunId?: string; + attachmentFetchFallback?: (params: { + attachment: Attachment; + }) => Promise; + attachments?: Attachment[]; + installation: LeafChatInstallation; + logger?: AutumnLogger; + onAction?: (message: string) => Promise | void; + recentMessages?: ChatContextMessage[]; + text: string; + channelId: string; + threadId: string; +}; + +export type ChatContextMessage = { + author: string; + isBot: boolean | "unknown"; + text: string; +}; diff --git a/apps/leaf/src/ui/blocks.ts b/apps/leaf/src/ui/blocks.ts new file mode 100644 index 000000000..513ca5fa1 --- /dev/null +++ b/apps/leaf/src/ui/blocks.ts @@ -0,0 +1,254 @@ +import type { AppEnv } from "@autumn/shared"; +import { Actions, Button, Card, CardText, Divider, Field, Fields } from "chat"; +import { toolLabel } from "../agent/toolPolicy.js"; + +const formatPreview = (preview: unknown) => + typeof preview === "string" + ? preview + : ""; + +const getRequest = (args?: Record) => + (args?.request && typeof args.request === "object" + ? args.request + : args) as Record | undefined; + +const getFieldValue = (value: unknown) => + typeof value === "string" || typeof value === "number" + ? String(value) + : typeof value === "boolean" + ? value + ? "Yes" + : "No" + : null; + +const getRecord = (value: unknown) => + value && typeof value === "object" ? (value as Record) : {}; + +const formatPrice = (request: Record) => { + const customize = getRecord(request.customize); + const price = getRecord(customize.price); + const amount = getFieldValue(price.amount); + const interval = getFieldValue(price.interval); + return amount ? `$${amount}${interval ? `/${interval}` : ""}` : null; +}; + +const formatInvoiceMode = (value: unknown) => { + if (typeof value === "boolean") return value ? "enabled" : "disabled"; + const invoiceMode = getRecord(value); + if (!Object.keys(invoiceMode).length) return null; + + return [ + invoiceMode.enabled === true + ? "enabled" + : invoiceMode.enabled === false + ? "disabled" + : null, + invoiceMode.finalize === false + ? "draft invoice" + : invoiceMode.finalize === true + ? "finalize invoice" + : null, + invoiceMode.enable_plan_immediately === true + ? "enable immediately" + : invoiceMode.enable_plan_immediately === false + ? "access waits" + : null, + ] + .filter((part): part is string => Boolean(part)) + .join(", "); +}; + +const envLabel = (env?: AppEnv) => + env === "live" ? "Live" : env === "sandbox" ? "Sandbox" : null; + +const requestFields = ({ + env, + toolName, + toolArgs, +}: { + env?: AppEnv; + toolName: string; + toolArgs?: Record; +}) => { + const request = getRequest(toolArgs); + const environment = envLabel(env); + const baseFields = [ + Field({ + label: "Action", + value: toolLabel(toolName), + }), + ...(environment + ? [Field({ label: "Environment", value: environment })] + : []), + ]; + if (!request) return baseFields; + + return [ + ...baseFields, + ...[ + ["Customer", request.customer_id], + ["Plan", request.plan_id], + ["Entity", request.entity_id], + ["Subscription", request.subscription_id], + ["Price", formatPrice(request)], + ["Enable immediately", request.enable_plan_immediately], + ["Invoice mode", formatInvoiceMode(request.invoice_mode)], + ["Proration", request.proration_behavior], + ["Redirect", request.redirect_mode], + ].flatMap(([label, value]) => { + const fieldValue = getFieldValue(value); + return fieldValue ? [Field({ label: String(label), value: fieldValue })] : []; + }), + ].slice(0, 8); +}; + +const cleanPreviewLine = (line: string) => + line + .replace(/^[-*•]\s*/, "") + .replace(/\*\*/g, "") + .replace(/__+/g, "") + .trim(); + +const previewLines = (preview: unknown) => + formatPreview(preview) + .replace( + /\s*(Plan:|Customer:|Description|Amount|Total|Discounts?:|Payment will|No discounts|No existing)/g, + "\n$1", + ) + .split(/\n+/) + .map(cleanPreviewLine) + .filter(Boolean) + .filter( + (line) => + !/^(i('|’)ll|let me|here('|’)s|would you like|shall i|tool:|[\{\}\"])/i.test( + line, + ), + ) + .slice(0, 8); + +const resultLines = (result: unknown) => { + if (!result) return []; + if (typeof result === "string") return [result]; + if (typeof result !== "object") return [String(result)]; + + const body = result as Record; + const resultBody = getRecord(body.result); + const nested = resultBody.message || resultBody.status ? resultBody : getRecord(body.data); + const value = (key: string) => body[key] ?? nested[key]; + const message = value("message"); + const status = value("status"); + const id = value("id"); + const url = value("url"); + const checkoutUrl = value("checkout_url"); + + return [ + typeof message === "string" ? message : null, + typeof status === "string" ? `Status: ${status}` : null, + typeof id === "string" ? `ID: ${id}` : null, + typeof url === "string" ? `URL: ${url}` : null, + typeof checkoutUrl === "string" ? `Checkout URL: ${checkoutUrl}` : null, + ] + .filter((line): line is string => Boolean(line)) + .slice(0, 6); +}; + +const statusLines = ({ + status, + result, +}: { + status: "approved" | "cancelled" | "failed" | "running"; + result?: unknown; +}) => { + const lines = resultLines(result); + if (lines.length) return lines; + if (status === "running") return ["Applying the approved action now..."]; + if (status === "cancelled") return ["No changes were made."]; + return status === "failed" ? ["The action failed."] : []; +}; + +export const approvalCard = ({ + env, + id, + toolName, + toolArgs, + preview, +}: { + env?: AppEnv; + id: string; + toolName: string; + toolArgs?: Record; + preview?: unknown; +}) => + { + const fields = requestFields({ env, toolName, toolArgs }); + const lines = preview ? previewLines(preview) : []; + + return Card({ + title: `${toolLabel(toolName)}?`, + subtitle: "Review the preview before this runs", + children: [ + ...(fields.length ? [Fields(fields)] : []), + ...(lines.length + ? [Divider(), CardText(lines.map((line) => `• ${line}`).join("\n"))] + : []), + Actions([ + Button({ + id: "approve_billing_action", + label: "Approve", + style: "primary", + value: id, + }), + Button({ + id: "cancel_billing_action", + label: "Cancel", + style: "danger", + value: id, + }), + ]), + ], + }); + }; + +export const approvalStatusCard = ({ + env, + status, + toolName, + toolArgs, + preview, + result, +}: { + env?: AppEnv; + status: "approved" | "cancelled" | "failed" | "running"; + toolName: string; + toolArgs?: Record; + preview?: unknown; + result?: unknown; +}) => { + const fields = requestFields({ env, toolName, toolArgs }); + const lines = statusLines({ status, result }); + const title = + status === "approved" + ? `${toolLabel(toolName)} approved` + : status === "cancelled" + ? `${toolLabel(toolName)} cancelled` + : status === "running" + ? `Running ${toolLabel(toolName)}` + : `${toolLabel(toolName)} failed`; + + return Card({ + title, + subtitle: + status === "running" + ? "Applying the approved action" + : "The approval is closed", + children: [ + ...(fields.length ? [Fields(fields)] : []), + ...(lines.length + ? [ + Divider(), + CardText(lines.map((line) => `• ${cleanPreviewLine(line)}`).join("\n")), + ] + : []), + ], + }); +}; diff --git a/apps/leaf/src/ui/progress.ts b/apps/leaf/src/ui/progress.ts new file mode 100644 index 000000000..8625f4193 --- /dev/null +++ b/apps/leaf/src/ui/progress.ts @@ -0,0 +1,56 @@ +import type { Channel, Thread } from "chat"; +import { Plan } from "chat"; + +export type ReplyTarget = Thread | Channel; +export type LoadingState = Plan | null; + +export const startLoading = async (target: ReplyTarget) => { + try { + await target.startTyping("Starting Autumn..."); + const loading = new Plan({ initialMessage: "Starting Autumn..." }); + await target.post(loading); + return loading; + } catch (error) { + console.warn("[chat] Could not show loading state", error); + return null; + } +}; + +export const createActionLogger = (loading: LoadingState) => { + const seen = new Set(); + let first = true; + + return async (message: string) => { + if (!loading || seen.has(message)) return; + seen.add(message); + + try { + if (first) { + first = false; + await loading.reset({ initialMessage: message }); + return; + } + await loading.addTask({ title: message }); + } catch (error) { + console.warn("[chat] Could not update loading state", error); + } + }; +}; + +export const finishLoading = async ( + target: ReplyTarget, + loading: LoadingState, + message: string, +) => { + if (!loading) { + await target.post({ markdown: message }); + return; + } + + try { + await loading.complete({ completeMessage: message }); + } catch (error) { + console.warn("[chat] Could not complete loading state", error); + await target.post({ markdown: message }); + } +}; diff --git a/apps/leaf/tests/evals/README.md b/apps/leaf/tests/evals/README.md new file mode 100644 index 000000000..4aad0b115 --- /dev/null +++ b/apps/leaf/tests/evals/README.md @@ -0,0 +1,39 @@ +# Leaf Evals + +Braintrust evals for Leaf and Autumn MCP behavior. + +## Run + +```sh +bun -F @autumn/leaf eval:mcp +``` + +Eval files set `noSendLogs` when `BRAINTRUST_API_KEY` is absent, but agent +model calls still need the normal model provider environment. + +## Pattern + +- Build setup state with `fixtures/*` builders. +- Create runtime state with `context/createEvalContext`. +- Use a driver factory, usually `createGenericMcpAgentDriver`, to exercise the runtime. +- Assert behavior with deterministic scorers before adding LLM judges. +- Keep real customer/org names out of fixtures; use setup tags like + `invoice-mode-customer-missing-email`. +- Keep `trace.event(...)` terminal-only. Braintrust spans should come from the + Mastra observability exporter unless a test explicitly needs custom spans. + +## Context + +The eval context is intentionally split by responsibility: + +- `harness/context` owns the mock Autumn API and local MCP server. +- `harness/configs` owns defaults and reusable eval configuration objects. +- `harness/drivers` owns agent/client variants that talk to the MCP server. +- `harness/tracing` owns local terminal visibility for user turns, tool calls, + API calls, and approvals. + +Eval files should read like scenarios: choose a fixture setup, create a context, +run conversation turns, return scorer output. + +The old MCP evals under `packages/mcp/tests/evals` are intentionally left in +place while this structure is proven out. diff --git a/apps/leaf/tests/evals/agent/billing/attach/custom-base-price.eval.ts b/apps/leaf/tests/evals/agent/billing/attach/custom-base-price.eval.ts new file mode 100644 index 000000000..8aaae1b89 --- /dev/null +++ b/apps/leaf/tests/evals/agent/billing/attach/custom-base-price.eval.ts @@ -0,0 +1,95 @@ +import { + billing, + response, + tools, +} from "../../../fixtures/expectations/index.js"; +import { withCustomers } from "../../../fixtures/createSetup.js"; +import { orgSetups } from "../../../fixtures/orgSetups.js"; +import { approve, initEval, user } from "../../../harness/index.js"; +import { billingAttachScores } from "../../../utils/scorers.js"; + +type EvalMetadata = { + domain: "billing"; + flow: "attach"; +}; + +const experimentName = "attach-custom-price"; +const customPrice = 49; + +const setup = withCustomers({ + setup: orgSetups.knowledgePlatform(), + customers: ({ customers }) => ({ + account: customers.base({ + email: "billing@northstar.example", + id: "cus_attach_custom_price", + name: "Northstar Labs", + }), + }), +}); +const customer = setup.refs.customers.account; +const enterprisePlan = setup.refs.plans.enterprise; + +const expectedAttachRequest = { + customer_id: customer.id, + customize: { + price: { + amount: customPrice, + interval: "month", + }, + }, + enable_plan_immediately: true, + invoice_mode: { + enable_plan_immediately: true, + enabled: true, + finalize: false, + }, + plan_id: enterprisePlan.id, + redirect_mode: "if_required", +}; + +initEval({ + experimentName, + setup, + metadata: { + domain: "billing", + flow: "attach", + }, + scores: billingAttachScores(), + cases: [ + { + name: "custom monthly price with draft invoice", + conversation: [ + user({ + message: + "Please attach the Enterprise plan to Northstar Labs with a custom base price of $49/month.", + }), + user({ message: "Looks good, attach it." }), + approve(), + ], + expect: [ + tools.called({ + toolNames: ["listCustomers", "listPlans"], + }), + billing.previewBeforeWrite({ + preview: { + body: expectedAttachRequest, + toolName: "previewAttach", + }, + write: { + body: expectedAttachRequest, + toolName: "attach", + }, + }), + response.mentions({ + phrases: [ + "Northstar Labs", + "Enterprise", + "$49", + "invoice", + "immediately", + ], + }), + ], + }, + ], +}); diff --git a/apps/leaf/tests/evals/fixtures/createSetup.ts b/apps/leaf/tests/evals/fixtures/createSetup.ts new file mode 100644 index 000000000..f20657f3c --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/createSetup.ts @@ -0,0 +1,230 @@ +import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js"; +import type { ApiCustomerSchedule } from "@api/customers/components/apiCustomerSchedule"; +import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js"; +import type { ApiPlanV1 } from "@api/products/apiPlanV1.js"; +import { + balances as balanceFixtures, + customers as customerFixtures, + customerList as customerListFixture, + schedules as scheduleFixtures, + subscriptions as subscriptionFixtures, +} from "./customers/index.js"; +import { + basePrice as basePriceFixture, + features as featureFixtures, + featureList as featureListFixture, + items as itemFixtures, + itemList as itemListFixture, + plan as planFixture, + planList as planListFixture, +} from "./plans/index.js"; +import type { EvalSetup, EvalSetupIds, PlanRef, ScheduleRef } from "./types.js"; + +const flattenRecordValues = (record: Record) => + Object.values(record).flatMap((value) => + Array.isArray(value) ? value : [value], + ); + +const refIds = ( + record: Record, +) => + Object.fromEntries( + Object.entries(record).map(([key, value]) => [ + key, + Array.isArray(value) ? value.map((item) => item.id) : value.id, + ]), + ); + +const setupIds = < + Features extends Record, + Plans extends Record, + Customers extends Record, + Schedules extends Record, +>({ + customers, + features, + plans, + schedules, +}: { + customers: Customers; + features: Features; + plans: Plans; + schedules: Schedules; +}) => + ({ + customers: refIds(customers), + features: refIds(features), + plans: refIds(plans), + schedules: refIds(schedules), + }) as unknown as EvalSetupIds; + +/** + * Compose a mock Autumn org for evals from keyed feature, plan, and customer refs. + * The returned arrays feed the mock API; refs keep setup assertions readable. + */ +export const createSetup = < + Features extends Record, + Plans extends Record, + Customers extends Record, + Schedules extends Record = Record, +>({ + customers: createCustomers, + features: createFeatures, + plans: createPlans, + schedules: createSchedules, + tag, +}: { + tag: string; + features: ({ + featureList, + features, + }: { + featureList: typeof featureListFixture; + features: typeof featureFixtures; + }) => Features; + plans: ({ + basePrice, + features, + itemList, + items, + plan, + planList, + }: { + basePrice: typeof basePriceFixture; + features: Features; + itemList: typeof itemListFixture; + items: typeof itemFixtures; + plan: typeof planFixture; + planList: typeof planListFixture; + }) => Plans; + customers: ({ + balances, + customerList, + customers, + features, + plans, + subscriptions, + }: { + balances: typeof balanceFixtures; + customerList: typeof customerListFixture; + customers: typeof customerFixtures; + features: Features; + plans: Plans; + subscriptions: typeof subscriptionFixtures; + }) => Customers; + schedules?: ({ + customers, + plans, + schedules, + }: { + customers: Customers; + plans: Plans; + schedules: typeof scheduleFixtures; + }) => Schedules; +}): EvalSetup => { + const featureRefs = createFeatures({ + featureList: featureListFixture, + features: featureFixtures, + }); + const planRefs = createPlans({ + basePrice: basePriceFixture, + features: featureRefs, + itemList: itemListFixture, + items: itemFixtures, + plan: planFixture, + planList: planListFixture, + }); + const customerRefs = createCustomers({ + balances: balanceFixtures, + customerList: customerListFixture, + customers: customerFixtures, + features: featureRefs, + plans: planRefs, + subscriptions: subscriptionFixtures, + }); + const scheduleRefs = createSchedules?.({ + customers: customerRefs, + plans: planRefs, + schedules: scheduleFixtures, + }); + + return { + tag, + ids: setupIds({ + customers: customerRefs, + features: featureRefs, + plans: planRefs, + schedules: (scheduleRefs ?? {}) as Schedules, + }), + features: Object.values(featureRefs), + plans: flattenRecordValues(planRefs), + customers: flattenRecordValues(customerRefs), + schedules: flattenRecordValues( + (scheduleRefs ?? {}) as Schedules, + ), + refs: { + features: featureRefs, + plans: planRefs, + customers: customerRefs, + schedules: (scheduleRefs ?? {}) as Schedules, + }, + }; +}; + +/** Extend an org setup with eval-specific customers while preserving typed refs. */ +export const withCustomers = < + Setup extends EvalSetup, + Customers extends Record, +>({ + customers: createCustomers, + setup, +}: { + setup: Setup; + customers: ({ + balances, + customerList, + customers, + features, + plans, + subscriptions, + }: { + balances: typeof balanceFixtures; + customerList: typeof customerListFixture; + customers: typeof customerFixtures; + features: Setup["refs"]["features"]; + plans: Setup["refs"]["plans"]; + subscriptions: typeof subscriptionFixtures; + }) => Customers; +}): EvalSetup< + Setup["refs"]["features"], + Setup["refs"]["plans"], + Customers, + Setup["refs"]["schedules"] +> => { + const customerRefs = createCustomers({ + balances: balanceFixtures, + customerList: customerListFixture, + customers: customerFixtures, + features: setup.refs.features, + plans: setup.refs.plans, + subscriptions: subscriptionFixtures, + }); + + return { + ...setup, + ids: setupIds({ + customers: customerRefs, + features: setup.refs.features, + plans: setup.refs.plans, + schedules: setup.refs.schedules, + }), + customers: [ + ...setup.customers, + ...flattenRecordValues(customerRefs), + ], + refs: { + ...setup.refs, + customers: customerRefs, + }, + }; +}; diff --git a/apps/leaf/tests/evals/fixtures/customers/base/baseBalance.ts b/apps/leaf/tests/evals/fixtures/customers/base/baseBalance.ts new file mode 100644 index 000000000..0401760d1 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/base/baseBalance.ts @@ -0,0 +1,60 @@ +import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1.js"; +import { ResetInterval } from "@models/productModels/intervals/resetInterval.js"; + +const dateToEpochMs = (date: Date | null) => date?.getTime() ?? null; + +/** Base balance fixture with matching top-level values and one simple breakdown row. */ +export const baseBalance = ({ + featureId = "credits", + granted = 0, + remaining = granted, + reset = { interval: ResetInterval.Month }, + nextResetAt = null, + planId = null, + usage = granted - remaining, +}: { + featureId?: string; + granted?: number; + remaining?: number; + usage?: number; + nextResetAt?: Date | null; + reset?: { interval?: ResetInterval; intervalCount?: number } | null; + planId?: string | null; +} = {}): ApiBalanceV1 => { + const nextResetAtMs = dateToEpochMs(nextResetAt); + const resetValue = reset + ? { + interval: reset.interval ?? ResetInterval.Month, + interval_count: reset.intervalCount, + resets_at: nextResetAtMs, + } + : null; + + return { + object: "balance", + feature_id: featureId, + granted, + remaining, + usage, + unlimited: false, + overage_allowed: false, + max_purchase: null, + next_reset_at: nextResetAtMs, + breakdown: [ + { + object: "balance_breakdown", + id: `balance_${featureId}`, + plan_id: planId, + included_grant: granted, + prepaid_grant: 0, + remaining, + usage, + unlimited: false, + reset: resetValue, + price: null, + expires_at: null, + overage: 0, + }, + ], + }; +}; diff --git a/apps/leaf/tests/evals/fixtures/customers/base/baseCustomer.ts b/apps/leaf/tests/evals/fixtures/customers/base/baseCustomer.ts new file mode 100644 index 000000000..c64b348c1 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/base/baseCustomer.ts @@ -0,0 +1,39 @@ +import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js"; +import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1.js"; +import type { ApiSubscriptionV1 } from "@api/customers/cusPlans/apiSubscriptionV1.js"; +import { AppEnv } from "@models/genModels/genEnums.js"; + +const defaultCreatedAt = new Date("2026-01-01T00:00:00.000Z"); + +/** Base customer API fixture; prefer presets unless you need direct shape control. */ +export const baseCustomer = ({ + balances = {}, + createdAt = defaultCreatedAt, + email = "billing@example.com", + id = "customer_active", + name = "Active Customer", + subscriptions = [], +}: { + id?: string | null; + name?: string | null; + email?: string | null; + createdAt?: Date; + subscriptions?: ApiSubscriptionV1[]; + balances?: Record; +} = {}): BaseApiCustomerV5 => ({ + balances, + billing_controls: {}, + config: { disable_pooled_balance: false }, + created_at: createdAt.getTime(), + env: AppEnv.Sandbox, + id, + email, + fingerprint: null, + flags: {}, + metadata: {}, + name, + purchases: [], + send_email_receipts: false, + stripe_id: null, + subscriptions, +}); diff --git a/apps/leaf/tests/evals/fixtures/customers/base/baseSchedule.ts b/apps/leaf/tests/evals/fixtures/customers/base/baseSchedule.ts new file mode 100644 index 000000000..41a56faf9 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/base/baseSchedule.ts @@ -0,0 +1,35 @@ +import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js"; +import type { ApiCustomerSchedule } from "@api/customers/components/apiCustomerSchedule"; + +const defaultCreatedAt = new Date("2026-01-01T00:00:00.000Z"); + +export const baseSchedule = ({ + createdAt = defaultCreatedAt, + customer, + customerId = customer?.id ?? "customer", + entityId = null, + id = `sched_${customerId}`, + phases, +}: { + createdAt?: Date; + customer?: BaseApiCustomerV5; + customerId?: string; + entityId?: string | null; + id?: string; + phases: Array<{ + customerProductIds?: string[]; + id?: string; + startsAt: Date; + }>; +}): ApiCustomerSchedule => ({ + id, + customer_id: customerId, + entity_id: entityId, + created_at: createdAt.getTime(), + phases: phases.map((phase, index) => ({ + id: phase.id ?? `${id}_phase_${index + 1}`, + created_at: createdAt.getTime(), + customer_product_ids: phase.customerProductIds ?? [], + starts_at: phase.startsAt.getTime(), + })), +}); diff --git a/apps/leaf/tests/evals/fixtures/customers/base/baseSubscription.ts b/apps/leaf/tests/evals/fixtures/customers/base/baseSubscription.ts new file mode 100644 index 000000000..62ec606a5 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/base/baseSubscription.ts @@ -0,0 +1,39 @@ +import type { ApiSubscriptionV1 } from "@api/customers/cusPlans/apiSubscriptionV1.js"; +import type { ApiPlanV1 } from "@api/products/apiPlanV1.js"; + +const defaultStartedAt = new Date("2026-01-01T00:00:00.000Z"); +const dateToEpochMs = (date: Date | null) => date?.getTime() ?? null; + +/** Base subscription fixture; pass plan to include the expanded plan response. */ +export const baseSubscription = ({ + id, + plan, + planId = plan?.id ?? "pro", + status = "active", + startedAt = defaultStartedAt, + currentPeriodStart = startedAt, + currentPeriodEnd = null, +}: { + id?: string; + plan?: ApiPlanV1; + planId?: string; + status?: ApiSubscriptionV1["status"]; + startedAt?: Date; + currentPeriodStart?: Date | null; + currentPeriodEnd?: Date | null; +}): ApiSubscriptionV1 => ({ + id: id ?? `sub_${planId}`, + plan, + plan_id: planId, + auto_enable: false, + add_on: false, + status, + past_due: false, + canceled_at: null, + expires_at: null, + trial_ends_at: null, + started_at: startedAt.getTime(), + current_period_start: dateToEpochMs(currentPeriodStart), + current_period_end: dateToEpochMs(currentPeriodEnd), + quantity: 1, +}); diff --git a/apps/leaf/tests/evals/fixtures/customers/base/index.ts b/apps/leaf/tests/evals/fixtures/customers/base/index.ts new file mode 100644 index 000000000..fba67a5c4 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/base/index.ts @@ -0,0 +1,4 @@ +export { baseBalance } from "./baseBalance.js"; +export { baseCustomer } from "./baseCustomer.js"; +export { baseSchedule } from "./baseSchedule.js"; +export { baseSubscription } from "./baseSubscription.js"; diff --git a/apps/leaf/tests/evals/fixtures/customers/index.ts b/apps/leaf/tests/evals/fixtures/customers/index.ts new file mode 100644 index 000000000..00806ae6d --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/index.ts @@ -0,0 +1,13 @@ +export { + baseBalance, + baseCustomer, + baseSchedule, + baseSubscription, +} from "./base/index.js"; +export { + balances, + customerList, + customers, + schedules, + subscriptions, +} from "./presets/index.js"; diff --git a/apps/leaf/tests/evals/fixtures/customers/presets/balances.ts b/apps/leaf/tests/evals/fixtures/customers/presets/balances.ts new file mode 100644 index 000000000..c59783e10 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/presets/balances.ts @@ -0,0 +1,6 @@ +import { baseBalance } from "../base/baseBalance.js"; + +export const balances = { + empty: baseBalance, + metered: baseBalance, +} as const; diff --git a/apps/leaf/tests/evals/fixtures/customers/presets/customerList.ts b/apps/leaf/tests/evals/fixtures/customers/presets/customerList.ts new file mode 100644 index 000000000..12795e6d1 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/presets/customerList.ts @@ -0,0 +1,34 @@ +import type { ApiSubscriptionV1 } from "@api/customers/cusPlans/apiSubscriptionV1.js"; +import { baseCustomer } from "../base/baseCustomer.js"; + +/** Generate deterministic customers for broad list/search evals. */ +export const customerList = ({ + count, + emailDomain = "example.test", + idPrefix = "customer", + namePrefix = "Customer", + subscription, +}: { + count: number; + emailDomain?: string; + idPrefix?: string; + namePrefix?: string; + subscription?: ({ + index, + }: { + index: number; + }) => ApiSubscriptionV1 | undefined; +}) => + Array.from({ length: count }, (_, index) => { + const number = index + 1; + const padded = String(number).padStart(3, "0"); + const id = `${idPrefix}_${padded}`; + const maybeSubscription = subscription?.({ index }); + + return baseCustomer({ + id, + email: `${id}@${emailDomain}`, + name: `${namePrefix} ${number}`, + subscriptions: maybeSubscription ? [maybeSubscription] : [], + }); + }); diff --git a/apps/leaf/tests/evals/fixtures/customers/presets/customers.ts b/apps/leaf/tests/evals/fixtures/customers/presets/customers.ts new file mode 100644 index 000000000..f2f734502 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/presets/customers.ts @@ -0,0 +1,20 @@ +import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js"; +import type { ApiPlanV1 } from "@api/products/apiPlanV1.js"; +import { baseCustomer } from "../base/baseCustomer.js"; +import { subscriptions } from "./subscriptions.js"; + +type CustomerArgs = Parameters[0]; + +/** Customer presets for common eval scenarios; compose subscriptions explicitly. */ +export const customers = { + base: (args?: CustomerArgs): BaseApiCustomerV5 => baseCustomer(args), + active: (args?: CustomerArgs): BaseApiCustomerV5 => baseCustomer(args), + withPlan: ({ + plan, + ...args + }: CustomerArgs & { plan: ApiPlanV1 }): BaseApiCustomerV5 => + baseCustomer({ + ...args, + subscriptions: [subscriptions.active({ plan })], + }), +} as const; diff --git a/apps/leaf/tests/evals/fixtures/customers/presets/index.ts b/apps/leaf/tests/evals/fixtures/customers/presets/index.ts new file mode 100644 index 000000000..5b04e8e50 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/presets/index.ts @@ -0,0 +1,5 @@ +export { balances } from "./balances.js"; +export { customerList } from "./customerList.js"; +export { customers } from "./customers.js"; +export { schedules } from "./schedules.js"; +export { subscriptions } from "./subscriptions.js"; diff --git a/apps/leaf/tests/evals/fixtures/customers/presets/schedules.ts b/apps/leaf/tests/evals/fixtures/customers/presets/schedules.ts new file mode 100644 index 000000000..7cc3083b5 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/presets/schedules.ts @@ -0,0 +1,9 @@ +import { baseSchedule } from "../base/baseSchedule.js"; + +type ScheduleArgs = Parameters[0]; + +export const schedules = { + customer: (args: ScheduleArgs) => baseSchedule(args), + entity: (args: Omit & { entityId: string }) => + baseSchedule(args), +} as const; diff --git a/apps/leaf/tests/evals/fixtures/customers/presets/subscriptions.ts b/apps/leaf/tests/evals/fixtures/customers/presets/subscriptions.ts new file mode 100644 index 000000000..19d1161d6 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/customers/presets/subscriptions.ts @@ -0,0 +1,10 @@ +import { baseSubscription } from "../base/baseSubscription.js"; + +type SubscriptionArgs = Omit[0], "status">; + +export const subscriptions = { + active: (args: SubscriptionArgs) => + baseSubscription({ ...args, status: "active" }), + scheduled: (args: SubscriptionArgs) => + baseSubscription({ ...args, status: "scheduled" }), +} as const; diff --git a/apps/leaf/tests/evals/fixtures/expectations/api.ts b/apps/leaf/tests/evals/fixtures/expectations/api.ts new file mode 100644 index 000000000..03b4e7404 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/expectations/api.ts @@ -0,0 +1,34 @@ +import type { + ApiCalledExpectation, + ApiCalledInOrderExpectation, + ExpectedApiCall, +} from "./types.js"; + +export const api = { + call: ({ + body, + toolName, + }: { + body?: Record; + toolName: ExpectedApiCall["toolName"]; + }): ExpectedApiCall => ({ + ...(body ? { body } : {}), + toolName, + }), + called: ({ + calls, + }: { + calls: ExpectedApiCall[]; + }): ApiCalledExpectation => ({ + calls, + type: "api.called", + }), + calledInOrder: ({ + calls, + }: { + calls: ExpectedApiCall[]; + }): ApiCalledInOrderExpectation => ({ + calls, + type: "api.calledInOrder", + }), +}; diff --git a/apps/leaf/tests/evals/fixtures/expectations/billing.ts b/apps/leaf/tests/evals/fixtures/expectations/billing.ts new file mode 100644 index 000000000..74bf380f3 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/expectations/billing.ts @@ -0,0 +1,16 @@ +import { api } from "./api.js"; +import type { + ApiCalledInOrderExpectation, + ExpectedApiCall, +} from "./types.js"; + +export const billing = { + previewBeforeWrite: ({ + preview, + write, + }: { + preview: ExpectedApiCall; + write: ExpectedApiCall; + }): ApiCalledInOrderExpectation => + api.calledInOrder({ calls: [preview, write] }), +}; diff --git a/apps/leaf/tests/evals/fixtures/expectations/index.ts b/apps/leaf/tests/evals/fixtures/expectations/index.ts new file mode 100644 index 000000000..146061556 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/expectations/index.ts @@ -0,0 +1,14 @@ +export { api } from "./api.js"; +export { billing } from "./billing.js"; +export { response } from "./response.js"; +export { tools } from "./tools.js"; +export type { + ApiCalledExpectation, + ApiCalledInOrderExpectation, + EvalExpectation, + EvalExpected, + ExpectedApiCall, + LegacyEvalExpected, + ResponseMentionsExpectation, + ToolsCalledExpectation, +} from "./types.js"; diff --git a/apps/leaf/tests/evals/fixtures/expectations/response.ts b/apps/leaf/tests/evals/fixtures/expectations/response.ts new file mode 100644 index 000000000..d014be036 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/expectations/response.ts @@ -0,0 +1,12 @@ +import type { ResponseMentionsExpectation } from "./types.js"; + +export const response = { + mentions: ({ + phrases, + }: { + phrases: string[]; + }): ResponseMentionsExpectation => ({ + phrases, + type: "response.mentions", + }), +}; diff --git a/apps/leaf/tests/evals/fixtures/expectations/tools.ts b/apps/leaf/tests/evals/fixtures/expectations/tools.ts new file mode 100644 index 000000000..f6a554e5f --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/expectations/tools.ts @@ -0,0 +1,12 @@ +import type { ToolsCalledExpectation } from "./types.js"; + +export const tools = { + called: ({ + toolNames, + }: { + toolNames: ToolsCalledExpectation["toolNames"]; + }): ToolsCalledExpectation => ({ + toolNames, + type: "tools.called", + }), +}; diff --git a/apps/leaf/tests/evals/fixtures/expectations/types.ts b/apps/leaf/tests/evals/fixtures/expectations/types.ts new file mode 100644 index 000000000..ec208ab46 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/expectations/types.ts @@ -0,0 +1,42 @@ +import type { AutumnEvalToolName } from "../../harness/context/types.js"; + +export type ExpectedApiCall = { + body?: Record; + toolName: AutumnEvalToolName; +}; + +export type LegacyEvalExpected = { + apiCalls?: ExpectedApiCall[]; + finalTextIncludes?: string[]; + toolCalls?: AutumnEvalToolName[]; +}; + +export type ToolsCalledExpectation = { + toolNames: AutumnEvalToolName[]; + type: "tools.called"; +}; + +export type ApiCalledExpectation = { + calls: ExpectedApiCall[]; + type: "api.called"; +}; + +export type ApiCalledInOrderExpectation = { + calls: ExpectedApiCall[]; + type: "api.calledInOrder"; +}; + +export type ResponseMentionsExpectation = { + phrases: string[]; + type: "response.mentions"; +}; + +export type EvalExpectation = + | ApiCalledExpectation + | ApiCalledInOrderExpectation + | ResponseMentionsExpectation + | ToolsCalledExpectation; + +export type EvalExpected = + | LegacyEvalExpected + | (EvalExpectation[] & LegacyEvalExpected); diff --git a/apps/leaf/tests/evals/fixtures/orgSetups.ts b/apps/leaf/tests/evals/fixtures/orgSetups.ts new file mode 100644 index 000000000..7507c64ad --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/orgSetups.ts @@ -0,0 +1,7 @@ +import { knowledgePlatformSetup } from "./setups/knowledgePlatformSetup.js"; + +export type { EvalSetup as EvalOrgSetup } from "./types.js"; + +export const orgSetups = { + knowledgePlatform: knowledgePlatformSetup, +} as const; diff --git a/apps/leaf/tests/evals/fixtures/plans/featureList.ts b/apps/leaf/tests/evals/fixtures/plans/featureList.ts new file mode 100644 index 000000000..d82c9796e --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/plans/featureList.ts @@ -0,0 +1,19 @@ +import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js"; +import { features } from "./features.js"; + +/** Build keyed feature records for org-like setups with many boolean flags. */ +export const featureList = { + boolean: ({ + featureIds, + names = {}, + }: { + featureIds: readonly FeatureId[]; + names?: Partial>; + }): Record => + Object.fromEntries( + featureIds.map((featureId) => [ + featureId, + features.boolean({ featureId, name: names[featureId] }), + ]), + ) as Record, +} as const; diff --git a/apps/leaf/tests/evals/fixtures/plans/features.ts b/apps/leaf/tests/evals/fixtures/plans/features.ts new file mode 100644 index 000000000..987ccf48b --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/plans/features.ts @@ -0,0 +1,94 @@ +import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js"; +import { FeatureType } from "@models/featureModels/featureEnums.js"; + +const feature = ({ + archived = false, + consumable, + eventNames, + featureId, + name, + type, +}: { + archived?: boolean; + consumable: boolean; + eventNames?: string[]; + featureId: string; + name: string; + type: ApiFeatureV1["type"]; +}): ApiFeatureV1 => ({ + archived, + consumable, + event_names: eventNames, + id: featureId, + name, + type, +}); + +const nameFromId = (featureId: string) => + featureId + .split(/[-_]/) + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(" "); + +/** Feature fixtures for plan scenarios; pass feature objects into item fixtures. */ +export const features = { + allocated: ({ + featureId = "seats", + name = featureId === "seats" ? "Seats" : nameFromId(featureId), + }: { + featureId?: string; + name?: string; + } = {}): ApiFeatureV1 => + feature({ + consumable: false, + featureId, + name, + type: FeatureType.Metered, + }), + boolean: ({ + featureId = "admin_dashboard", + name = featureId === "admin_dashboard" + ? "Admin Dashboard" + : nameFromId(featureId), + }: { + featureId?: string; + name?: string; + } = {}): ApiFeatureV1 => + feature({ + consumable: false, + featureId, + name, + type: FeatureType.Boolean, + }), + consumable: ({ + featureId = "api_calls", + name = featureId === "api_calls" ? "API Calls" : nameFromId(featureId), + }: { + featureId?: string; + name?: string; + } = {}): ApiFeatureV1 => + feature({ + consumable: true, + eventNames: [featureId], + featureId, + name, + type: FeatureType.Metered, + }), + creditSystem: ({ + featureId = "credits", + meteredFeatureId = "api_calls", + name = featureId === "credits" ? "Credits" : nameFromId(featureId), + }: { + featureId?: string; + meteredFeatureId?: string; + name?: string; + } = {}): ApiFeatureV1 => ({ + ...feature({ + consumable: true, + featureId, + name, + type: FeatureType.CreditSystem, + }), + credit_schema: [{ metered_feature_id: meteredFeatureId, credit_cost: 1 }], + }), +} as const; diff --git a/apps/leaf/tests/evals/fixtures/plans/index.ts b/apps/leaf/tests/evals/fixtures/plans/index.ts new file mode 100644 index 000000000..0ca21d387 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/plans/index.ts @@ -0,0 +1,6 @@ +export { featureList } from "./featureList.js"; +export { features } from "./features.js"; +export { itemList } from "./itemList.js"; +export { items } from "./items.js"; +export { planList } from "./planList.js"; +export { basePrice, plan } from "./plans.js"; diff --git a/apps/leaf/tests/evals/fixtures/plans/itemList.ts b/apps/leaf/tests/evals/fixtures/plans/itemList.ts new file mode 100644 index 000000000..3ad0af702 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/plans/itemList.ts @@ -0,0 +1,17 @@ +import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js"; +import type { ApiPlanItemV1 } from "@api/products/items/apiPlanItemV1.js"; +import { items } from "./items.js"; + +/** Build repeated plan item lists from keyed feature refs. */ +export const itemList = { + boolean: ({ + featureIds, + features, + }: { + featureIds: readonly FeatureId[]; + features: Record; + }): ApiPlanItemV1[] => + featureIds.map((featureId) => + items.boolean({ feature: features[featureId] }), + ), +} as const; diff --git a/apps/leaf/tests/evals/fixtures/plans/items.ts b/apps/leaf/tests/evals/fixtures/plans/items.ts new file mode 100644 index 000000000..8476453aa --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/plans/items.ts @@ -0,0 +1,176 @@ +import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js"; +import { BillingMethod } from "@api/products/components/billingMethod.js"; +import type { ApiPlanItemV1 } from "@api/products/items/apiPlanItemV1.js"; +import { FeatureType } from "@models/featureModels/featureEnums.js"; +import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType.js"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; +import { ResetInterval } from "@models/productModels/intervals/resetInterval.js"; +import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js"; + +type PlanItemPrice = NonNullable; +type Rollover = NonNullable; +type UsageTier = NonNullable[number]; + +const resetInterval = { + month: ResetInterval.Month, + year: ResetInterval.Year, +} as const; + +const billingInterval = { + month: BillingInterval.Month, + year: BillingInterval.Year, +} as const; + +const defaultCreditTiers: UsageTier[] = [ + { to: 10_000, amount: 0, flat_amount: 100 }, + { to: 50_000, amount: 0, flat_amount: 400 }, + { to: 100_000, amount: 0, flat_amount: 750 }, + { to: "inf", amount: 0, flat_amount: 1_000 }, +]; + +const defaultRollover: Rollover = { + expiry_duration_length: 1, + expiry_duration_type: RolloverExpiryDurationType.Month, + max: null, + max_percentage: 50, +}; + +const assertFeatureType = ({ + feature, + expected, + item, +}: { + feature: ApiFeatureV1; + expected: ApiFeatureV1["type"] | ApiFeatureV1["type"][]; + item: string; +}) => { + const expectedTypes = Array.isArray(expected) ? expected : [expected]; + if (expectedTypes.includes(feature.type)) return; + + throw new Error( + `${item} item requires ${expectedTypes.join(" or ")} feature, got ${feature.type} (${feature.id}).`, + ); +}; + +/** Plan item fixtures validate feature compatibility to avoid impossible setups. */ +export const items = { + boolean: ({ feature }: { feature: ApiFeatureV1 }): ApiPlanItemV1 => { + assertFeatureType({ + expected: FeatureType.Boolean, + feature, + item: "boolean", + }); + + return { + display: { + primary_text: feature.name, + }, + feature_id: feature.id, + included: 1, + price: null, + reset: null, + unlimited: true, + }; + }, + included: ({ + feature, + included = 0, + interval = feature.consumable ? "month" : null, + }: { + feature: ApiFeatureV1; + included?: number; + interval?: "month" | "year" | null; + }): ApiPlanItemV1 => { + assertFeatureType({ + expected: [FeatureType.Metered, FeatureType.CreditSystem], + feature, + item: "included", + }); + + return { + display: { + primary_text: `${included.toLocaleString()} ${feature.name}`, + secondary_text: interval ? `resets every ${interval}` : undefined, + }, + feature_id: feature.id, + included, + price: null, + reset: interval ? { interval: resetInterval[interval] } : null, + unlimited: false, + }; + }, + prepaidCredits: ({ + feature, + included = 5_000, + interval = "month", + rollover = defaultRollover, + tiers = defaultCreditTiers, + }: { + feature: ApiFeatureV1; + included?: number; + interval?: "month" | "year"; + rollover?: Rollover; + tiers?: UsageTier[]; + }): ApiPlanItemV1 => { + assertFeatureType({ + expected: FeatureType.CreditSystem, + feature, + item: "prepaidCredits", + }); + + return { + display: { + primary_text: `${included.toLocaleString()} ${feature.name}`, + secondary_text: "then prepaid volume tiers", + }, + feature_id: feature.id, + included, + price: { + billing_method: BillingMethod.Prepaid, + billing_units: 1, + interval: billingInterval[interval], + max_purchase: null, + tier_behavior: TierBehavior.VolumeBased, + tiers, + }, + reset: { interval: resetInterval[interval] }, + rollover, + unlimited: false, + }; + }, + consumableCredits: ({ + amount = 0.01, + feature, + interval = "month", + rollover = defaultRollover, + }: { + amount?: number; + feature: ApiFeatureV1; + interval?: "month" | "year"; + rollover?: Rollover; + }): ApiPlanItemV1 => { + assertFeatureType({ + expected: FeatureType.CreditSystem, + feature, + item: "consumableCredits", + }); + + return { + display: { + primary_text: `$${amount} per ${feature.name}`, + }, + feature_id: feature.id, + included: 0, + price: { + amount, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + interval: billingInterval[interval], + max_purchase: null, + }, + reset: { interval: resetInterval[interval] }, + rollover, + unlimited: false, + }; + }, +} as const; diff --git a/apps/leaf/tests/evals/fixtures/plans/planList.ts b/apps/leaf/tests/evals/fixtures/plans/planList.ts new file mode 100644 index 000000000..fbfab3595 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/plans/planList.ts @@ -0,0 +1,57 @@ +import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js"; +import type { ApiPlanV1 } from "@api/products/apiPlanV1.js"; +import { items } from "./items.js"; +import { basePrice, plan } from "./plans.js"; + +type AddOnConfig = { + amount?: number | null; + feature: ApiFeatureV1; + interval?: "month" | "year"; + key: string; + name?: string; + planId: string; +}; + +const addOnPrice = ({ + amount, + interval, +}: { + amount?: number | null; + interval: "month" | "year"; +}) => { + if (amount == null) return null; + return interval === "month" + ? basePrice.monthly({ amount }) + : basePrice.annual({ amount }); +}; + +/** Build keyed one-feature add-on plans for org setups with many add-ons. */ +export const planList = { + addOns: ({ + addOns, + defaultInterval = "month", + }: { + addOns: readonly AddOn[]; + defaultInterval?: "month" | "year"; + }): { [Item in AddOn as Item["key"]]: ApiPlanV1 } => + Object.fromEntries( + addOns.map( + ({ + amount = null, + feature, + interval = defaultInterval, + key, + name, + planId, + }) => [ + key, + plan.addOn({ + basePrice: addOnPrice({ amount, interval }), + items: [items.boolean({ feature })], + name, + planId, + }), + ], + ), + ) as { [Item in AddOn as Item["key"]]: ApiPlanV1 }, +} as const; diff --git a/apps/leaf/tests/evals/fixtures/plans/plans.ts b/apps/leaf/tests/evals/fixtures/plans/plans.ts new file mode 100644 index 000000000..4afa984b9 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/plans/plans.ts @@ -0,0 +1,219 @@ +import type { CustomizePlanV1 } from "@api/billing/common/customizePlan/customizePlanV1"; +import type { ApiPlanV1 } from "@api/products/apiPlanV1.js"; +import type { ApiPlanItemV1 } from "@api/products/items/apiPlanItemV1.js"; +import type { PlanItemFilter } from "@api/products/items/filter/planItemFilter"; +import { AppEnv } from "@models/genModels/genEnums.js"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; + +type PlanPrice = NonNullable; +type EvalCustomizePlan = Omit< + CustomizePlanV1, + "add_items" | "items" | "price" +> & { + add_items?: ApiPlanV1["items"]; + items?: ApiPlanV1["items"]; + price?: CustomizePlanV1["price"] | PlanPrice | null; +}; + +const dollarsToCents = (amount: number) => amount * 100; +const displayAmount = (amount: number) => `$${amount}`; + +const planNameFromId = (planId: string) => + planId + .split(/[-_]/) + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(" "); + +const itemMatchesFilter = ({ + filter, + item, +}: { + filter: PlanItemFilter; + item: ApiPlanItemV1; +}) => { + if (filter.feature_id !== undefined && item.feature_id !== filter.feature_id) + return false; + if ( + filter.billing_method !== undefined && + item.price?.billing_method !== filter.billing_method + ) + return false; + if (filter.interval !== undefined) { + const itemInterval = item.price?.interval ?? item.reset?.interval; + if (String(itemInterval) !== String(filter.interval)) return false; + } + return true; +}; + +const assertNoDuplicateItems = (items: ApiPlanV1["items"]) => { + const featureIds = items.map((item) => item.feature_id); + const duplicateFeatureId = featureIds.find( + (featureId, index) => featureIds.indexOf(featureId) !== index, + ); + if (!duplicateFeatureId) return; + + throw new Error( + `Customized plan has duplicate item for feature ${duplicateFeatureId}; remove or update the original item first.`, + ); +}; + +const applyCustomizeItems = ({ + customize, + items, +}: { + customize: EvalCustomizePlan; + items: ApiPlanV1["items"]; +}) => { + if ( + customize.items !== undefined && + (customize.add_items !== undefined || + customize.remove_items !== undefined || + customize.update_items !== undefined) + ) { + throw new Error( + "customize.items cannot be combined with add_items, remove_items, or update_items.", + ); + } + + const nextItems = + customize.items ?? + [ + ...items + .filter( + (item) => + !(customize.remove_items ?? []).some((filter) => + itemMatchesFilter({ filter, item }), + ), + ) + .map((item) => { + const update = (customize.update_items ?? []).find((update) => + itemMatchesFilter({ filter: update.filter, item }), + ); + return update?.included !== undefined + ? { ...item, included: update.included } + : item; + }), + ...(customize.add_items ?? []), + ]; + assertNoDuplicateItems(nextItems); + return nextItems; +}; + +/** Base price amounts are in dollars; returned API price.amount is cents. */ +export const basePrice = { + annual: ({ amount = 200 } = {}): PlanPrice => ({ + amount: dollarsToCents(amount), + display: { + primary_text: displayAmount(amount), + secondary_text: "per year", + }, + interval: BillingInterval.Year, + }), + monthly: ({ amount = 20 } = {}): PlanPrice => ({ + amount: dollarsToCents(amount), + display: { + primary_text: displayAmount(amount), + secondary_text: "per month", + }, + interval: BillingInterval.Month, + }), +}; + +const createPlan = ({ + addOn = false, + basePrice, + items = [], + name, + planId, + version = 1, +}: { + addOn?: boolean; + basePrice: PlanPrice | null; + items?: ApiPlanV1["items"]; + name?: string; + planId: string; + version?: number; +}): ApiPlanV1 => ({ + add_on: addOn, + archived: false, + auto_enable: false, + base_variant_id: null, + config: { ignore_past_due: false }, + created_at: 1_767_225_600_000, + description: null, + env: AppEnv.Sandbox, + group: null, + id: planId, + items, + name: name ?? planNameFromId(planId), + price: basePrice, + version, +}); + +/** Plan fixtures default to realistic base prices and accept plan items directly. */ +export const plan = { + addOn: ({ + basePrice: price = null, + planId, + ...args + }: { + basePrice?: PlanPrice | null; + items?: ApiPlanV1["items"]; + name?: string; + planId: string; + version?: number; + }): ApiPlanV1 => + createPlan({ ...args, addOn: true, basePrice: price, planId }), + annual: ({ + basePrice: price = basePrice.annual(), + planId = "enterprise", + ...args + }: { + basePrice?: PlanPrice | null; + items?: ApiPlanV1["items"]; + name?: string; + planId?: string; + version?: number; + } = {}): ApiPlanV1 => createPlan({ ...args, basePrice: price, planId }), + monthly: ({ + basePrice: price = basePrice.monthly(), + planId = "pro", + ...args + }: { + basePrice?: PlanPrice | null; + items?: ApiPlanV1["items"]; + name?: string; + planId?: string; + version?: number; + } = {}): ApiPlanV1 => createPlan({ ...args, basePrice: price, planId }), + customized: ({ + customize, + name, + plan: basePlan, + planId = `${basePlan.id}_custom`, + version = basePlan.version, + }: { + customize: EvalCustomizePlan; + name?: string; + plan: ApiPlanV1; + planId?: string; + version?: number; + }): ApiPlanV1 => ({ + ...basePlan, + base_variant_id: basePlan.base_variant_id ?? basePlan.id, + id: planId, + items: applyCustomizeItems({ + customize, + items: basePlan.items, + }), + name: name ?? basePlan.name, + price: + customize.price === undefined + ? basePlan.price + : (customize.price as PlanPrice | null), + version, + ...(customize.free_trial !== undefined + ? { free_trial: customize.free_trial ?? undefined } + : {}), + }), +}; diff --git a/apps/leaf/tests/evals/fixtures/responses.ts b/apps/leaf/tests/evals/fixtures/responses.ts new file mode 100644 index 000000000..a01c14323 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/responses.ts @@ -0,0 +1,78 @@ +import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js"; +import type { ApiPlanV1 } from "@api/products/apiPlanV1.js"; + +const planAmount = (plan: ApiPlanV1) => plan.price?.amount ?? 0; +const schedulePhases = (phases: unknown) => + Array.isArray(phases) + ? phases.map((phase, index) => { + const record = phase as Record; + return { + customer_product_ids: [`cp_schedule_${index + 1}`], + phase_id: `phase_${index + 1}`, + starts_at: + typeof record.starts_at === "number" ? record.starts_at : null, + }; + }) + : []; + +export const responses = { + attachPreview: ({ + customer, + plan, + }: { + customer: BaseApiCustomerV5; + plan: ApiPlanV1; + }) => ({ + customer_id: customer.id, + plan_id: plan.id, + currency: "usd", + line_items: [ + { description: `${plan.name} annual`, total: planAmount(plan) }, + ], + total: planAmount(plan), + }), + attachSuccess: ({ + customer, + plan, + }: { + customer: BaseApiCustomerV5; + plan: ApiPlanV1; + }) => ({ + customer_id: customer.id, + plan_id: plan.id, + status: "created", + }), + createSchedulePreview: ({ + customerId, + phases, + }: { + customerId: string; + phases: unknown; + }) => ({ + customer_id: customerId, + currency: "usd", + line_items: schedulePhases(phases).map((phase, index) => ({ + description: `Schedule phase ${index + 1}`, + starts_at: phase.starts_at, + total: 0, + })), + total: 0, + }), + createScheduleSuccess: ({ + customerId, + entityId = null, + phases, + }: { + customerId: string; + entityId?: string | null; + phases: unknown; + }) => ({ + customer_id: customerId, + entity_id: entityId, + invoice: null, + payment_url: null, + phases: schedulePhases(phases), + schedule_id: `sched_${customerId}`, + status: "created", + }), +}; diff --git a/apps/leaf/tests/evals/fixtures/setups/knowledgePlatformSetup.ts b/apps/leaf/tests/evals/fixtures/setups/knowledgePlatformSetup.ts new file mode 100644 index 000000000..59c9b2a65 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/setups/knowledgePlatformSetup.ts @@ -0,0 +1,167 @@ +import { createSetup } from "../createSetup.js"; + +const featureIds = { + activity_events: "activity_events", + approval_chains: "approval_chains", + automation_rules: "automation_rules", + brand_controls: "brand_controls", + compliance_controls: "compliance_controls", + credits: "credits", + export_center: "export_center", + insight_reports: "insight_reports", + member_slots: "member_slots", + outbound_hooks: "outbound_hooks", + platform_api: "platform_api", + priority_queue: "priority_queue", + private_spaces: "private_spaces", + project_slots: "project_slots", + revision_history: "revision_history", + team_policies: "team_policies", +} as const; + +const planIds = { + automationPack: "automation_pack", + enterprise: "enterprise", + launch: "launch", + scale: "scale", + scaleYearly: "scale_yearly", + securityPack: "security_pack", + trial: "trial", + whiteLabelPack: "white_label_pack", +} as const; + +const platformFeatureIds = [ + featureIds.insight_reports, + featureIds.team_policies, + featureIds.private_spaces, + featureIds.export_center, + featureIds.priority_queue, + featureIds.automation_rules, + featureIds.outbound_hooks, + featureIds.platform_api, + featureIds.approval_chains, + featureIds.brand_controls, + featureIds.compliance_controls, + featureIds.revision_history, +] as const; + +/** Anonymized org setup with credits, many feature flags, core plans, and add-ons. */ +export const knowledgePlatformSetup = () => + createSetup({ + tag: "knowledge-platform", + features: ({ featureList, features }) => ({ + activity_events: features.consumable({ + featureId: featureIds.activity_events, + name: "Activity Events", + }), + credits: features.creditSystem({ + featureId: featureIds.credits, + meteredFeatureId: featureIds.activity_events, + }), + member_slots: features.allocated({ featureId: featureIds.member_slots }), + project_slots: features.allocated({ + featureId: featureIds.project_slots, + }), + ...featureList.boolean({ featureIds: platformFeatureIds }), + }), + plans: ({ basePrice, features, itemList, items, plan, planList }) => { + const creditItems = [ + items.prepaidCredits({ feature: features.credits }), + items.consumableCredits({ feature: features.credits }), + ]; + const coreFeatures = [ + featureIds.insight_reports, + featureIds.team_policies, + featureIds.private_spaces, + featureIds.export_center, + featureIds.automation_rules, + featureIds.platform_api, + ]; + const expandedFeatures = [ + ...coreFeatures, + featureIds.priority_queue, + featureIds.outbound_hooks, + featureIds.approval_chains, + featureIds.brand_controls, + featureIds.compliance_controls, + featureIds.revision_history, + ]; + + return { + launch: plan.monthly({ + basePrice: basePrice.monthly({ amount: 300 }), + items: [ + ...creditItems, + ...itemList.boolean({ featureIds: coreFeatures, features }), + ], + planId: planIds.launch, + }), + scale: plan.monthly({ + basePrice: basePrice.monthly({ amount: 500 }), + items: [ + ...creditItems, + ...itemList.boolean({ featureIds: expandedFeatures, features }), + ], + planId: planIds.scale, + }), + scaleYearly: plan.annual({ + basePrice: basePrice.annual({ amount: 5_000 }), + items: [ + ...creditItems, + ...itemList.boolean({ featureIds: expandedFeatures, features }), + ], + planId: planIds.scaleYearly, + }), + trial: plan.monthly({ + basePrice: null, + items: [ + items.included({ feature: features.credits, included: 1_000 }), + ...itemList.boolean({ + featureIds: [ + featureIds.insight_reports, + featureIds.private_spaces, + featureIds.platform_api, + ], + features, + }), + ], + planId: planIds.trial, + }), + enterprise: plan.monthly({ + basePrice: null, + items: [ + ...creditItems, + items.included({ feature: features.member_slots, included: 25 }), + items.included({ feature: features.project_slots, included: 100 }), + ...itemList.boolean({ featureIds: expandedFeatures, features }), + ], + planId: planIds.enterprise, + }), + ...planList.addOns({ + addOns: [ + { + amount: 75, + feature: features.automation_rules, + key: "automationPack", + planId: planIds.automationPack, + }, + { + amount: 2_400, + feature: features.compliance_controls, + interval: "year", + key: "securityPack", + planId: planIds.securityPack, + }, + { + amount: 3_000, + feature: features.brand_controls, + interval: "year", + key: "whiteLabelPack", + planId: planIds.whiteLabelPack, + }, + ], + }), + }; + }, + customers: () => ({}), + }); diff --git a/apps/leaf/tests/evals/fixtures/types.ts b/apps/leaf/tests/evals/fixtures/types.ts new file mode 100644 index 000000000..f1e85d545 --- /dev/null +++ b/apps/leaf/tests/evals/fixtures/types.ts @@ -0,0 +1,79 @@ +import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js"; +import type { ApiCustomerSchedule } from "@api/customers/components/apiCustomerSchedule"; +import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js"; +import type { ApiPlanV1 } from "@api/products/apiPlanV1.js"; + +export type PlanRef = ApiPlanV1 | ApiPlanV1[]; +export type ScheduleRef = ApiCustomerSchedule | ApiCustomerSchedule[]; +type RefId = Value["id"]; +type RefIds = Value extends unknown[] + ? never + : RefId; + +export type EvalSetupIds< + Features extends Record = Record, + Plans extends Record = Record, + Customers extends Record< + string, + BaseApiCustomerV5 | BaseApiCustomerV5[] + > = Record, + Schedules extends Record = Record, +> = { + features: { + [Key in keyof Features]: RefIds; + }; + plans: { + [Key in keyof Plans]: Plans[Key] extends ApiPlanV1[] + ? Array> + : Plans[Key] extends ApiPlanV1 + ? RefIds + : never; + }; + customers: { + [Key in keyof Customers]: Customers[Key] extends BaseApiCustomerV5[] + ? Array> + : Customers[Key] extends BaseApiCustomerV5 + ? RefIds + : never; + }; + schedules: { + [Key in keyof Schedules]: Schedules[Key] extends ApiCustomerSchedule[] + ? Array> + : Schedules[Key] extends ApiCustomerSchedule + ? RefIds + : never; + }; +}; + +export type EvalSetupRefs< + Features extends Record = Record, + Plans extends Record = Record, + Customers extends Record< + string, + BaseApiCustomerV5 | BaseApiCustomerV5[] + > = Record, + Schedules extends Record = Record, +> = { + features: Features; + plans: Plans; + customers: Customers; + schedules: Schedules; +}; + +export type EvalSetup< + Features extends Record = Record, + Plans extends Record = Record, + Customers extends Record< + string, + BaseApiCustomerV5 | BaseApiCustomerV5[] + > = Record, + Schedules extends Record = Record, +> = { + tag: string; + ids: EvalSetupIds; + features: ApiFeatureV1[]; + plans: ApiPlanV1[]; + customers: BaseApiCustomerV5[]; + schedules: ApiCustomerSchedule[]; + refs: EvalSetupRefs; +}; diff --git a/apps/leaf/tests/evals/harness/configs/genericMcpAgentConfig.ts b/apps/leaf/tests/evals/harness/configs/genericMcpAgentConfig.ts new file mode 100644 index 000000000..5b63ac4d2 --- /dev/null +++ b/apps/leaf/tests/evals/harness/configs/genericMcpAgentConfig.ts @@ -0,0 +1,12 @@ +export type GenericMcpAgentDriverConfig = { + maxSteps?: number; + model?: string; +}; + +export const genericMcpAgentInstructions = + "Use Autumn MCP tools. Call getAgentRules before customer, billing, balance, entity, or plan work. Preview destructive writes before applying them."; + +export const defaultGenericMcpAgentConfig = { + maxSteps: 6, + model: "anthropic/claude-sonnet-4-6", +} satisfies Required; diff --git a/apps/leaf/tests/evals/harness/context/createAutumnApiMock.ts b/apps/leaf/tests/evals/harness/context/createAutumnApiMock.ts new file mode 100644 index 000000000..c7fff50e5 --- /dev/null +++ b/apps/leaf/tests/evals/harness/context/createAutumnApiMock.ts @@ -0,0 +1,200 @@ +import { customers } from "../../fixtures/customers/index.js"; +import { responses } from "../../fixtures/responses.js"; +import type { EvalTrace } from "../tracing/types.js"; +import type { AutumnApiMock, AutumnApiMockOverrides } from "./types.js"; + +const serverURL = "http://localhost:8080"; + +const endpointToTool = { + "/v1/balances.create": "createBalance", + "/v1/billing.attach": "attach", + "/v1/billing.create_schedule": "createSchedule", + "/v1/billing.preview_attach": "previewAttach", + "/v1/billing.preview_create_schedule": "previewCreateSchedule", + "/v1/customers.get": "getCustomer", + "/v1/customers.get_or_create": "getOrCreateCustomer", + "/v1/customers.list": "listCustomers", + "/v1/customers.update": "updateCustomer", + "/v1/features.list": "listFeatures", + "/v1/plans.get": "getPlan", + "/v1/plans.list": "listPlans", +} as const; + +const getString = (body: Record, key: string) => + typeof body[key] === "string" ? body[key] : ""; + +const defaultHandlers = { + attach: ({ body, setup }) => { + const customer = setup.customers.find( + (customer) => customer.id === getString(body, "customer_id"), + ); + const plan = setup.plans.find( + (plan) => plan.id === getString(body, "plan_id"), + ); + if (!customer || !plan) return { error: "missing customer or plan" }; + customer.subscriptions = [ + ...customer.subscriptions, + { + add_on: plan.add_on, + auto_enable: plan.auto_enable, + canceled_at: null, + current_period_end: null, + current_period_start: 1_767_225_600_000, + expires_at: null, + id: `sub_${plan.id}`, + past_due: false, + plan_id: plan.id, + quantity: 1, + started_at: 1_767_225_600_000, + status: "active", + trial_ends_at: null, + }, + ]; + return responses.attachSuccess({ customer, plan }); + }, + createBalance: () => ({ status: "created" }), + createSchedule: ({ body, setup }) => { + const customerId = getString(body, "customer_id"); + const customer = setup.customers.find( + (customer) => customer.id === customerId, + ); + if (!customer) return { error: "customer not found" }; + return responses.createScheduleSuccess({ + customerId, + entityId: getString(body, "entity_id") || null, + phases: body.phases, + }); + }, + getCustomer: ({ body, setup }) => { + const customer = setup.customers.find( + (customer) => customer.id === getString(body, "customer_id"), + ); + return customer ?? { error: "customer not found" }; + }, + getOrCreateCustomer: ({ body, setup }) => { + const customerId = getString(body, "customer_id"); + const customer = setup.customers.find( + (customer) => customer.id === customerId, + ); + if (customer) return customer; + + const created = customers.active({ id: customerId }); + setup.customers.push(created); + return created; + }, + getPlan: ({ body, setup }) => { + const plan = setup.plans.find( + (plan) => plan.id === getString(body, "plan_id"), + ); + return plan ?? { error: "plan not found" }; + }, + listCustomers: ({ body, setup }) => { + const search = getString(body, "search").toLowerCase(); + const list = search + ? setup.customers.filter((customer) => + [customer.id, customer.name, customer.email].some( + (value) => + typeof value === "string" && value.toLowerCase().includes(search), + ), + ) + : setup.customers; + + return { + limit: list.length, + list, + offset: 0, + total: setup.customers.length, + total_count: setup.customers.length, + total_filtered_count: list.length, + }; + }, + listFeatures: ({ setup }) => ({ list: setup.features }), + listPlans: ({ setup }) => ({ + list: setup.plans, + }), + previewAttach: ({ body, setup }) => { + const customer = setup.customers.find( + (customer) => customer.id === getString(body, "customer_id"), + ); + const plan = setup.plans.find( + (plan) => plan.id === getString(body, "plan_id"), + ); + if (!customer || !plan) return { error: "missing customer or plan" }; + return responses.attachPreview({ customer, plan }); + }, + previewCreateSchedule: ({ body, setup }) => { + const customerId = getString(body, "customer_id"); + const customer = setup.customers.find( + (customer) => customer.id === customerId, + ); + if (!customer) return { error: "customer not found" }; + return responses.createSchedulePreview({ + customerId, + phases: body.phases, + }); + }, + updateCustomer: ({ body, setup }) => { + const customer = setup.customers.find( + (customer) => customer.id === getString(body, "customer_id"), + ); + if (!customer) return { error: "customer not found" }; + if (typeof body.email === "string") customer.email = body.email; + if (typeof body.name === "string") customer.name = body.name; + return customer; + }, +} satisfies AutumnApiMockOverrides; + +export const createAutumnApiMock = ({ + overrides = {}, + setup, + trace, +}: { + overrides?: AutumnApiMockOverrides; + setup: AutumnApiMock["setup"]; + trace?: EvalTrace; +}): AutumnApiMock => { + const calls: AutumnApiMock["calls"] = []; + const originalFetch = globalThis.fetch; + const handlers = { ...defaultHandlers, ...overrides }; + + globalThis.fetch = (async (input, init) => { + const url = new URL(String(input)); + if (url.origin !== serverURL) return originalFetch(input, init); + + const endpoint = url.pathname; + const toolName = + endpointToTool[endpoint as keyof typeof endpointToTool] ?? null; + const body = JSON.parse(String(init?.body ?? "{}")); + const call = { body, endpoint, toolName }; + calls.push(call); + trace?.event({ call, type: "api_call" }); + + if (!toolName) { + return Response.json( + { error: `Unhandled endpoint: ${endpoint}` }, + { status: 500 }, + ); + } + + const handler = handlers[toolName]; + if (!handler) { + return Response.json( + { error: `No handler for ${toolName}` }, + { status: 500 }, + ); + } + + const response = handler({ body, setup }); + trace?.event({ endpoint, response, type: "api_response" }); + return Response.json(response); + }) as typeof fetch; + + return { + calls, + restore: () => { + globalThis.fetch = originalFetch; + }, + serverURL, + setup, + }; +}; diff --git a/apps/leaf/tests/evals/harness/context/createAutumnMcpServer.ts b/apps/leaf/tests/evals/harness/context/createAutumnMcpServer.ts new file mode 100644 index 000000000..ca46b4b06 --- /dev/null +++ b/apps/leaf/tests/evals/harness/context/createAutumnMcpServer.ts @@ -0,0 +1,67 @@ +import { createServer, type IncomingMessage, type Server } from "node:http"; +import type { Socket } from "node:net"; +import { MCPServer } from "@mastra/mcp"; +import { setAnalyticsSink } from "../../../../../../packages/mcp/src/analytics/analyticsSink.js"; +import type { AutumnMcpAuth } from "../../../../../../packages/mcp/src/server/auth/auth.js"; +import { createRawAutumnOperationTools } from "../../../../../../packages/mcp/src/tools/index.js"; +import type { EvalMcpServer } from "./types.js"; + +const closeServer = ({ + server, + sockets, +}: { + server: Server; + sockets: Set; +}) => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections?.(); + for (const socket of sockets) socket.destroy(); + }); + +const createEvalMcpServer = () => + new MCPServer({ + id: "autumn-mcp-eval", + name: "Autumn MCP Eval", + version: "0.0.1", + description: "Operate on Autumn customers, plans, and billing.", + instructions: + "Use preview tools before billing writes. Write tools are destructive and should only be called after explicit user confirmation.", + tools: createRawAutumnOperationTools(), + }); + +export const createAutumnMcpServer = (auth: AutumnMcpAuth) => + new Promise((resolve) => { + setAnalyticsSink(null); + const sockets = new Set(); + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/mcp", `http://${req.headers.host}`); + if (url.pathname !== "/mcp") { + res.writeHead(404).end(); + return; + } + + (req as IncomingMessage & { auth?: AutumnMcpAuth }).auth = auth; + await createEvalMcpServer().startHTTP({ + httpPath: "/mcp", + options: { serverless: true }, + req, + res, + url, + }); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("MCP eval server did not bind to a TCP port."); + } + resolve({ + close: () => closeServer({ server, sockets }), + url: new URL(`http://127.0.0.1:${address.port}/mcp`), + }); + }); + }); diff --git a/apps/leaf/tests/evals/harness/context/createEvalRuntimeContext.ts b/apps/leaf/tests/evals/harness/context/createEvalRuntimeContext.ts new file mode 100644 index 000000000..7c85a545d --- /dev/null +++ b/apps/leaf/tests/evals/harness/context/createEvalRuntimeContext.ts @@ -0,0 +1,52 @@ +import type { AutumnMcpAuth } from "../../../../../../packages/mcp/src/server/auth/auth.js"; +import type { EvalSetup } from "../../fixtures/types.js"; +import type { EvalTrace } from "../tracing/types.js"; +import { createAutumnApiMock } from "./createAutumnApiMock.js"; +import { createAutumnMcpServer } from "./createAutumnMcpServer.js"; +import type { AutumnApiMockOverrides, EvalRuntimeContext } from "./types.js"; + +const defaultAuth: AutumnMcpAuth = { + apiKey: "sk_test", + env: "sandbox", + principalId: "eval-user", + resource: "http://localhost:2718/mcp", + scopes: [ + "customers:read", + "customers:write", + "plans:read", + "billing:read", + "billing:write", + "balances:write", + ], + serverURL: "http://localhost:8080", +}; + +export const createEvalRuntimeContext = async ({ + auth = {}, + autumnApiOverrides, + setup, + trace, +}: { + auth?: Partial; + autumnApiOverrides?: AutumnApiMockOverrides; + setup: EvalSetup; + trace: EvalTrace; +}): Promise => { + const resolvedAuth = { ...defaultAuth, ...auth }; + const autumnApi = createAutumnApiMock({ + overrides: autumnApiOverrides, + setup, + trace, + }); + const mcpServer = await createAutumnMcpServer(resolvedAuth); + + return { + auth: resolvedAuth, + autumnApi, + cleanup: async () => { + autumnApi.restore(); + await mcpServer.close(); + }, + mcpServer, + }; +}; diff --git a/apps/leaf/tests/evals/harness/context/types.ts b/apps/leaf/tests/evals/harness/context/types.ts new file mode 100644 index 000000000..0606ae22a --- /dev/null +++ b/apps/leaf/tests/evals/harness/context/types.ts @@ -0,0 +1,53 @@ +import type { AutumnMcpAuth } from "../../../../../../packages/mcp/src/server/auth/auth.js"; +import type { EvalSetup } from "../../fixtures/types.js"; + +export type AutumnEvalToolName = + | "attach" + | "createBalance" + | "createSchedule" + | "getCustomer" + | "getOrCreateCustomer" + | "getPlan" + | "listCustomers" + | "listFeatures" + | "listPlans" + | "previewAttach" + | "previewCreateSchedule" + | "updateCustomer"; + +export type AutumnApiCall = { + toolName: AutumnEvalToolName | null; + endpoint: string; + body: Record; +}; + +export type AutumnApiMockHandler = ({ + body, + setup, +}: { + body: Record; + setup: EvalSetup; +}) => unknown; + +export type AutumnApiMockOverrides = Partial< + Record +>; + +export type AutumnApiMock = { + calls: AutumnApiCall[]; + restore(): void; + serverURL: string; + setup: EvalSetup; +}; + +export type EvalMcpServer = { + close(): Promise; + url: URL; +}; + +export type EvalRuntimeContext = { + auth: AutumnMcpAuth; + autumnApi: AutumnApiMock; + cleanup(): Promise; + mcpServer: EvalMcpServer; +}; diff --git a/apps/leaf/tests/evals/harness/createEvalContext.ts b/apps/leaf/tests/evals/harness/createEvalContext.ts new file mode 100644 index 000000000..b8e02d464 --- /dev/null +++ b/apps/leaf/tests/evals/harness/createEvalContext.ts @@ -0,0 +1,107 @@ +import type { AutumnMcpAuth } from "../../../../../packages/mcp/src/server/auth/auth.js"; +import type { EvalSetup } from "../fixtures/types.js"; +import { createEvalRuntimeContext } from "./context/createEvalRuntimeContext.js"; +import type { + AutumnApiMockOverrides, + EvalRuntimeContext, +} from "./context/types.js"; +import type { EvalAgentDriver } from "./drivers/types.js"; +import { createEvalTrace } from "./tracing/createEvalTrace.js"; +import type { EvalTrace, EvalTraceLevel } from "./tracing/types.js"; + +export type EvalTurn = + | { maxSteps?: number; message: string; type: "user" } + | { maxSteps?: number; optional?: boolean; type: "approve" }; + +export type EvalTurnResult = { + text?: string; + type: EvalTurn["type"]; +}; + +export type EvalRunResult = { + apiCalls: EvalRuntimeContext["autumnApi"]["calls"]; + finalText: string; + toolCalls: ReturnType< + Awaited>["getToolCalls"] + >; + turns: EvalTurnResult[]; +}; + +export const createEvalContext = async ({ + auth, + autumnApiOverrides, + driver, + name, + setup, + today, + trace: traceConfig = {}, +}: { + auth?: Partial; + autumnApiOverrides?: AutumnApiMockOverrides; + driver: EvalAgentDriver; + name?: string; + setup: EvalSetup; + today?: Date; + trace?: { level?: EvalTraceLevel }; +}) => { + const trace: EvalTrace = createEvalTrace(traceConfig); + trace.event({ name, type: "eval_started" }); + const runtimeContext = await createEvalRuntimeContext({ + auth, + autumnApiOverrides, + setup, + trace, + }); + const runningDriver = await driver.start({ + context: runtimeContext, + name, + setup, + today, + trace, + }); + + const runConversation = async (turns: EvalTurn[]): Promise => { + const turnResults: EvalTurnResult[] = []; + for (const turn of turns) { + if (turn.type === "user") { + trace.event({ message: turn.message, type: "user_turn" }); + const output = await runningDriver.send(turn.message, { + maxSteps: turn.maxSteps, + }); + turnResults.push({ text: output.text, type: turn.type }); + continue; + } + + if (!runningDriver.hasPendingApproval()) { + if (turn.optional) { + turnResults.push({ type: turn.type }); + continue; + } + throw new Error("No pending approval to approve."); + } + const output = await runningDriver.approve({ maxSteps: turn.maxSteps }); + turnResults.push({ text: output.text, type: turn.type }); + } + + trace.event({ type: "eval_finished" }); + return { + apiCalls: runtimeContext.autumnApi.calls, + finalText: turnResults + .map((turn) => turn.text) + .filter(Boolean) + .join("\n"), + toolCalls: runningDriver.getToolCalls(), + turns: turnResults, + }; + }; + + return { + cleanup: async () => { + await runningDriver.cleanup(); + await runtimeContext.cleanup(); + }, + runConversation, + runtimeContext, + trace, + }; +}; diff --git a/apps/leaf/tests/evals/harness/drivers/genericMcpAgent.ts b/apps/leaf/tests/evals/harness/drivers/genericMcpAgent.ts new file mode 100644 index 000000000..99d3a4c94 --- /dev/null +++ b/apps/leaf/tests/evals/harness/drivers/genericMcpAgent.ts @@ -0,0 +1,180 @@ +import { Agent } from "@mastra/core/agent"; +import type { MessageListItem } from "@mastra/core/agent/message-list"; +import { Mastra } from "@mastra/core/mastra"; +import { InMemoryStore } from "@mastra/core/storage"; +import { MCPClient } from "@mastra/mcp"; +import { createRequestContext } from "../../../../../../packages/mcp/src/server/auth/auth.js"; +import { createLeafTracingOptions } from "../../../../src/internal/observability/leafTracingOptions.js"; +import { createMastraBraintrustObservability } from "../../../../src/providers/braintrust/index.js"; +import { + defaultGenericMcpAgentConfig, + type GenericMcpAgentDriverConfig, + genericMcpAgentInstructions, +} from "../configs/genericMcpAgentConfig.js"; +import type { + EvalAgentDriver, + EvalDriverStartInput, + EvalToolCall, +} from "./types.js"; + +type ToolWithApproval = { + execute?: unknown; + mcp?: { annotations?: { destructiveHint?: boolean } }; + needsApprovalFn?: unknown; + requireApproval?: unknown; +}; + +const applyToolApprovalPolicy = (tools: Record) => { + for (const tool of Object.values(tools)) { + const requiresApproval = tool.mcp?.annotations?.destructiveHint === true; + tool.requireApproval = requiresApproval; + if (!requiresApproval) tool.needsApprovalFn = undefined; + } +}; + +const toEvalToolCall = (call: { + args?: Record; + name: string; +}): EvalToolCall => ({ + args: call.args ?? {}, + name: call.name, +}); + +const instrumentToolCalls = ({ + tools, + toolCalls, + trace, +}: { + tools: Record; + toolCalls: EvalToolCall[]; + trace: EvalDriverStartInput["trace"]; +}) => { + for (const [name, tool] of Object.entries(tools)) { + if (typeof tool.execute !== "function") continue; + const execute = tool.execute.bind(tool) as ( + args: Record, + ...rest: unknown[] + ) => Promise; + tool.execute = async ( + args: Record, + ...rest: unknown[] + ) => { + const call = toEvalToolCall({ args, name }); + toolCalls.push(call); + trace.event({ call, type: "tool_call" }); + return execute(args, ...rest); + }; + } +}; + +export const createGenericMcpAgentDriver = ({ + maxSteps = defaultGenericMcpAgentConfig.maxSteps, + model = defaultGenericMcpAgentConfig.model, +}: GenericMcpAgentDriverConfig = {}): EvalAgentDriver => ({ + name: "generic-mcp-agent", + start: async ({ context, setup, today, trace }: EvalDriverStartInput) => { + const mcpClient = new MCPClient({ + id: `leaf-eval-${crypto.randomUUID()}`, + servers: { + autumn: { + requireToolApproval: ({ annotations }) => + annotations?.destructiveHint === true, + url: context.mcpServer.url, + }, + }, + }); + const { toolsets, errors } = await mcpClient.listToolsetsWithErrors(); + if (Object.keys(errors).length) { + throw new Error(`MCP tool discovery failed: ${JSON.stringify(errors)}`); + } + + const tools = toolsets.autumn ?? {}; + applyToolApprovalPolicy(tools); + const toolCalls: EvalToolCall[] = []; + instrumentToolCalls({ toolCalls, tools, trace }); + + const agent = new Agent({ + id: "leaf-mcp-eval-agent", + name: "Leaf MCP Eval Agent", + description: "A generic agent using Autumn MCP tools.", + instructions: genericMcpAgentInstructions, + model, + tools, + }); + const mastra = new Mastra({ + agents: { eval: agent }, + logger: false, + observability: createMastraBraintrustObservability(), + storage: new InMemoryStore({ id: `leaf-eval-${crypto.randomUUID()}` }), + }); + const evalAgent = mastra.getAgent("eval"); + let messages: MessageListItem[] = []; + let pendingApproval: { runId: string; toolCallId?: string } | null = null; + + const options = (stepLimit?: number) => ({ + context: today + ? [ + { + content: `Current date: ${today.toISOString()}.`, + role: "system" as const, + }, + ] + : undefined, + maxSteps: stepLimit ?? maxSteps, + requestContext: createRequestContext(context.auth), + tracingOptions: createLeafTracingOptions({ + env: context.auth.env, + orgId: context.auth.orgId, + setup: setup.tag, + source: "eval", + }), + }); + + const rememberApproval = (output: { + finishReason?: string; + runId?: string; + suspendPayload?: { toolCallId?: string }; + }) => { + pendingApproval = + output.finishReason === "suspended" && output.runId + ? { + runId: output.runId, + toolCallId: output.suspendPayload?.toolCallId, + } + : null; + if (pendingApproval) trace.event({ type: "approval_pending" }); + }; + + return { + approve: async ({ maxSteps: stepLimit } = {}) => { + if (!pendingApproval) { + throw new Error("No pending approval to approve."); + } + trace.event({ type: "approval_approved" }); + const output = await evalAgent.approveToolCallGenerate({ + ...options(stepLimit), + runId: pendingApproval.runId, + toolCallId: pendingApproval.toolCallId, + }); + messages = output.messages; + rememberApproval(output); + trace.event({ text: output.text ?? "", type: "agent_text" }); + return { text: output.text }; + }, + cleanup: async () => { + await mastra.shutdown(); + await mcpClient.disconnect(); + }, + getToolCalls: () => [...toolCalls], + hasPendingApproval: () => pendingApproval !== null, + send: async (message, { maxSteps: stepLimit } = {}) => { + messages.push({ content: message, role: "user" }); + const output = await evalAgent.generate(messages, options(stepLimit)); + messages = output.messages; + rememberApproval(output); + trace.event({ text: output.text ?? "", type: "agent_text" }); + return { text: output.text }; + }, + }; + }, +}); diff --git a/apps/leaf/tests/evals/harness/drivers/leafAgent.ts b/apps/leaf/tests/evals/harness/drivers/leafAgent.ts new file mode 100644 index 000000000..d24540750 --- /dev/null +++ b/apps/leaf/tests/evals/harness/drivers/leafAgent.ts @@ -0,0 +1,200 @@ +import { AppEnv } from "@autumn/shared"; +import type { MessageListItem } from "@mastra/core/agent/message-list"; +import type { ToolsInput } from "@mastra/core/agent"; +import { Mastra } from "@mastra/core/mastra"; +import { InMemoryStore } from "@mastra/core/storage"; +import { MCPClient } from "@mastra/mcp"; +import { createRequestContext } from "../../../../../../packages/mcp/src/server/auth/auth.js"; +import { + agentDocUris, + createAutumnChatAgent, +} from "../../../../src/agent/chatAgent.js"; +import { createLeafTracingOptions } from "../../../../src/internal/observability/leafTracingOptions.js"; +import { createMastraBraintrustObservability } from "../../../../src/providers/braintrust/index.js"; +import { defaultGenericMcpAgentConfig } from "../configs/genericMcpAgentConfig.js"; +import type { + EvalAgentDriver, + EvalDriverStartInput, + EvalToolCall, +} from "./types.js"; + +type LeafAgentDriverConfig = { + maxSteps?: number; + model?: string; +}; + +type ToolWithApproval = { + execute?: unknown; + mcp?: { annotations?: { destructiveHint?: boolean } }; + needsApprovalFn?: unknown; + requireApproval?: unknown; +}; + +const applyToolApprovalPolicy = (tools: Record) => { + for (const tool of Object.values(tools)) { + const requiresApproval = tool.mcp?.annotations?.destructiveHint === true; + tool.requireApproval = requiresApproval; + if (!requiresApproval) tool.needsApprovalFn = undefined; + } +}; + +const instrumentToolCalls = ({ + tools, + toolCalls, + trace, +}: { + tools: Record; + toolCalls: EvalToolCall[]; + trace: EvalDriverStartInput["trace"]; +}) => { + for (const [name, tool] of Object.entries(tools)) { + if (typeof tool.execute !== "function") continue; + const execute = tool.execute.bind(tool) as ( + args: Record, + ...rest: unknown[] + ) => Promise; + tool.execute = async ( + args: Record, + ...rest: unknown[] + ) => { + const call = { args, name }; + toolCalls.push(call); + trace.event({ call, type: "tool_call" }); + return execute(args, ...rest); + }; + } +}; + +const readDocs = async (mcpClient: MCPClient) => { + const resources = await Promise.allSettled( + agentDocUris.map((uri) => mcpClient.resources.read("autumn", uri)), + ); + return resources + .flatMap((result) => + result.status === "fulfilled" + ? result.value.contents.flatMap((content) => + "text" in content ? [content.text] : [], + ) + : [], + ) + .join("\n\n"); +}; + +export const createLeafAgentDriver = ({ + maxSteps = defaultGenericMcpAgentConfig.maxSteps, + model = defaultGenericMcpAgentConfig.model, +}: LeafAgentDriverConfig = {}): EvalAgentDriver => ({ + name: "leaf-agent", + start: async ({ context, setup, today, trace }: EvalDriverStartInput) => { + const mcpClient = new MCPClient({ + id: `leaf-agent-eval-${crypto.randomUUID()}`, + servers: { + autumn: { + requireToolApproval: ({ annotations }) => + annotations?.destructiveHint === true, + url: context.mcpServer.url, + }, + }, + }); + const [{ toolsets, errors }, docsText] = await Promise.all([ + mcpClient.listToolsetsWithErrors(), + readDocs(mcpClient), + ]); + if (Object.keys(errors).length) { + throw new Error(`MCP tool discovery failed: ${JSON.stringify(errors)}`); + } + + const env = + context.auth.env === AppEnv.Live ? AppEnv.Live : AppEnv.Sandbox; + const tools = (toolsets.autumn ?? {}) as Record; + applyToolApprovalPolicy(tools); + const toolCalls: EvalToolCall[] = []; + instrumentToolCalls({ toolCalls, tools, trace }); + + const agent = createAutumnChatAgent({ + docsText, + env, + model, + tools: tools as ToolsInput, + }); + const mastra = new Mastra({ + agents: { chat: agent }, + logger: false, + observability: createMastraBraintrustObservability(), + storage: new InMemoryStore({ + id: `leaf-agent-eval-${crypto.randomUUID()}`, + }), + }); + const evalAgent = mastra.getAgent("chat"); + let messages: MessageListItem[] = []; + let pendingApproval: { runId: string; toolCallId?: string } | null = null; + + const options = (stepLimit?: number) => ({ + context: today + ? [ + { + content: `Current date: ${today.toISOString()}.`, + role: "system" as const, + }, + ] + : undefined, + maxSteps: stepLimit ?? maxSteps, + requestContext: createRequestContext(context.auth), + tracingOptions: createLeafTracingOptions({ + env, + orgId: context.auth.orgId, + setup: setup.tag, + source: "eval", + }), + }); + + const rememberApproval = (output: { + finishReason?: string; + runId?: string; + suspendPayload?: { toolCallId?: string }; + }) => { + pendingApproval = + output.finishReason === "suspended" && output.runId + ? { + runId: output.runId, + toolCallId: output.suspendPayload?.toolCallId, + } + : null; + if (pendingApproval) trace.event({ type: "approval_pending" }); + }; + + return { + approve: async ({ maxSteps: stepLimit } = {}) => { + if (!pendingApproval) { + throw new Error("No pending approval to approve."); + } + trace.event({ type: "approval_approved" }); + const output = await evalAgent.approveToolCallGenerate({ + ...options(stepLimit), + runId: pendingApproval.runId, + toolCallId: pendingApproval.toolCallId, + }); + messages = output.messages; + rememberApproval(output); + trace.event({ text: output.text ?? "", type: "agent_text" }); + return { text: output.text }; + }, + cleanup: async () => { + await mastra.shutdown(); + await mcpClient.disconnect(); + }, + getToolCalls: () => [...toolCalls], + hasPendingApproval: () => pendingApproval !== null, + send: async (message, { maxSteps: stepLimit } = {}) => { + messages.push({ content: message, role: "user" }); + const output = await evalAgent.generate(messages, options(stepLimit)); + messages = output.messages; + rememberApproval(output); + trace.event({ text: output.text ?? "", type: "agent_text" }); + return { text: output.text }; + }, + }; + }, +}); + +export type { LeafAgentDriverConfig }; diff --git a/apps/leaf/tests/evals/harness/drivers/types.ts b/apps/leaf/tests/evals/harness/drivers/types.ts new file mode 100644 index 000000000..709d5a954 --- /dev/null +++ b/apps/leaf/tests/evals/harness/drivers/types.ts @@ -0,0 +1,36 @@ +import type { EvalSetup } from "../../fixtures/types.js"; +import type { EvalRuntimeContext } from "../context/types.js"; +import type { EvalTrace } from "../tracing/types.js"; + +export type EvalToolCall = { + args: Record; + name: string; +}; + +export type EvalAgentOutput = { + text?: string; +}; + +export type EvalDriverStartInput = { + context: EvalRuntimeContext; + name?: string; + setup: EvalSetup; + today?: Date; + trace: EvalTrace; +}; + +export type RunningEvalDriver = { + approve(options?: { maxSteps?: number }): Promise; + cleanup(): Promise; + getToolCalls(): EvalToolCall[]; + hasPendingApproval(): boolean; + send( + message: string, + options?: { maxSteps?: number }, + ): Promise; +}; + +export type EvalAgentDriver = { + name: string; + start(input: EvalDriverStartInput): Promise; +}; diff --git a/apps/leaf/tests/evals/harness/index.ts b/apps/leaf/tests/evals/harness/index.ts new file mode 100644 index 000000000..679de99ac --- /dev/null +++ b/apps/leaf/tests/evals/harness/index.ts @@ -0,0 +1,41 @@ +export { + defaultGenericMcpAgentConfig, + type GenericMcpAgentDriverConfig, + genericMcpAgentInstructions, +} from "./configs/genericMcpAgentConfig.js"; +export { createAutumnApiMock } from "./context/createAutumnApiMock.js"; +export { createAutumnMcpServer } from "./context/createAutumnMcpServer.js"; +export { createEvalRuntimeContext } from "./context/createEvalRuntimeContext.js"; +export type { + AutumnApiCall, + AutumnApiMock, + AutumnApiMockHandler, + AutumnApiMockOverrides, + AutumnEvalToolName, + EvalMcpServer, + EvalRuntimeContext, +} from "./context/types.js"; +export type { + EvalRunResult, + EvalTurn, + EvalTurnResult, +} from "./createEvalContext.js"; +export { createEvalContext } from "./createEvalContext.js"; +export { createGenericMcpAgentDriver } from "./drivers/genericMcpAgent.js"; +export { + createLeafAgentDriver, + type LeafAgentDriverConfig, +} from "./drivers/leafAgent.js"; +export type { + EvalAgentDriver, + EvalAgentOutput, + EvalToolCall, + RunningEvalDriver, +} from "./drivers/types.js"; +export { approve, initEval, user } from "./initEval.js"; +export { createEvalTrace } from "./tracing/createEvalTrace.js"; +export type { + EvalTrace, + EvalTraceEvent, + EvalTraceLevel, +} from "./tracing/types.js"; diff --git a/apps/leaf/tests/evals/harness/initEval.ts b/apps/leaf/tests/evals/harness/initEval.ts new file mode 100644 index 000000000..878bbef58 --- /dev/null +++ b/apps/leaf/tests/evals/harness/initEval.ts @@ -0,0 +1,117 @@ +import { Eval } from "braintrust"; +import type { AutumnMcpAuth } from "../../../../../packages/mcp/src/server/auth/auth.js"; +import type { EvalSetup } from "../fixtures/types.js"; +import { + standardEvalScores, + type EvalExpected, + type EvalScorer, +} from "../utils/scorers.js"; +import { + createEvalContext, + type EvalRunResult, + type EvalTurn, +} from "./createEvalContext.js"; +import type { AutumnApiMockOverrides } from "./context/types.js"; +import { createLeafAgentDriver } from "./drivers/leafAgent.js"; +import type { EvalAgentDriver } from "./drivers/types.js"; +import type { EvalTraceLevel } from "./tracing/types.js"; + +type EvalCaseMetadata = Record; + +type InitEvalCase = { + conversation: EvalTurn[]; + expect?: EvalExpected; + metadata?: Partial; + name?: string; +}; + +type InitEvalInput = { + conversation: EvalTurn[]; +}; + +type InitEvalOptions = { + auth?: Partial; + autumnApiOverrides?: AutumnApiMockOverrides; + cases: InitEvalCase[]; + driver?: EvalAgentDriver; + experimentName: string; + metadata: Metadata; + scores?: EvalScorer[]; + setup: EvalSetup; + timeout?: number; + today?: Date; + trace?: { level?: EvalTraceLevel }; +}; + +export const user = ({ + maxSteps, + message, +}: { + maxSteps?: number; + message: string; +}): EvalTurn => ({ + ...(maxSteps === undefined ? {} : { maxSteps }), + message, + type: "user", +}); + +export const approve = ({ + maxSteps, + optional = true, +}: { + maxSteps?: number; + optional?: boolean; +} = {}): EvalTurn => ({ + ...(maxSteps === undefined ? {} : { maxSteps }), + optional, + type: "approve", +}); + +export const initEval = ({ + auth, + autumnApiOverrides, + cases, + driver = createLeafAgentDriver(), + experimentName, + metadata, + scores = standardEvalScores(), + setup, + timeout = 45_000, + today, + trace, +}: InitEvalOptions) => + Eval( + "leaf", + { + experimentName, + data: cases.map((testCase) => ({ + expected: testCase.expect ?? {}, + input: { conversation: testCase.conversation }, + metadata: { + ...metadata, + ...testCase.metadata, + ...(testCase.name ? { caseName: testCase.name } : {}), + setup: setup.tag, + }, + })), + scores, + task: async (input) => { + const context = await createEvalContext({ + auth, + autumnApiOverrides, + driver, + name: experimentName, + setup, + today, + trace, + }); + try { + return await context.runConversation(input.conversation); + } finally { + await context.cleanup(); + } + }, + timeout, + }, + { noSendLogs: !process.env.BRAINTRUST_API_KEY }, + ); diff --git a/apps/leaf/tests/evals/harness/tracing/createEvalTrace.ts b/apps/leaf/tests/evals/harness/tracing/createEvalTrace.ts new file mode 100644 index 000000000..977e655f6 --- /dev/null +++ b/apps/leaf/tests/evals/harness/tracing/createEvalTrace.ts @@ -0,0 +1,26 @@ +import { formatTraceEvent } from "./formatTrace.js"; +import type { EvalTrace, EvalTraceEvent, EvalTraceLevel } from "./types.js"; + +export const createEvalTrace = ({ + level = "steps", +}: { + level?: EvalTraceLevel; +} = {}): EvalTrace => { + const events: EvalTraceEvent[] = []; + const printEvent = (event: EvalTraceEvent) => { + if (level === "off") return; + const line = formatTraceEvent(event); + if (line) console.error(line); + }; + + return { + entries: () => [...events], + event: (event) => { + events.push(event); + printEvent(event); + }, + print: () => { + for (const event of events) printEvent(event); + }, + }; +}; diff --git a/apps/leaf/tests/evals/harness/tracing/formatTrace.ts b/apps/leaf/tests/evals/harness/tracing/formatTrace.ts new file mode 100644 index 000000000..35e472480 --- /dev/null +++ b/apps/leaf/tests/evals/harness/tracing/formatTrace.ts @@ -0,0 +1,185 @@ +import type { AutumnApiCall } from "../context/types.js"; +import type { EvalToolCall } from "../drivers/types.js"; +import type { EvalTraceEvent } from "./types.js"; + +const truncate = ({ text, max = 160 }: { text: string; max?: number }) => + text.length > max ? `${text.slice(0, max - 3)}...` : text; + +const bodyOf = (value: Record) => + value.request && typeof value.request === "object" + ? (value.request as Record) + : value; + +const billingToolNames = new Set([ + "attach", + "createSchedule", + "previewAttach", + "previewCreateSchedule", +]); + +const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const looksLikeEpochMsField = (key: string, value: number) => + (value >= 946_684_800_000 && + value <= 4_102_444_800_000 && + (key.endsWith("_at") || + key.endsWith("_time") || + key === "timestamp" || + key === "date")) || + false; + +const formatEpochMs = (value: number) => { + const date = new Date(value); + const day = date.getUTCDate(); + const month = monthNames[date.getUTCMonth()]; + const year = date.getUTCFullYear(); + const hour = String(date.getUTCHours()).padStart(2, "0"); + const minute = String(date.getUTCMinutes()).padStart(2, "0"); + return `${day} ${month} ${year} ${hour}:${minute} UTC (${value})`; +}; + +const humanizeEpochMs = (value: unknown, key = ""): unknown => { + if (typeof value === "number" && looksLikeEpochMsField(key, value)) { + return formatEpochMs(value); + } + if (Array.isArray(value)) { + return value.map((item) => humanizeEpochMs(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + humanizeEpochMs(entryValue, entryKey), + ]), + ); + } + return value; +}; + +const formatJsonBody = ({ + body, + label, +}: { + body: Record; + label: string; +}) => { + const json = JSON.stringify(humanizeEpochMs(body), null, 2); + return json ? `\n[${label}]\n${json}` : ""; +}; + +const compactFields = (body: Record) => + [ + ["customer", body.customer_id], + ["plan", body.plan_id], + ["entity", body.entity_id], + ["feature", body.feature_id], + ["email", body.email], + ["search", body.search], + [ + "invoice_mode", + typeof body.invoice_mode === "object" && body.invoice_mode !== null + ? "true" + : undefined, + ], + ] + .flatMap(([key, value]) => + typeof value === "string" && value ? [`${key}=${value}`] : [], + ) + .join(" "); + +const formatToolCall = (call: EvalToolCall) => { + const body = bodyOf(call.args); + const fields = compactFields(body); + const details = billingToolNames.has(call.name) + ? formatJsonBody({ body, label: "tool:body" }) + : ""; + return `[tool] ${call.name}${fields ? ` ${fields}` : ""}${details}`; +}; + +const formatApiCall = (call: AutumnApiCall) => { + const fields = compactFields(call.body); + const details = call.endpoint.startsWith("/v1/billing.") + ? formatJsonBody({ body: call.body, label: "api:body" }) + : ""; + return `[api] POST ${call.endpoint}${fields ? ` ${fields}` : ""}${details}`; +}; + +const summarizeRecord = (record: Record) => + [ + ["id", record.id], + ["name", record.name], + [ + "subscriptions", + Array.isArray(record.subscriptions) + ? record.subscriptions.length + : undefined, + ], + ["plan_id", record.plan_id], + ] + .flatMap(([key, value]) => + typeof value === "string" || typeof value === "number" + ? [`${key}=${value}`] + : [], + ) + .join(" "); + +const formatApiResponse = ({ + endpoint, + response, +}: { + endpoint: string; + response: unknown; +}) => { + if (!response || typeof response !== "object") { + return `[api:response] ${endpoint} ${String(response)}`; + } + + const record = response as Record; + if (Array.isArray(record.list)) { + const first = record.list[0]; + const firstSummary = + first && typeof first === "object" + ? summarizeRecord(first as Record) + : ""; + return `[api:response] ${endpoint} list=${record.list.length}${firstSummary ? ` first(${firstSummary})` : ""}`; + } + + const summary = summarizeRecord(record); + return `[api:response] ${endpoint}${summary ? ` ${summary}` : ""}`; +}; + +export const formatTraceEvent = (event: EvalTraceEvent): string | null => { + switch (event.type) { + case "agent_text": + return event.text ? `[agent] ${truncate({ text: event.text })}` : null; + case "api_call": + return formatApiCall(event.call); + case "api_response": + return formatApiResponse(event); + case "approval_approved": + return "[approval] approved pending tool call"; + case "approval_pending": + return "[approval] pending"; + case "eval_finished": + return "[eval] finished"; + case "eval_started": + return `[eval] ${event.name ?? "started"}`; + case "tool_call": + return formatToolCall(event.call); + case "user_turn": + return `[user] ${truncate({ text: event.message })}`; + } +}; diff --git a/apps/leaf/tests/evals/harness/tracing/types.ts b/apps/leaf/tests/evals/harness/tracing/types.ts new file mode 100644 index 000000000..25e86a506 --- /dev/null +++ b/apps/leaf/tests/evals/harness/tracing/types.ts @@ -0,0 +1,21 @@ +import type { AutumnApiCall } from "../context/types.js"; +import type { EvalToolCall } from "../drivers/types.js"; + +export type EvalTraceLevel = "off" | "steps"; + +export type EvalTraceEvent = + | { type: "eval_started"; name?: string } + | { type: "user_turn"; message: string } + | { type: "agent_text"; text: string } + | { type: "tool_call"; call: EvalToolCall } + | { type: "api_call"; call: AutumnApiCall } + | { type: "api_response"; endpoint: string; response: unknown } + | { type: "approval_pending" } + | { type: "approval_approved" } + | { type: "eval_finished" }; + +export type EvalTrace = { + event(event: EvalTraceEvent): void; + entries(): EvalTraceEvent[]; + print(): void; +}; diff --git a/apps/leaf/tests/evals/mcp/billing/customer-plan-summary.eval.ts b/apps/leaf/tests/evals/mcp/billing/customer-plan-summary.eval.ts new file mode 100644 index 000000000..f89ff888c --- /dev/null +++ b/apps/leaf/tests/evals/mcp/billing/customer-plan-summary.eval.ts @@ -0,0 +1,106 @@ +import { Eval } from "braintrust"; +import { withCustomers } from "../../fixtures/createSetup.js"; +import { orgSetups } from "../../fixtures/orgSetups.js"; +import { + createEvalContext, + createGenericMcpAgentDriver, +} from "../../harness/index.js"; +import { + type EvalExpected, + type EvalOutput, + expectedApiCalls, + expectedToolCalls, + finalTextIncludes, +} from "../../utils/scorers.js"; + +type EvalInput = { + confirmation: string; + prompt: string; +}; + +type EvalMetadata = { + domain: "billing"; + setup: string; +}; + +const experimentName = "customer-plan"; + +const setup = withCustomers({ + setup: orgSetups.knowledgePlatform(), + customers: ({ customers, plans, subscriptions }) => ({ + joe: customers.active({ + id: "joe_customer", + name: "Joe", + subscriptions: [ + subscriptions.active({ + currentPeriodEnd: new Date("2026-02-07T00:00:00.000Z"), + currentPeriodStart: new Date("2026-01-01T00:00:00.000Z"), + id: "sub_joe_scale_custom", + plan: plans.scale, + }), + ], + }), + }), +}); +const customer = setup.refs.customers.joe; + +Eval( + "leaf", + { + experimentName, + data: [ + { + expected: { + apiCalls: [{ toolName: "listCustomers" }], + finalTextIncludes: [ + "Joe", + "Scale", + "$500", + "Credits", + "Insight Reports", + ], + toolCalls: ["listCustomers"], + }, + input: { + confirmation: `Yes, use ${customer.id}.`, + prompt: "what plan is Joe on?", + }, + metadata: { + domain: "billing", + setup: setup.tag, + }, + }, + ], + scores: [ + (args) => ({ + name: "Expected tool calls", + score: expectedToolCalls(args), + }), + (args) => ({ + name: "Expected API calls", + score: expectedApiCalls(args), + }), + (args) => ({ + name: "Final text includes", + score: finalTextIncludes(args), + }), + ], + task: async (input) => { + const context = await createEvalContext({ + driver: createGenericMcpAgentDriver(), + name: experimentName, + setup, + }); + try { + return await context.runConversation([ + { message: input.prompt, type: "user" }, + { message: input.confirmation, type: "user" }, + ]); + } finally { + await context.cleanup(); + } + }, + timeout: 45_000, + }, + { noSendLogs: !process.env.BRAINTRUST_API_KEY }, +); diff --git a/apps/leaf/tests/evals/mcp/billing/multi-year-schedule.eval.ts b/apps/leaf/tests/evals/mcp/billing/multi-year-schedule.eval.ts new file mode 100644 index 000000000..6305f5f59 --- /dev/null +++ b/apps/leaf/tests/evals/mcp/billing/multi-year-schedule.eval.ts @@ -0,0 +1,329 @@ +import { Eval } from "braintrust"; +import { withCustomers } from "../../fixtures/createSetup.js"; +import { orgSetups } from "../../fixtures/orgSetups.js"; +import { + createEvalContext, + createGenericMcpAgentDriver, +} from "../../harness/index.js"; +import { + type EvalExpected, + type EvalOutput, + expectedApiCalls, + expectedToolCalls, + finalTextIncludes, + noCreateScheduleBeforePreview, + noScheduleCalls, +} from "../../utils/scorers.js"; + +type EvalInput = { + approval?: string; + details?: string; + prompt: string; +}; + +type EvalMetadata = { + domain: "billing"; + scenario: "multi-year-sales-led-schedule"; + setup: string; + source: "customer-slack-scenario-mining"; +}; + +type EvalScoreArgs = { + expected?: EvalExpected; + output: EvalOutput; +}; + +const experimentName = "multi-year-schedule"; +const evalToday = new Date("2026-06-08T00:00:00.000Z"); +const addUtcYears = ({ + date, + years, +}: { + date: Date; + years: number; +}) => + new Date( + Date.UTC( + date.getUTCFullYear() + years, + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + date.getUTCMilliseconds(), + ), + ); +const phaseStart = (yearsFromToday: number) => + addUtcYears({ date: evalToday, years: yearsFromToday }).getTime(); + +const setup = withCustomers({ + setup: orgSetups.knowledgePlatform(), + customers: ({ customers, plans, subscriptions }) => ({ + account: customers.active({ + email: "finance@northwind.example", + id: "cus_sales_led_schedule", + name: "Northwind Labs", + subscriptions: [ + subscriptions.active({ + currentPeriodEnd: addUtcYears({ date: evalToday, years: 1 }), + currentPeriodStart: evalToday, + id: "sub_sales_led_enterprise", + plan: plans.enterprise, + }), + ], + }), + }), +}); +const customer = setup.refs.customers.account; +const enterprisePlan = setup.refs.plans.enterprise; + +const expectedPhases = [ + { + plans: [ + { + customize: { + price: { amount: 100_000, interval: "year" }, + }, + plan_id: enterprisePlan.id, + }, + ], + starts_at: phaseStart(0), + }, + { + plans: [ + { + customize: { + price: { amount: 125_000, interval: "year" }, + }, + plan_id: enterprisePlan.id, + }, + ], + starts_at: phaseStart(1), + }, + { + plans: [ + { + customize: { + price: { amount: 150_000, interval: "year" }, + }, + plan_id: enterprisePlan.id, + }, + ], + starts_at: phaseStart(2), + }, +]; + +const usesOnlyPriceOverrides = ({ output }: { output: EvalOutput }) => { + const scheduleCalls = output.apiCalls.filter( + (call) => + call.toolName === "previewCreateSchedule" || + call.toolName === "createSchedule", + ); + if (!scheduleCalls.length) return 0; + + return scheduleCalls.every((call) => + Array.isArray(call.body.phases) + ? call.body.phases.every((phase) => { + const phaseRecord = phase as Record; + return Array.isArray(phaseRecord.plans) + ? phaseRecord.plans.every((plan) => { + const planRecord = plan as Record; + const customize = planRecord.customize as + | Record + | undefined; + return ( + planRecord.feature_quantities === undefined && + customize?.items === undefined && + customize?.price !== undefined + ); + }) + : false; + }) + : false, + ) + ? 1 + : 0; +}; + +Eval( + "leaf", + { + experimentName, + data: [ + { + expected: { + apiCalls: [ + { toolName: "listCustomers" }, + { toolName: "listPlans" }, + { toolName: "listFeatures" }, + { + body: { customer_id: customer.id }, + toolName: "getCustomer", + }, + { + body: { + customer_id: customer.id, + phases: expectedPhases, + redirect_mode: "if_required", + }, + toolName: "previewCreateSchedule", + }, + { + body: { + customer_id: customer.id, + phases: expectedPhases, + redirect_mode: "if_required", + }, + toolName: "createSchedule", + }, + ], + finalTextIncludes: [ + "Northwind Labs", + "Enterprise", + "2026", + "2027", + "2028", + "credits", + "unchanged", + ], + toolCalls: [ + "listCustomers", + "listPlans", + "listFeatures", + "getCustomer", + "previewCreateSchedule", + "createSchedule", + ], + }, + input: { + approval: "Looks good, create the schedule.", + details: [ + "Use customer Northwind Labs, customer id cus_sales_led_schedule.", + "Use the Enterprise plan at customer level.", + "Contract starts today, June 8, 2026.", + "Year 1 is $100,000/year starting June 8, 2026.", + "Year 2 is $125,000/year starting one year from today, June 8, 2027.", + "Year 3 is $150,000/year starting two years from today, June 8, 2028.", + "Credits and feature access stay unchanged in every year.", + "Do not send an invoice or checkout now; preview the schedule first.", + ].join(" "), + prompt: + "Northwind Labs has a three-year sales-led Enterprise schedule. Please provision it in Autumn; the annual price changes each year, but credits do not change.", + }, + metadata: { + domain: "billing", + scenario: "multi-year-sales-led-schedule", + setup: setup.tag, + source: "customer-slack-scenario-mining", + }, + }, + { + expected: { + finalTextIncludes: ["Northwind Labs", "now", "past"], + }, + input: { + prompt: [ + "Please provision a three-year Enterprise schedule for Northwind Labs, customer id cus_sales_led_schedule.", + "Year 1 is $100,000/year, year 2 is $125,000/year, and year 3 is $150,000/year.", + "Credits and feature access stay unchanged in every year.", + ].join(" "), + }, + metadata: { + domain: "billing", + scenario: "multi-year-sales-led-schedule", + setup: setup.tag, + source: "customer-slack-scenario-mining", + }, + }, + ], + scores: [ + (args: EvalScoreArgs) => ({ + name: "Expected tool calls", + score: expectedToolCalls(args), + }), + (args: EvalScoreArgs) => ({ + name: "Expected API calls", + score: expectedApiCalls(args), + }), + (args: EvalScoreArgs) => ({ + name: "Final text includes", + score: finalTextIncludes(args), + }), + (args: EvalScoreArgs) => ({ + name: "Preview before create schedule", + score: noCreateScheduleBeforePreview(args), + }), + (args: EvalScoreArgs) => ({ + name: "Only price overrides", + score: + args.expected?.apiCalls?.some( + (call) => call.toolName === "previewCreateSchedule", + ) || + args.expected?.apiCalls?.some( + (call) => call.toolName === "createSchedule", + ) + ? usesOnlyPriceOverrides(args) + : 1, + }), + (args: EvalScoreArgs) => ({ + name: "No schedule calls without start clarification", + score: + args.expected?.apiCalls || args.expected?.toolCalls + ? 1 + : noScheduleCalls(args), + }), + ], + task: async (input: EvalInput) => { + const context = await createEvalContext({ + autumnApiOverrides: { + createSchedule: ({ body }) => ({ + customer_id: body.customer_id, + entity_id: null, + invoice: null, + payment_url: null, + phases: expectedPhases.map((phase, index) => ({ + customer_product_ids: [`cp_schedule_${index + 1}`], + phase_id: `phase_schedule_${index + 1}`, + starts_at: phase.starts_at, + })), + schedule_id: "sched_sales_led_multiyear", + status: "created", + }), + previewCreateSchedule: ({ body }) => ({ + currency: "usd", + customer_id: body.customer_id, + line_items: expectedPhases.map((phase, index) => ({ + description: `Enterprise year ${index + 1}`, + starts_at: phase.starts_at, + total: phase.plans[0].customize.price.amount, + })), + total: 375_000, + }), + }, + driver: createGenericMcpAgentDriver(), + name: experimentName, + setup, + today: evalToday, + }); + try { + const turns = [ + { message: input.prompt, type: "user" as const }, + ...(input.details + ? [{ message: input.details, type: "user" as const }] + : []), + ...(input.approval + ? [ + { message: input.approval, type: "user" as const }, + { optional: true, type: "approve" as const }, + ] + : []), + ]; + return await context.runConversation(turns); + } finally { + await context.cleanup(); + } + }, + timeout: 60_000, + }, + { noSendLogs: !process.env.BRAINTRUST_API_KEY }, +); diff --git a/apps/leaf/tests/evals/utils/scorers.ts b/apps/leaf/tests/evals/utils/scorers.ts new file mode 100644 index 000000000..23468de7c --- /dev/null +++ b/apps/leaf/tests/evals/utils/scorers.ts @@ -0,0 +1,225 @@ +import type { AutumnApiCall } from "../harness/context/types.js"; +import type { + EvalExpected, + EvalExpectation, + ExpectedApiCall, + LegacyEvalExpected, +} from "../fixtures/expectations/types.js"; + +export type { + EvalExpected, + EvalExpectation, + ExpectedApiCall, + LegacyEvalExpected, +} from "../fixtures/expectations/types.js"; + +export type EvalOutput = { + apiCalls: AutumnApiCall[]; + finalText: string; + toolCalls: Array<{ name: string; args: Record }>; +}; + +export type EvalScoreArgs = { + expected?: EvalExpected; + output: EvalOutput; +}; + +export type EvalScorer = (args: EvalScoreArgs) => { + name: string; + score: number; +}; + +const includesObject = ( + actual: Record, + expected: Record, +) => + Object.entries(expected).every(([key, value]) => + typeof value === "object" && value !== null + ? JSON.stringify(actual[key]) === JSON.stringify(value) + : actual[key] === value, + ); + +const isExpectationList = ( + expected?: EvalExpected, +): expected is EvalExpectation[] => Array.isArray(expected); + +const getLegacyExpected = ( + expected?: EvalExpected, +): LegacyEvalExpected | undefined => + isExpectationList(expected) ? undefined : expected; + +const getExpectationList = (expected?: EvalExpected): EvalExpectation[] => + isExpectationList(expected) ? expected : []; + +const getExpectedToolNames = (expected?: EvalExpected) => [ + ...(getLegacyExpected(expected)?.toolCalls ?? []), + ...getExpectationList(expected).flatMap((expectation) => + expectation.type === "tools.called" ? expectation.toolNames : [], + ), +]; + +const getExpectedApiCalls = (expected?: EvalExpected) => [ + ...(getLegacyExpected(expected)?.apiCalls ?? []), + ...getExpectationList(expected).flatMap((expectation) => + expectation.type === "api.called" || + expectation.type === "api.calledInOrder" + ? expectation.calls + : [], + ), +]; + +const getExpectedApiCallOrder = (expected?: EvalExpected) => + getExpectationList(expected).flatMap((expectation) => + expectation.type === "api.calledInOrder" ? [expectation.calls] : [], + ); + +const getExpectedResponsePhrases = (expected?: EvalExpected) => [ + ...(getLegacyExpected(expected)?.finalTextIncludes ?? []), + ...getExpectationList(expected).flatMap((expectation) => + expectation.type === "response.mentions" ? expectation.phrases : [], + ), +]; + +const matchesApiCall = ({ + actual, + expected, +}: { + actual: AutumnApiCall; + expected: ExpectedApiCall; +}) => + actual.toolName === expected.toolName && + (!expected.body || includesObject(actual.body, expected.body)); + +export const expectedApiCalls = ({ + expected, + output, +}: EvalScoreArgs) => { + const expectedCalls = getExpectedApiCalls(expected); + if (!expectedCalls.length) return 1; + return expectedCalls.every((expectedCall) => + output.apiCalls.some((call) => + matchesApiCall({ actual: call, expected: expectedCall }), + ), + ) + ? 1 + : 0; +}; + +export const expectedApiCallsInOrder = ({ + expected, + output, +}: EvalScoreArgs) => { + const expectedCallGroups = getExpectedApiCallOrder(expected); + if (!expectedCallGroups.length) return 1; + + return expectedCallGroups.every((expectedCalls) => { + let startIndex = 0; + for (const expectedCall of expectedCalls) { + const foundIndex = output.apiCalls.findIndex( + (call, index) => + index >= startIndex && + matchesApiCall({ actual: call, expected: expectedCall }), + ); + if (foundIndex === -1) return false; + startIndex = foundIndex + 1; + } + return true; + }) + ? 1 + : 0; +}; + +export const expectedToolCalls = ({ + expected, + output, +}: EvalScoreArgs) => { + const expectedTools = getExpectedToolNames(expected); + if (!expectedTools.length) return 1; + return expectedTools.every((toolName) => + output.toolCalls.some((call) => call.name === toolName), + ) + ? 1 + : 0; +}; + +export const finalTextIncludes = ({ + expected, + output, +}: EvalScoreArgs) => { + const phrases = getExpectedResponsePhrases(expected); + if (!phrases.length) return 1; + const text = output.finalText.toLowerCase(); + return phrases.every((phrase) => text.includes(phrase.toLowerCase())) ? 1 : 0; +}; + +export const noAttachBeforePreview = ({ output }: { output: EvalOutput }) => { + const attachIndex = output.apiCalls.findIndex( + (call) => call.toolName === "attach", + ); + const previewIndex = output.apiCalls.findIndex( + (call) => call.toolName === "previewAttach", + ); + return attachIndex === -1 || + (previewIndex !== -1 && previewIndex < attachIndex) + ? 1 + : 0; +}; + +export const noCreateScheduleBeforePreview = ({ + output, +}: { + output: EvalOutput; +}) => { + const createIndex = output.apiCalls.findIndex( + (call) => call.toolName === "createSchedule", + ); + const previewIndex = output.apiCalls.findIndex( + (call) => call.toolName === "previewCreateSchedule", + ); + return createIndex === -1 || + (previewIndex !== -1 && previewIndex < createIndex) + ? 1 + : 0; +}; + +export const noScheduleCalls = ({ output }: { output: EvalOutput }) => + output.apiCalls.every( + (call) => + call.toolName !== "previewCreateSchedule" && + call.toolName !== "createSchedule", + ) && + output.toolCalls.every( + (call) => + call.name !== "previewCreateSchedule" && call.name !== "createSchedule", + ) + ? 1 + : 0; + +export const standardEvalScores = (): EvalScorer[] => [ + (args) => ({ + name: "Expected tool calls", + score: expectedToolCalls(args), + }), + (args) => ({ + name: "Expected API calls", + score: expectedApiCalls(args), + }), + (args) => ({ + name: "Expected API call order", + score: expectedApiCallsInOrder(args), + }), + (args) => ({ + name: "Final text includes", + score: finalTextIncludes(args), + }), +]; + +export const billingAttachScores = (): EvalScorer[] => standardEvalScores(); + +export const billingScheduleScores = (): EvalScorer[] => [ + ...standardEvalScores(), + (args) => ({ + name: "Preview before create schedule", + score: noCreateScheduleBeforePreview(args), + }), +]; diff --git a/apps/leaf/tests/unit/agent/agent.test.ts b/apps/leaf/tests/unit/agent/agent.test.ts new file mode 100644 index 000000000..2666b6118 --- /dev/null +++ b/apps/leaf/tests/unit/agent/agent.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; + +process.env.DATABASE_URL ??= + "postgresql://postgres:postgres@localhost:5432/postgres"; +process.env.ENCRYPTION_PASSWORD ??= "test"; +process.env.SLACK_CLIENT_ID ??= "test"; +process.env.SLACK_CLIENT_SECRET ??= "test"; +process.env.SLACK_SIGNING_SECRET ??= "test"; +process.env.FIRECRAWL_API_KEY ??= "fc_test"; + +const { agentDocUris, getDefaultChatEnv, selectChatEnv } = await import( + "../../../src/agent/agent.js" +); +const { autumnChatInstructions } = await import( + "../../../src/agent/chatAgent.js" +); +const { createFirecrawlTools } = await import( + "../../../src/agent/firecrawl.js" +); + +const execute = async ( + tool: { execute?: (...args: never[]) => Promise } | undefined, + input: unknown, +) => { + if (!tool?.execute) throw new Error("Tool is not executable"); + return tool.execute(input as never, {} as never); +}; + +const originalNodeEnv = process.env.NODE_ENV; + +afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } +}); + +describe("chat environment selection", () => { + test("loads feature catalog MCP guidance", () => { + expect(agentDocUris).toContain("autumn://docs/feature-catalog"); + }); + + test("loads request-log MCP guidance", () => { + expect(agentDocUris).toContain("autumn://docs/request-logs"); + expect(agentDocUris).toContain("autumn://docs/request-log-customers"); + expect(agentDocUris).toContain("autumn://docs/request-log-balances"); + expect(agentDocUris).toContain("autumn://docs/request-log-billing"); + expect(agentDocUris).toContain("autumn://docs/request-log-stripe-webhooks"); + expect(agentDocUris).toContain("autumn://docs/request-log-analytics"); + }); + + test("instructs the agent to read org rules before Autumn work", () => { + expect(autumnChatInstructions).toContain("getAgentRules"); + expect(autumnChatInstructions).toContain("org-specific behavior"); + }); + + test("defaults to sandbox outside production", () => { + delete process.env.NODE_ENV; + expect(getDefaultChatEnv()).toBe(AppEnv.Sandbox); + + process.env.NODE_ENV = "development"; + expect(getDefaultChatEnv()).toBe(AppEnv.Sandbox); + }); + + test("defaults to live in production", () => { + process.env.NODE_ENV = "production"; + expect(getDefaultChatEnv()).toBe(AppEnv.Live); + }); + + test("uses live from structured model output", async () => { + await expect( + selectChatEnv({ + message: "list customers", + select: () => ({ env: AppEnv.Live }), + }), + ).resolves.toBe(AppEnv.Live); + }); + + test("uses sandbox from structured model output", async () => { + await expect( + selectChatEnv({ + message: "try this in the sandbox first", + select: () => ({ env: AppEnv.Sandbox }), + }), + ).resolves.toBe(AppEnv.Sandbox); + }); + + test("rejects malformed model output", async () => { + await expect( + selectChatEnv({ + message: "test mode", + select: () => ({ env: "test" }), + }), + ).rejects.toThrow(); + }); +}); + +describe("Firecrawl tools", () => { + test("registers search and scrape tools", () => { + const tools = createFirecrawlTools({ + apiKey: "fc_test", + client: { + search: async () => ({ web: [] }), + scrape: async () => ({}), + }, + }); + + expect(Object.keys(tools).sort()).toEqual(["scrapeUrl", "searchWeb"]); + }); + + test("maps search results into compact output", async () => { + const tools = createFirecrawlTools({ + apiKey: "fc_test", + client: { + search: async (query, options) => { + expect(query).toBe("autumn billing docs"); + expect(options).toEqual({ limit: 2, sources: ["web"] }); + return { + web: [ + { + title: "Autumn Docs", + url: "https://docs.useautumn.com", + description: "Billing docs", + }, + ], + }; + }, + scrape: async () => ({}), + }, + }); + + await expect( + execute(tools.searchWeb, { query: "autumn billing docs", limit: 2 }), + ).resolves.toEqual({ + results: [ + { + title: "Autumn Docs", + url: "https://docs.useautumn.com", + description: "Billing docs", + }, + ], + }); + }); + + test("scrapes one URL and bounds returned markdown", async () => { + const tools = createFirecrawlTools({ + apiKey: "fc_test", + client: { + search: async () => ({ web: [] }), + scrape: async (url, options) => { + expect(url).toBe("https://example.com"); + expect(options).toEqual({ formats: ["markdown"] }); + return { + markdown: `${"a".repeat(13_000)}\n\n\nextra`, + metadata: { + title: "Example", + sourceURL: "https://example.com", + }, + }; + }, + }, + }); + + const result = await execute(tools.scrapeUrl, { + url: "https://example.com", + }); + + expect(result).toMatchObject({ + title: "Example", + url: "https://example.com", + }); + expect((result as { markdown: string }).markdown.length).toBe(12_000); + }); +}); diff --git a/apps/leaf/tests/unit/agent/attachments.test.ts b/apps/leaf/tests/unit/agent/attachments.test.ts new file mode 100644 index 000000000..b8ec9c202 --- /dev/null +++ b/apps/leaf/tests/unit/agent/attachments.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import type { Attachment } from "chat"; +import { prepareAttachmentMessage } from "../../../src/agent/attachments.js"; + +const getUserContent = async ( + params: Parameters[0], +) => { + const prepared = await prepareAttachmentMessage(params); + const [message] = prepared.message as Array<{ + content: Array>; + role: string; + }>; + return { ...prepared, content: message.content }; +}; + +describe("Slack attachment message preparation", () => { + test("adds PDFs as file parts", async () => { + const attachment = { + data: Buffer.from("pdf"), + mimeType: "application/pdf", + name: "contract.pdf", + size: 3, + type: "file", + } satisfies Attachment; + + const { attachmentCount, content } = await getUserContent({ + attachments: [attachment], + text: "please provision this", + }); + + expect(attachmentCount).toBe(1); + expect(content[0]).toMatchObject({ + filename: "contract.pdf", + mediaType: "application/pdf", + type: "file", + }); + expect(content[1]).toMatchObject({ + text: "please provision this", + type: "text", + }); + }); + + test("adds images as file parts", async () => { + const attachment = { + fetchData: async () => Buffer.from("png"), + mimeType: "image/png", + name: "screenshot.png", + size: 3, + type: "image", + } satisfies Attachment; + + const { attachmentCount, content } = await getUserContent({ + attachments: [attachment], + text: "", + }); + + expect(attachmentCount).toBe(1); + expect(content[0]).toMatchObject({ + filename: "screenshot.png", + mediaType: "image/png", + type: "file", + }); + expect(content[1]).toMatchObject({ + text: "Please answer using the attached Slack file(s).", + type: "text", + }); + }); + + test("uses fallback download when adapter fetchData is unavailable", async () => { + const attachment = { + mimeType: "application/pdf", + name: "contract.pdf", + size: 3, + type: "file", + } satisfies Attachment; + + const { attachmentCount } = await prepareAttachmentMessage({ + attachments: [attachment], + fetchFallback: async ({ attachment: fallbackAttachment }) => { + expect(fallbackAttachment.name).toBe("contract.pdf"); + return Buffer.from("pdf"); + }, + text: "read this", + }); + + expect(attachmentCount).toBe(1); + }); + + test("skips unsupported and oversized attachments with notes", async () => { + const attachments = [ + { + mimeType: "application/zip", + name: "archive.zip", + size: 1, + type: "file", + }, + { + mimeType: "application/pdf", + name: "huge.pdf", + size: 21 * 1024 * 1024, + type: "file", + }, + ] satisfies Attachment[]; + + const { attachmentCount, notes } = await prepareAttachmentMessage({ + attachments, + text: "read these", + }); + + expect(attachmentCount).toBe(0); + expect(notes).toEqual([ + "Skipped archive.zip: unsupported file type.", + "Skipped huge.pdf: file is too large.", + ]); + }); +}); diff --git a/apps/leaf/tests/unit/agent/sandbox/createSandboxTools.test.ts b/apps/leaf/tests/unit/agent/sandbox/createSandboxTools.test.ts new file mode 100644 index 000000000..e9285b587 --- /dev/null +++ b/apps/leaf/tests/unit/agent/sandbox/createSandboxTools.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { createSandboxTools } from "../../../../src/agent/sandbox/createSandboxTools.js"; +import type { SandboxProvider } from "../../../../src/agent/sandbox/types.js"; + +const execute = async ( + tool: { execute?: (...args: never[]) => Promise } | undefined, + input: unknown, +) => { + if (!tool?.execute) throw new Error("Tool is not executable"); + return tool.execute(input as never, {} as never); +}; + +describe("sandbox tools", () => { + test("calls provider with sanitized files and return paths", async () => { + const provider: SandboxProvider = { + run: async (args) => { + expect(args.command).toBe("python analyze.py"); + expect(args.files).toEqual([ + { path: "/work/input.json", content: '{"ok":true}' }, + ]); + expect(args.returnFiles).toEqual(["/work/result.json"]); + expect(args.timeoutMs).toBe(20_000); + return { + stdout: "done", + stderr: "", + exitCode: 0, + timedOut: false, + files: [{ path: "/work/result.json", content: '{"done":true}' }], + }; + }, + }; + const tools = createSandboxTools({ provider }); + + await expect( + execute(tools.runSandboxCommand, { + task: "analyze json", + command: "python analyze.py", + files: [{ path: "input.json", content: '{"ok":true}' }], + returnFiles: ["result.json"], + }), + ).resolves.toEqual({ + stdout: "done", + stderr: "", + exitCode: 0, + timedOut: false, + files: [{ path: "/work/result.json", content: '{"done":true}' }], + }); + }); + + test("rejects unsafe input before calling provider", async () => { + let called = false; + const provider: SandboxProvider = { + run: async () => { + called = true; + throw new Error("should not run"); + }, + }; + const tools = createSandboxTools({ provider }); + + await expect( + execute(tools.runSandboxCommand, { + task: "leak", + command: "echo ok", + files: [{ path: ".env", content: "API_KEY=secret" }], + }), + ).rejects.toThrow(); + expect(called).toBe(false); + }); + + test("truncates provider output", async () => { + const provider: SandboxProvider = { + run: async () => ({ + stdout: "a".repeat(25 * 1024), + stderr: "", + timedOut: false, + files: [{ path: "/work/out.txt", content: "b".repeat(25 * 1024) }], + }), + }; + const tools = createSandboxTools({ provider }); + const result = (await execute(tools.runSandboxCommand, { + task: "large", + command: "cat out.txt", + })) as { stdout: string; files: Array<{ content: string }> }; + + expect(result.stdout).toEndWith("[truncated]"); + expect(result.files[0]?.content).toEndWith("[truncated]"); + }); +}); diff --git a/apps/leaf/tests/unit/agent/sandbox/guardrails.test.ts b/apps/leaf/tests/unit/agent/sandbox/guardrails.test.ts new file mode 100644 index 000000000..372d3fbf0 --- /dev/null +++ b/apps/leaf/tests/unit/agent/sandbox/guardrails.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { + assertSafeSandboxCommand, + sanitizeReturnFiles, + sanitizeSandboxFiles, + truncateText, +} from "../../../../src/agent/sandbox/guardrails.js"; + +describe("sandbox guardrails", () => { + test("normalizes relative file paths under /work", () => { + expect( + sanitizeSandboxFiles([{ path: "input.json", content: "{}" }]), + ).toEqual([{ path: "/work/input.json", content: "{}" }]); + expect(sanitizeReturnFiles(["result.json"])).toEqual(["/work/result.json"]); + }); + + test("rejects files outside /work", () => { + expect(() => + sanitizeSandboxFiles([{ path: "../secrets.txt", content: "x" }]), + ).toThrow("Sandbox files must stay under /work"); + expect(() => sanitizeReturnFiles(["/etc/passwd"])).toThrow( + "Sandbox files must stay under /work", + ); + }); + + test("rejects .env files", () => { + expect(() => + sanitizeSandboxFiles([{ path: ".env", content: "FOO=bar" }]), + ).toThrow("Sandbox files cannot be named .env"); + }); + + test("rejects token-like input", () => { + expect(() => + sanitizeSandboxFiles([ + { path: "input.txt", content: "SLACK_BOT_TOKEN=xoxb-1234567890" }, + ]), + ).toThrow("Sandbox input appears to contain a secret or token"); + expect(() => + assertSafeSandboxCommand("echo Bearer abcdefghijklmnopqrstuvwxyz"), + ).toThrow("Sandbox input appears to contain a secret or token"); + }); + + test("enforces file count and total bytes", () => { + expect(() => + sanitizeSandboxFiles( + Array.from({ length: 6 }, (_, index) => ({ + path: `file-${index}.txt`, + content: "x", + })), + ), + ).toThrow("Sandbox input cannot exceed 5 files"); + expect(() => + sanitizeSandboxFiles([ + { path: "large.txt", content: "x".repeat(257 * 1024) }, + ]), + ).toThrow("Sandbox input is too large"); + }); + + test("truncates text by bytes", () => { + const result = truncateText("a".repeat(10), 5); + expect(result).toBe("aaaaa\n[truncated]"); + }); +}); diff --git a/apps/leaf/tests/unit/approvals/flow.test.ts b/apps/leaf/tests/unit/approvals/flow.test.ts new file mode 100644 index 000000000..1f4aa34c9 --- /dev/null +++ b/apps/leaf/tests/unit/approvals/flow.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv, type ChatApproval } from "@autumn/shared"; +import type { ActionEvent } from "chat"; +import { approvalErrorResult } from "../../../src/approvals/errors.js"; +import { approvalRequestFromOutput } from "../../../src/approvals/request.js"; +import type { AgentOutput } from "../../../src/types.js"; + +const setLeafTestEnv = () => { + process.env.DATABASE_URL ??= "postgres://postgres:postgres@localhost:5432/db"; + process.env.ENCRYPTION_PASSWORD ??= "test-password"; + process.env.FIRECRAWL_API_KEY ??= "test-firecrawl-key"; + process.env.SLACK_CLIENT_ID ??= "test-slack-client-id"; + process.env.SLACK_CLIENT_SECRET ??= "test-slack-client-secret"; + process.env.SLACK_SIGNING_SECRET ??= "test-slack-signing-secret"; +}; + +describe("approval flow", () => { + test("maps suspended destructive tool output to a pending approval request", () => { + const request = approvalRequestFromOutput({ + env: AppEnv.Sandbox, + finishReason: "suspended", + runId: "run_1", + suspendPayload: { + toolCallId: "call_1", + toolName: "attach", + args: { request: { customer_id: "cus_1", plan_id: "pro" } }, + }, + text: "Preview ready.", + } satisfies AgentOutput); + + expect(request).toEqual({ + env: AppEnv.Sandbox, + runId: "run_1", + toolCallId: "call_1", + toolName: "attach", + toolArgs: { request: { customer_id: "cus_1", plan_id: "pro" } }, + preview: "Preview ready.", + }); + }); + + test("maps preview output to the matching write approval request", () => { + const request = approvalRequestFromOutput({ + env: AppEnv.Live, + previewApproval: { + toolName: "updateSubscription", + toolArgs: { request: { customer_id: "cus_1", plan_id: "pro" } }, + preview: { total: 100 }, + }, + } satisfies AgentOutput); + + expect(request).toEqual({ + env: AppEnv.Live, + toolName: "updateSubscription", + toolArgs: { request: { customer_id: "cus_1", plan_id: "pro" } }, + preview: { total: 100 }, + }); + }); + + test("formats Autumn API errors for Slack approval cards", () => { + const result = approvalErrorResult( + new Error( + 'Autumn API request failed (400): {"message":"(Stripe Error) Missing email. In order to create invoices that are sent to the customer, the customer must have a valid email.","code":"stripe_error","env":"sandbox"}', + ), + ); + + expect(result).toEqual({ + error: true, + message: + "(Stripe Error) Missing email. In order to create invoices that are sent to the customer, the customer must have a valid email.", + }); + }); + + test("formats returned tool failure objects for Slack approval cards", () => { + const result = approvalErrorResult({ + id: "TOOL_EXECUTION_FAILED", + error: { + message: + 'Autumn API request failed (400): {"message":"Missing email.","code":"stripe_error"}', + }, + }); + + expect(result).toEqual({ + error: true, + message: "Missing email.", + }); + }); + + test("formats MCP isError responses for Slack approval cards", () => { + const result = approvalErrorResult({ + isError: true, + content: [ + { + type: "text", + text: JSON.stringify({ + id: "TOOL_EXECUTION_FAILED", + details: { + errorMessage: + 'Error: Autumn API request failed (404): {"message":"Feature definitely_missing_feature_123 not found","code":"feature_not_found","env":"sandbox"}', + }, + }), + }, + ], + }); + + expect(result).toEqual({ + error: true, + message: "Feature definitely_missing_feature_123 not found", + }); + }); + + test("detects MCP isError responses as failed tool results", async () => { + setLeafTestEnv(); + const { isErrorResult } = await import("../../../src/approvals/store.js"); + + expect( + isErrorResult({ + isError: true, + content: [{ type: "text", text: "Tool failed" }], + }), + ).toBe(true); + }); + + test("edits the approval message to failed when the approved tool fails", async () => { + setLeafTestEnv(); + const { handleApprovalActionWithDeps } = await import( + "../../../src/approvals/flow.js" + ); + const edits: unknown[] = []; + const approval = { + env: AppEnv.Sandbox, + status: "pending", + tool_name: "attach", + tool_args: { + request: { + customer_id: "cus_1", + plan_id: "pro", + }, + }, + } as unknown as ChatApproval; + const event = { + actionId: "approve_billing_action", + messageId: "message_1", + threadId: "thread_1", + user: { userId: "U1" }, + value: "approval_1", + } as unknown as ActionEvent; + + await handleApprovalActionWithDeps(event, { + approveAndRun: async () => ({ + error: true, + message: "Missing email.", + }), + cancelApproval: async () => approval, + editActionMessage: async (_event, content) => { + edits.push(content); + }, + getApproval: async () => approval, + logger: { + error: () => {}, + info: () => {}, + warn: () => {}, + }, + }); + + expect(edits).toHaveLength(2); + expect(JSON.stringify(edits[0])).toContain("Applying the approved action"); + expect(JSON.stringify(edits[1])).toContain("Attach plan failed"); + expect(JSON.stringify(edits[1])).toContain("Missing email."); + }); +}); diff --git a/apps/leaf/tests/unit/evals/mock-autumn-server.test.ts b/apps/leaf/tests/unit/evals/mock-autumn-server.test.ts new file mode 100644 index 000000000..bfeb015df --- /dev/null +++ b/apps/leaf/tests/unit/evals/mock-autumn-server.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, test } from "bun:test"; +import { BillingMethod } from "@api/products/components/billingMethod.js"; +import { FeatureType } from "@models/featureModels/featureEnums.js"; +import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js"; +import { + createSetup, + withCustomers, +} from "../../evals/fixtures/createSetup.js"; +import { orgSetups } from "../../evals/fixtures/orgSetups.js"; +import { createAutumnApiMock } from "../../evals/harness/index.js"; +import { + expectedApiCalls, + expectedToolCalls, +} from "../../evals/utils/scorers.js"; + +const createCustomerPlanSetup = () => + createSetup({ + tag: "joe-customized-pro-plan", + features: ({ features }) => ({ + credits: features.creditSystem(), + dashboard: features.boolean(), + }), + plans: ({ basePrice, features, items, plan }) => ({ + pro: plan.monthly({ + basePrice: basePrice.monthly({ amount: 79 }), + items: [ + items.included({ feature: features.credits, included: 25_000 }), + items.boolean({ feature: features.dashboard }), + ], + planId: "pro", + }), + }), + customers: ({ customers, plans, subscriptions }) => ({ + joe: customers.active({ + id: "joe_customer", + name: "Joe", + subscriptions: [subscriptions.active({ plan: plans.pro })], + }), + }), + }); + +describe("eval mock Autumn server", () => { + test("composes boolean feature lists into setup refs", () => { + const setup = createSetup({ + tag: "boolean-feature-list", + features: ({ featureList }) => ({ + ...featureList.boolean({ + featureIds: ["sso", "audit_logs"], + names: { sso: "SSO" }, + }), + }), + plans: ({ features, items, plan }) => ({ + pro: plan.monthly({ + items: [items.boolean({ feature: features.sso })], + planId: "pro", + }), + }), + customers: () => ({}), + }); + + expect(setup.refs.features.sso).toMatchObject({ + id: "sso", + name: "SSO", + type: FeatureType.Boolean, + }); + expect(setup.ids.features.sso).toBe("sso"); + expect(setup.refs.features.audit_logs).toMatchObject({ + id: "audit_logs", + name: "Audit Logs", + type: FeatureType.Boolean, + }); + expect(setup.plans[0]?.items[0]?.feature_id).toBe("sso"); + }); + + test("composes anonymized knowledge platform org setup", () => { + const setup = orgSetups.knowledgePlatform(); + const enterprise = setup.refs.plans.enterprise; + const automationPack = setup.plans.find( + (plan) => plan.id === setup.ids.plans.automationPack, + ); + if (Array.isArray(enterprise) || !automationPack) { + throw new Error("Expected single plan refs."); + } + + const creditItems = enterprise.items.filter( + (item) => item.feature_id === "credits", + ); + const featureIds = setup.features.map((feature) => feature.id); + + expect(setup.refs.features.credits).toMatchObject({ + type: FeatureType.CreditSystem, + }); + expect(enterprise.price).toBeNull(); + expect(setup.ids.features.insight_reports).toBe("insight_reports"); + expect(setup.ids.plans.enterprise).toBe("enterprise"); + expect(creditItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + price: expect.objectContaining({ + billing_method: BillingMethod.Prepaid, + tier_behavior: TierBehavior.VolumeBased, + }), + }), + expect.objectContaining({ + price: expect.objectContaining({ + billing_method: BillingMethod.UsageBased, + }), + }), + ]), + ); + expect(featureIds.filter((id) => id !== "credits").length).toBeGreaterThan( + 10, + ); + expect(automationPack).toMatchObject({ + add_on: true, + items: [{ feature_id: "automation_rules" }], + }); + expect(featureIds).not.toContain("AI_CHAT"); + expect(featureIds).not.toContain("AI_CREDITS"); + }); + + test("extends reusable org setup with typed eval customers", () => { + const setup = withCustomers({ + setup: orgSetups.knowledgePlatform(), + customers: ({ customers, plans, subscriptions }) => ({ + joe: customers.active({ + id: "joe_customer", + subscriptions: [subscriptions.active({ plan: plans.scale })], + }), + }), + }); + + expect(setup.ids.customers.joe).toBe("joe_customer"); + expect(setup.refs.customers.joe.subscriptions[0]?.plan_id).toBe( + setup.ids.plans.scale, + ); + }); + + test("creates customized plan variants for customer subscriptions", () => { + const setup = createSetup({ + tag: "custom-plan-version", + features: ({ features }) => ({ + audit_logs: features.boolean({ featureId: "audit_logs" }), + credits: features.creditSystem(), + dashboard: features.boolean(), + }), + plans: ({ basePrice, features, items, plan }) => { + const pro = plan.monthly({ + basePrice: basePrice.monthly({ amount: 79 }), + items: [ + items.included({ feature: features.credits, included: 25_000 }), + items.boolean({ feature: features.dashboard }), + ], + planId: "pro", + }); + + return { + pro, + proCustom: plan.customized({ + customize: { + add_items: [items.boolean({ feature: features.audit_logs })], + price: basePrice.monthly({ amount: 99 }), + remove_items: [{ feature_id: features.dashboard.id }], + }, + plan: pro, + planId: "pro_custom", + }), + }; + }, + customers: ({ customers, plans, subscriptions }) => ({ + joe: customers.active({ + id: "joe_customer", + subscriptions: [subscriptions.active({ plan: plans.proCustom })], + }), + }), + }); + + const subscription = setup.refs.customers.joe.subscriptions[0]; + expect(subscription?.plan_id).toBe("pro_custom"); + expect(subscription?.plan).toMatchObject({ + base_variant_id: "pro", + id: "pro_custom", + price: { amount: 9_900 }, + }); + expect(subscription?.plan?.items.map((item) => item.feature_id)).toEqual([ + "credits", + "audit_logs", + ]); + }); + + test("requires customized plan replacements to remove original items", () => { + const setup = createSetup({ + tag: "custom-plan-duplicate-item", + features: ({ features }) => ({ + credits: features.creditSystem(), + }), + plans: ({ features, items, plan }) => { + const pro = plan.monthly({ + items: [items.included({ feature: features.credits, included: 100 })], + planId: "pro", + }); + + expect(() => + plan.customized({ + customize: { + add_items: [ + items.included({ feature: features.credits, included: 1_000 }), + ], + }, + plan: pro, + }), + ).toThrow("duplicate item"); + + return { pro }; + }, + customers: () => ({}), + }); + + expect(setup.ids.plans.pro).toBe("pro"); + }); + + test("composes customer schedule refs alongside scheduled subscriptions", () => { + const setup = createSetup({ + tag: "customer-schedule", + features: ({ features }) => ({ + credits: features.creditSystem(), + }), + plans: ({ features, items, plan }) => ({ + yearOne: plan.annual({ + items: [items.included({ feature: features.credits, included: 5_000 })], + planId: "year_one", + }), + yearTwo: plan.annual({ + items: [ + items.included({ feature: features.credits, included: 10_000 }), + ], + planId: "year_two", + }), + }), + customers: ({ customers, plans, subscriptions }) => ({ + joe: customers.active({ + id: "joe_customer", + subscriptions: [ + subscriptions.active({ plan: plans.yearOne }), + subscriptions.scheduled({ + plan: plans.yearTwo, + startedAt: new Date("2027-01-01T00:00:00.000Z"), + }), + ], + }), + }), + schedules: ({ customers, schedules }) => ({ + joeContract: schedules.customer({ + customer: customers.joe, + id: "sched_joe_contract", + phases: [ + { + customerProductIds: ["cus_prod_year_one"], + startsAt: new Date("2026-01-01T00:00:00.000Z"), + }, + { + customerProductIds: ["cus_prod_year_two"], + startsAt: new Date("2027-01-01T00:00:00.000Z"), + }, + ], + }), + }), + }); + + expect(setup.ids.schedules.joeContract).toBe("sched_joe_contract"); + expect(setup.schedules[0]?.phases.map((phase) => phase.starts_at)).toEqual([ + 1_767_225_600_000, + 1_798_761_600_000, + ]); + expect(setup.refs.customers.joe.subscriptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ plan_id: "year_two", status: "scheduled" }), + ]), + ); + }); + + test("generates customer search and get-or-create responses from setup state", async () => { + const setup = createCustomerPlanSetup(); + const server = createAutumnApiMock({ setup }); + const customer = setup.refs.customers.joe; + if (!customer) throw new Error("Eval setup is missing customer."); + + try { + const listed = await fetch(`${server.serverURL}/v1/customers.list`, { + body: JSON.stringify({ search: "Joe" }), + method: "POST", + }).then((response) => response.json()); + const fetched = await fetch( + `${server.serverURL}/v1/customers.get_or_create`, + { + body: JSON.stringify({ customer_id: customer.id }), + method: "POST", + }, + ).then((response) => response.json()); + + expect(listed).toMatchObject({ + list: [{ id: customer.id, subscriptions: [{ plan_id: "pro" }] }], + total_filtered_count: 1, + }); + expect(fetched).toMatchObject({ + id: customer.id, + subscriptions: [{ plan: { id: "pro", name: "Pro" } }], + }); + expect(server.calls.map((call) => call.toolName)).toEqual([ + "listCustomers", + "getOrCreateCustomer", + ]); + } finally { + server.restore(); + } + }); + + test("creates a customer through get-or-create when missing", async () => { + const setup = createCustomerPlanSetup(); + const server = createAutumnApiMock({ setup }); + + try { + const created = await fetch( + `${server.serverURL}/v1/customers.get_or_create`, + { + body: JSON.stringify({ customer_id: "new_customer" }), + method: "POST", + }, + ).then((response) => response.json()); + + expect(created).toMatchObject({ id: "new_customer" }); + expect(setup.customers.map((customer) => customer.id)).toContain( + "new_customer", + ); + } finally { + server.restore(); + } + }); + + test("scores expected tool and API calls", () => { + const output = { + apiCalls: [ + { + body: { search: "Joe" }, + endpoint: "/v1/customers.list", + toolName: "listCustomers" as const, + }, + { + body: { customer_id: "joe_customer" }, + endpoint: "/v1/customers.get_or_create", + toolName: "getOrCreateCustomer" as const, + }, + ], + finalText: "Joe is on Pro for $79 per month.", + toolCalls: [ + { args: {}, name: "listCustomers" }, + { args: {}, name: "getOrCreateCustomer" }, + ], + }; + + expect( + expectedApiCalls({ + expected: { + apiCalls: [ + { + body: { customer_id: "joe_customer" }, + toolName: "getOrCreateCustomer", + }, + ], + }, + output, + }), + ).toBe(1); + expect( + expectedToolCalls({ + expected: { toolCalls: ["listCustomers", "getOrCreateCustomer"] }, + output, + }), + ).toBe(1); + }); +}); diff --git a/apps/leaf/tests/unit/lib/logger.test.ts b/apps/leaf/tests/unit/lib/logger.test.ts new file mode 100644 index 000000000..2e5bccf0d --- /dev/null +++ b/apps/leaf/tests/unit/lib/logger.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { createLeafSessionContext } from "../../../src/lib/logger.js"; + +describe("Leaf logger context", () => { + test("creates stable session ids and distinct trace ids", () => { + const first = createLeafSessionContext({ + channelId: "C1", + provider: "slack", + providerUserId: "U1", + threadId: "T1", + workspaceId: "W1", + }); + const second = createLeafSessionContext({ + channelId: "C1", + provider: "slack", + providerUserId: "U2", + threadId: "T1", + workspaceId: "W1", + }); + + expect(first.sessionId).toBe(second.sessionId); + expect(first.traceId).not.toBe(second.traceId); + expect(first.context).toMatchObject({ + provider: "slack", + session_id: first.sessionId, + trace_id: first.traceId, + slack_channel_id: "C1", + slack_thread_id: "T1", + slack_workspace_id: "W1", + }); + }); +}); diff --git a/apps/leaf/tests/unit/mcp/oauth.test.ts b/apps/leaf/tests/unit/mcp/oauth.test.ts new file mode 100644 index 000000000..efc4d99fe --- /dev/null +++ b/apps/leaf/tests/unit/mcp/oauth.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import { LEAF_OAUTH_SCOPES } from "@autumn/shared"; +import { + getProtectedResourceMetadata, + type OAuthHttpError, +} from "../../../src/mcp/auth/protectedResourceMetadata.js"; +import { + buildAuthForRequest, + type MCPOAuthFlags, +} from "../../../src/mcp/auth/resolveRequestAuth.js"; + +const flags = { + "oauth-enabled": true, + "oauth-environment": "sandbox", + "server-url": "http://localhost:8080", +} satisfies Partial; + +const logger = { + warning: () => {}, +} as never; + +const resourceUrl = "http://localhost:2718/mcp"; +const internalResourceUrl = "http://localhost:2718/internal/mcp"; + +describe("MCP OAuth auth resolution", () => { + test("advertises the Leaf OAuth scope allowlist", () => { + expect( + getProtectedResourceMetadata({ resourceUrl }).scopes_supported, + ).toEqual([...LEAF_OAUTH_SCOPES]); + }); + + test("returns a WWW-Authenticate challenge without a bearer token", async () => { + await expect( + buildAuthForRequest({ + headers: new Headers(), + flags: flags as MCPOAuthFlags, + logger, + resourceUrl, + }), + ).rejects.toMatchObject({ + status: 401, + error: "invalid_token", + wwwAuthenticate: + 'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/mcp", error="invalid_token"', + } satisfies Partial); + }); + + test("returns an internal MCP resource challenge", async () => { + await expect( + buildAuthForRequest({ + headers: new Headers(), + flags: flags as MCPOAuthFlags, + logger, + resourceUrl: internalResourceUrl, + }), + ).rejects.toMatchObject({ + status: 401, + error: "invalid_token", + wwwAuthenticate: + 'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/internal/mcp", error="invalid_token"', + } satisfies Partial); + }); + + test("passes OAuth bearer tokens through without local verification", async () => { + const originalFetch = globalThis.fetch; + let fetchCalled = false; + const mockFetch = (async () => { + fetchCalled = true; + return Response.json({}); + }) as unknown as typeof fetch; + globalThis.fetch = mockFetch; + + try { + const auth = await buildAuthForRequest({ + headers: new Headers({ + authorization: "Bearer am_oauth_token", + }), + flags: flags as MCPOAuthFlags, + logger, + resourceUrl, + }); + + expect(auth).toMatchObject({ + apiKey: "am_oauth_token", + authMethod: "oauth", + env: "sandbox", + principalId: "oauth:unverified", + resource: "http://localhost:2718/mcp", + scopes: [...LEAF_OAUTH_SCOPES], + serverURL: "http://localhost:8080", + }); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("accepts a static secret-key when OAuth is enabled", async () => { + const auth = await buildAuthForRequest({ + headers: new Headers({ + "secret-key": "am_sk_test_chat", + }), + flags: flags as MCPOAuthFlags, + logger, + resourceUrl, + }); + + expect(auth.apiKey).toBe("am_sk_test_chat"); + expect(auth.principalId).toStartWith("secret-key:"); + expect(auth.resource).toBe("http://localhost:2718/mcp"); + expect(auth.scopes).toEqual([...LEAF_OAUTH_SCOPES]); + }); + + test("accepts an Autumn API key bearer token when OAuth is enabled", async () => { + const auth = await buildAuthForRequest({ + headers: new Headers({ + authorization: "Bearer am_sk_test_chat", + }), + flags: flags as MCPOAuthFlags, + logger, + resourceUrl, + }); + + expect(auth.apiKey).toBe("am_sk_test_chat"); + expect(auth.principalId).toStartWith("secret-key:"); + }); + + test("uses route-specific resource URLs", async () => { + const auth = await buildAuthForRequest({ + headers: new Headers({ + authorization: "Bearer am_sk_test_chat", + }), + flags: flags as MCPOAuthFlags, + logger, + resourceUrl: internalResourceUrl, + }); + + expect(auth.resource).toBe("http://localhost:2718/internal/mcp"); + expect( + getProtectedResourceMetadata({ + resourceUrl: internalResourceUrl, + serverURL: flags["server-url"], + }).resource, + ).toBe("http://localhost:2718/internal/mcp"); + }); + + test("missing static secret-key returns the auth error path", async () => { + await expect( + buildAuthForRequest({ + headers: new Headers(), + flags: { + ...flags, + "oauth-enabled": false, + } as MCPOAuthFlags, + logger, + resourceUrl, + }), + ).rejects.toMatchObject({ + status: 401, + error: "invalid_token", + } satisfies Partial); + }); +}); diff --git a/apps/leaf/tests/unit/providers/e2b/e2bSandboxMetadata.test.ts b/apps/leaf/tests/unit/providers/e2b/e2bSandboxMetadata.test.ts new file mode 100644 index 000000000..60226f524 --- /dev/null +++ b/apps/leaf/tests/unit/providers/e2b/e2bSandboxMetadata.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { + e2bSandboxLookupMetadata, + e2bSandboxMetadata, + e2bThreadKey, +} from "../../../../src/providers/e2b/e2bSandboxMetadata.js"; + +const context = { + channelId: "C123", + env: "live", + orgId: "org_123", + provider: "slack", + threadId: "1710000000.000", + workspaceId: "T123", +}; + +describe("E2B sandbox metadata", () => { + test("builds stable thread keys", () => { + expect(e2bThreadKey({ context })).toBe( + "org_123:live:slack:T123:C123:1710000000.000", + ); + }); + + test("builds full metadata and lookup metadata", () => { + expect(e2bSandboxMetadata({ context })).toEqual({ + app: "leaf", + channelId: "C123", + env: "live", + orgId: "org_123", + provider: "slack", + threadId: "1710000000.000", + threadKey: "org_123:live:slack:T123:C123:1710000000.000", + workspaceId: "T123", + }); + expect(e2bSandboxLookupMetadata({ context })).toEqual({ + app: "leaf", + threadKey: "org_123:live:slack:T123:C123:1710000000.000", + }); + }); +}); diff --git a/apps/leaf/tests/unit/providers/slack/context.test.ts b/apps/leaf/tests/unit/providers/slack/context.test.ts new file mode 100644 index 000000000..717639720 --- /dev/null +++ b/apps/leaf/tests/unit/providers/slack/context.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +import { getSlackWorkspaceId } from "../../../../src/providers/slack/context.js"; + +describe("chat context", () => { + test("parses Slack workspace ids", () => { + expect(getSlackWorkspaceId({ team_id: "T123" })).toBe("T123"); + expect(getSlackWorkspaceId({ team: { id: "T456" } })).toBe("T456"); + }); + + test("rejects missing workspace ids", () => { + expect(() => getSlackWorkspaceId({})).toThrow(); + }); +}); diff --git a/apps/leaf/tests/unit/providers/slack/files.test.ts b/apps/leaf/tests/unit/providers/slack/files.test.ts new file mode 100644 index 000000000..81b205961 --- /dev/null +++ b/apps/leaf/tests/unit/providers/slack/files.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { Attachment } from "chat"; +import { + fetchSlackAttachmentFallback, + getSlackFilesFromRaw, +} from "../../../../src/providers/slack/files.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("Slack file helpers", () => { + test("extracts Slack file metadata from raw messages", () => { + expect( + getSlackFilesFromRaw({ + raw: { + files: [ + { + id: "F1", + mimetype: "application/pdf", + name: "contract.pdf", + size: 123, + url_private: "https://files.slack.com/contract.pdf", + }, + null, + ], + }, + }), + ).toEqual([ + { + id: "F1", + mimetype: "application/pdf", + name: "contract.pdf", + size: 123, + url_private: "https://files.slack.com/contract.pdf", + }, + ]); + }); + + test("downloads fallback Slack private URLs with bot auth", async () => { + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("https://files.slack.com/contract.pdf"); + expect(init?.headers).toEqual({ Authorization: "Bearer xoxb-test" }); + return new Response("pdf"); + }) as typeof fetch; + + const data = await fetchSlackAttachmentFallback({ + attachment: { + mimeType: "application/pdf", + name: "contract.pdf", + size: 3, + type: "file", + } satisfies Attachment, + botToken: "xoxb-test", + rawFiles: [ + { + id: "F1", + mimetype: "application/pdf", + name: "contract.pdf", + size: 3, + url_private: "https://files.slack.com/contract.pdf", + }, + ], + }); + + expect(data?.toString()).toBe("pdf"); + }); + + test("looks up url_private with files.info when raw URL is missing", async () => { + const calls: string[] = []; + globalThis.fetch = (async (url, init) => { + calls.push(String(url)); + expect(init?.headers).toEqual({ Authorization: "Bearer xoxb-test" }); + if (String(url).startsWith("https://slack.com/api/files.info")) { + return Response.json({ + ok: true, + file: { url_private: "https://files.slack.com/contract.pdf" }, + }); + } + return new Response("pdf"); + }) as typeof fetch; + + const data = await fetchSlackAttachmentFallback({ + attachment: { + mimeType: "application/pdf", + name: "contract.pdf", + size: 3, + type: "file", + } satisfies Attachment, + botToken: "xoxb-test", + rawFiles: [ + { + id: "F1", + mimetype: "application/pdf", + name: "contract.pdf", + size: 3, + }, + ], + }); + + expect(data?.toString()).toBe("pdf"); + expect(calls).toHaveLength(2); + expect(calls[0]).toContain("file=F1"); + }); +}); diff --git a/apps/leaf/tests/unit/ui/blocks.test.ts b/apps/leaf/tests/unit/ui/blocks.test.ts new file mode 100644 index 000000000..ab2fc4561 --- /dev/null +++ b/apps/leaf/tests/unit/ui/blocks.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import { approvalCard, approvalStatusCard } from "../../../src/ui/blocks.js"; + +describe("approval card", () => { + test("renders billing approvals as structured cards", () => { + const card = approvalCard({ + id: "approval_1", + env: AppEnv.Sandbox, + toolName: "attach", + toolArgs: { + request: { + customer_id: "get-full-entity-ordering", + plan_id: "pro_att-disc-dedup", + }, + }, + preview: + "I'll preview this now!Here's the billing impact preview:Plan: ProCustomer: get-full-entity-orderingTotal $20.00No discounts applied", + }); + + expect(card.title).toBe("Attach plan?"); + expect(card.children[0]?.type).toBe("fields"); + expect(card.children.at(-1)?.type).toBe("actions"); + expect(JSON.stringify(card)).toContain("Sandbox"); + expect(JSON.stringify(card)).toContain("pro_att-disc-dedup"); + expect(JSON.stringify(card)).toContain("• Plan: Pro"); + }); + + test("renders closed approvals without buttons or raw JSON", () => { + const card = approvalStatusCard({ + status: "failed", + toolName: "attach", + toolArgs: { + request: { + customer_id: "cus_1", + plan_id: "pro", + }, + }, + result: { + error: true, + message: "Tool input validation failed.", + validationErrors: { fields: {} }, + }, + }); + + expect(card.title).toBe("Attach plan failed"); + expect(card.children.at(-1)?.type).not.toBe("actions"); + expect(JSON.stringify(card)).toContain("Tool input validation failed."); + expect(JSON.stringify(card)).not.toContain("validationErrors"); + }); + + test("renders failed execution errors in approval status cards", () => { + const card = approvalStatusCard({ + status: "failed", + toolName: "attach", + result: { + error: true, + message: "Missing email.", + }, + }); + + const json = JSON.stringify(card); + expect(card.title).toBe("Attach plan failed"); + expect(json).toContain("Missing email."); + expect(json).not.toContain('"error"'); + }); + + test("does not render raw request JSON as preview text", () => { + const card = approvalCard({ + id: "approval_1", + toolName: "updateSubscription", + toolArgs: { + request: { + customer_id: "charlie", + plan_id: "pro", + customize: { price: { amount: 200, interval: "month" } }, + }, + }, + preview: { + request: { + customer_id: "charlie", + plan_id: "pro", + }, + }, + }); + + expect(JSON.stringify(card)).toContain("$200/month"); + expect(JSON.stringify(card)).not.toContain('"request"'); + expect(card.children.at(-1)?.type).toBe("actions"); + }); + + test("cleans markdown and keeps decimal amounts intact", () => { + const card = approvalCard({ + id: "approval_1", + toolName: "updateSubscription", + toolArgs: { + request: { + customer_id: "charlie", + plan_id: "pro", + customize: { price: { amount: 400, interval: "month" } }, + }, + }, + preview: + "Let me preview that update!\n**Immediate charge (proration)**\n- 💳 **$178.65 due now**\n- Credit for unused time: -$178.65", + }); + + const json = JSON.stringify(card); + expect(json).toContain("$178.65 due now"); + expect(json).not.toContain("**"); + expect(json).not.toContain("$178.\\n"); + expect(json).not.toContain("Let me preview"); + }); + + test("shows action progress and omits empty success text", () => { + const running = approvalStatusCard({ + status: "running", + toolName: "updateSubscription", + toolArgs: { request: { customer_id: "charlie", plan_id: "pro" } }, + }); + const approved = approvalStatusCard({ + status: "approved", + toolName: "updateSubscription", + toolArgs: { request: { customer_id: "charlie", plan_id: "pro" } }, + preview: "**old preview**", + result: {}, + }); + + expect(JSON.stringify(running)).toContain( + "Applying the approved action now", + ); + expect(approved.title).toBe("Update subscription approved"); + expect(JSON.stringify(approved)).not.toContain("Applied successfully."); + expect(JSON.stringify(approved)).not.toContain("old preview"); + }); + + test("shows nested write result details", () => { + const card = approvalStatusCard({ + status: "approved", + env: AppEnv.Live, + toolName: "attach", + result: { + result: { + status: "created", + checkout_url: "https://checkout.example", + }, + }, + }); + + const json = JSON.stringify(card); + expect(json).toContain("Live"); + expect(json).toContain("Status: created"); + expect(json).toContain("Checkout URL: https://checkout.example"); + }); +}); diff --git a/apps/leaf/tsconfig.json b/apps/leaf/tsconfig.json new file mode 100644 index 000000000..39cf3a979 --- /dev/null +++ b/apps/leaf/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["bun", "node"], + "paths": { + "@autumn/shared": ["../../shared/index.ts"], + "@autumn/shared/*": ["../../shared/*"], + "@autumn/logging": ["../../packages/logging/src/index.ts"], + "@autumn/mcp/*": ["../../packages/mcp/*"], + "@api/*": ["../../shared/api/*"], + "@models/*": ["../../shared/models/*"], + "@utils/*": ["../../shared/utils/*"] + } + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/apps/mcp-server/Dockerfile b/apps/mcp-server/Dockerfile deleted file mode 100644 index 639e3abe9..000000000 --- a/apps/mcp-server/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM oven/bun:1.3.10 AS pruner -WORKDIR /app - -COPY . . -RUN bunx turbo@2.9.14 prune @autumn/mcp-server --docker - -FROM oven/bun:1.3.10 -WORKDIR /app - -COPY --from=pruner /app/out/json/ . -RUN mkdir -p scripts && touch scripts/preload-env.ts -RUN bun -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); pkg.workspaces.packages = ["shared", "apps/mcp-server", "packages/ksuid", "packages/mcp"]; delete pkg.dependencies; delete pkg.devDependencies; delete pkg.scripts; fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2));' -RUN rm bun.lock && bun install --production --ignore-scripts - -COPY --from=pruner /app/out/full/ . - -ENV NODE_ENV=production -EXPOSE 8080 - -CMD ["bun", "-F", "@autumn/mcp-server", "start"] diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json deleted file mode 100644 index 6892b7279..000000000 --- a/apps/mcp-server/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@autumn/mcp-server", - "private": true, - "type": "module", - "scripts": { - "dev": "bun --watch src/index.ts", - "start": "bun src/index.ts", - "ts": "tsc --noEmit" - }, - "dependencies": { - "@autumn/mcp": "workspace:*", - "@hono/node-server": "^1.19.5", - "hono": "4.12.7" - }, - "devDependencies": { - "@types/bun": "^1.2.13", - "@types/node": "^18.19.3", - "typescript": "~5.8.3" - } -} diff --git a/apps/mcp-server/src/http.ts b/apps/mcp-server/src/http.ts deleted file mode 100644 index 8460b92bd..000000000 --- a/apps/mcp-server/src/http.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { HttpBindings } from "@hono/node-server"; -import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; -import { - buildAuthForRequest, - createAskAutumnMCPServer, - createAutumnOperationsMCPServer, - getAuthorizationServerMetadata, - getProtectedResourceMetadata, - type ConsoleLogger, - type MCPServerFlags, - type OAuthEnvironment, - OAuthHttpError, -} from "@autumn/mcp"; -import type { Context } from "hono"; -import { Hono } from "hono"; - -export interface CreateMcpHttpAppOptions extends MCPServerFlags { - readonly "oauth-enabled": boolean; - readonly "oauth-environment": OAuthEnvironment; - readonly logger: ConsoleLogger; -} - -type AppContext = Context<{ Bindings: HttpBindings }>; -type McpPath = "/mcp" | "/internal/mcp"; - -export function createMcpHttpApp(options: CreateMcpHttpAppOptions) { - const app = new Hono<{ Bindings: HttpBindings }>(); - - app.use("*", async (c, next) => { - c.header("Access-Control-Allow-Origin", "*"); - c.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - c.header("Access-Control-Allow-Headers", "*"); - return c.req.method === "OPTIONS" ? c.body(null, 204) : next(); - }); - - app.get("/.well-known/oauth-protected-resource/mcp", (c) => - c.json(getProtectedResourceMetadata(c.req.raw.headers, options, "/mcp")), - ); - - app.get("/.well-known/oauth-protected-resource/internal/mcp", (c) => - c.json( - getProtectedResourceMetadata( - c.req.raw.headers, - options, - "/internal/mcp", - ), - ), - ); - - app.get("/.well-known/oauth-authorization-server", (c) => - c.json(getAuthorizationServerMetadata(options)), - ); - - const handleMcp = async ( - c: AppContext, - path: McpPath, - server: ReturnType, - ) => { - let auth: Awaited>; - try { - auth = await buildAuthForRequest( - c.req.raw.headers, - options, - options.logger, - path, - ); - } catch (error) { - if (error instanceof OAuthHttpError) { - if (error.wwwAuthenticate) { - c.header("WWW-Authenticate", error.wwwAuthenticate); - } - return c.json( - { error: error.error, error_description: error.message }, - { status: error.status as 401 | 403 }, - ); - } - throw error; - } - - (c.env.incoming as typeof c.env.incoming & { auth?: typeof auth }).auth = - auth; - await server.startHTTP({ - url: new URL(c.req.url), - httpPath: path, - req: c.env.incoming, - res: c.env.outgoing, - options: { serverless: true }, - }); - return RESPONSE_ALREADY_SENT; - }; - - app.all("/mcp", (c) => - handleMcp(c, "/mcp", createAutumnOperationsMCPServer()), - ); - app.all("/internal/mcp", (c) => - handleMcp(c, "/internal/mcp", createAskAutumnMCPServer()), - ); - - return app; -} diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts deleted file mode 100644 index 6d68d5893..000000000 --- a/apps/mcp-server/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { serve } from "@hono/node-server"; -import { - createConsoleLogger, - type OAuthEnvironment, -} from "@autumn/mcp"; -import { createMcpHttpApp } from "./http.js"; - -const port = Number.parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "2718", 10); -const serverURL = - process.env.MCP_SERVER_URL ?? - (process.env.NODE_ENV === "production" - ? "https://api.useautumn.com" - : "http://localhost:8080"); -const oauthEnvironment: OAuthEnvironment = - process.env.MCP_OAUTH_ENVIRONMENT === "live" ? "live" : "sandbox"; -const logger = createConsoleLogger("info"); -const app = createMcpHttpApp({ - "oauth-enabled": true, - "oauth-environment": oauthEnvironment, - "server-url": serverURL, - logger, -}); - -serve({ - fetch: app.fetch, - hostname: "0.0.0.0", - port, -}, ({ address, port }) => { - logger.info("MCP server started", { host: `${address}:${port}` }); -}); diff --git a/apps/mcp-server/tsconfig.json b/apps/mcp-server/tsconfig.json deleted file mode 100644 index 4447bce17..000000000 --- a/apps/mcp-server/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "lib": ["es2024"], - "module": "Preserve", - "moduleResolution": "bundler", - "noFallthroughCasesInSwitch": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "skipLibCheck": true, - "strict": true, - "target": "es2022", - "types": ["bun"], - "paths": { - "@api/*": ["../../shared/api/*"], - "@models/*": ["../../shared/models/*"], - "@utils/*": ["../../shared/utils/*"], - "@autumn/ksuid": ["../../packages/ksuid/src/index.ts"] - } - }, - "include": ["src/**/*.ts"] -} diff --git a/apps/website/app/blog/[slug]/page.tsx b/apps/website/app/blog/[slug]/page.tsx index 3a707c1be..83614ef4d 100644 --- a/apps/website/app/blog/[slug]/page.tsx +++ b/apps/website/app/blog/[slug]/page.tsx @@ -105,12 +105,12 @@ export default async function BlogPostPage({ params }: { params: BlogParams }) { {post.image && ( -
+
{post.title} diff --git a/apps/website/content/blog/how-we-built-a-multi-region-architecture-and-why-we-went-back.mdx b/apps/website/content/blog/how-we-built-a-multi-region-architecture-and-why-we-went-back.mdx new file mode 100644 index 000000000..37a953f84 --- /dev/null +++ b/apps/website/content/blog/how-we-built-a-multi-region-architecture-and-why-we-went-back.mdx @@ -0,0 +1,124 @@ +--- +title: "We went multi-region then undid it" +description: "Why we moved Autumn to a multi-region architecture, the tradeoffs we hit with active-active Redis, and why we eventually returned to a simpler single-region setup." +date: "2026-06-08" +author: "John, Autumn Co-Founder" +slug: "how-we-built-a-multi-region-architecture-and-why-we-went-back" +image: "/images/blog/multi-region-initial-architecture.png" +--- + +Last year we started to onboard companies with a global customer base. With our own users starting to appear in more regions, we decided to build a multi-region architecture to reduce latency times globally. + +Initially, our services were isolated to one region, us-west. + +Our aim was to reduce latency in two regions to start, us-west and us-east, and targeted a round trip latency of under 50ms. The main difficulty was that this applied to both reads and writes, so simply using DB read replicas weren’t an option. Ultimately, there were two major considerations: + +- How to spin up our server in multiple regions +- More crucially though, how to make data reads and writes low latency across regions + +## Spinning up our server in multiple regions + +There were two options here. Either we went serverless with something like Cloudflare Workers, or we manually spun up stateful servers in different regions. We went with the latter for a couple reasons: + +1. The whole point of this was to reduce latency. With serverless, we were afraid of inconsistent latencies due to cold startup times, which we benchmarked and proved to be true. +2. Our server was already stateful, and going serverless would’ve broken patterns we relied on. Event batching, for one, gets painful when every request runs in an isolated session. + +This blog from [Unkey](https://www.unkey.com/blog/serverless-exit) was really helpful when we made our decision. Now our next challenge was deciding on a provider. Our requirements were simple: + +- Latency should be as low as possible +- Spinning up multi-region servers should be as simple as possible + +Surprisingly, we tried almost every provider we could find and none of them fit perfectly. We ultimately chose AWS ECS, managed through Flightcontrol, where we spun up an ECS service in us-west and us-east, then used Route53 to route requests based on region. + +![Route53 ECS regional routing](/images/blog/multi-region-route53-ecs-regional-routing.png) + +To explain why we came to this decision, it’s worth walking through the other top contenders. + +**[Render](https://render.com/)** + +We were originally on Render so this seemed like the obvious choice. However, Render doesn’t natively support multi-region, so to set this up we had to manually create instances in each region. More annoyingly though, the only way to have a single domain route to different instances was to use Cloudflare’s load balancer. + +![Render Cloudflare load balancer](/images/blog/multi-region-render-cloudflare-load-balancer.png) + +Ultimately, we chose AWS over Render because we found that Cloudflare's Load Balancer introduced additional latency compared to Route53, which resolved at the DNS layer. With Render, there were also multiple hops involved as Render itself uses Cloudflare in front of their services. + +**[Railway](https://railway.com/)** + +Railway was extremely compelling because they supported multi-region natively. That meant that you could spin up a single service, have it replicated across different regions, and they would handle load balancing, provisioning, and more for you. The DX was unmatched. Unfortunately though, Railway’s infra isn’t on AWS. They build their own machines. This means a couple things: + +- Our database, cache, and other data stores wouldn’t be co-located with our server, unless we used Railway for those as well, which was too limiting for us +- Most of our users were also hosted on AWS so their servers wouldn’t be as close to ours + +![Railway AWS data hop](/images/blog/multi-region-railway-aws-data-hop.png) + +Ultimately, with both providers, the decision came down to latency. AWS consistently provided the lowest latencies in our benchmarks. + +![Provider p99 Checkly benchmark](/images/blog/multi-region-provider-p99-checkly-benchmark.svg) + +That said, ECS came with a bunch of maintenance overhead, especially coming from Render. Even with Flightcontrol, we had to build an internal dashboard to build and deploy across regions at once. Moreover, application and load balancer logs were an absolute pain to set up. But today I’m very glad we made the tradeoff. Having lower-level control over our infra has been useful, and AI has made things much easier too. + +## Making data reads and writes multi-region + +The bigger challenge we faced was with data access: making both reads and writes fast across regions. Think of us as a complex rate limiter. Before a request is allowed through, we often need to update usage counters atomically and decide whether the customer still has access. + +For example, when you send a message to Cursor, they may deduct an estimated number of credits before accepting your message, then reconcile the actual usage afterwards. Since these writes sit on the hot path, they need to be real-time and fast. We considered several approaches to solving this. + +1. **A master database per region** + +We’d spin up a Postgres database in each region, completely isolated from each other, and let our users pick which region their data lives in, so it sits closest to their server. The catch, beyond running multiple databases, is that our user’s own customers might be spread across regions. For example, if they’re running Cloudflare Workers, pinning a whole account to one region doesn’t hold up. + +![Master database per region users](/images/blog/multi-region-master-db-per-region-users.png) + +2. **A region per customer** + +Instead of pinning our user, we could pin a customer: our user’s user. Each customer is tied to a region, and all their reads and writes happen there. We'd keep a record mapping customers to regions, and route each request accordingly. + +![Region per customer](/images/blog/multi-region-region-per-customer.png) + +Now trying to do this with Postgres sounded like a headache. Imagine trying to JOIN data across different databases. We could simplify this with a read/write cache in each region instead of fully separate Postgres databases, but we still ruled it out because of the routing layer. We'd need yet another cache for the customer-to-region mapping, itself replicated across regions, and getting every request to the right region felt like way too much overhead. + +3. **Active-active Redis database** + +The final approach, which we ended up going with, was using an Active-Active database from Redis Cloud. You spin up Redis caches in multiple regions, all fully synced, and you can write to any of them. When concurrent writes hit the same key in different regions, Redis Cloud resolves the conflict using CRDTs: Conflict-free Replicated Data Types. + +Using a counter as an example: two concurrent increment operations merge into their sum rather than overwriting each other. This fit our use case perfectly. Each server connects to its own Redis cache in the cluster, and since our writes are just increments, the conflicts get resolved for us. + +## Why we went back + +We chose the Active-Active Redis database for simplicity, and while it definitely created the least infra overhead, I think it wasn’t really the right solution for us, which led to more complexity than it was worth. + +1. **Race conditions** + +First of all, with the active-active database, even though it solved that counter case perfectly, we found ourselves running into a bunch of race conditions. Take the following example: + +- We store each customer as a JSON blob with `customer_id` as the key +- Your customer performs an upgrade on us-east so we append to their `subscriptions` array +- At the same time, Stripe sends an `invoice.paid` webhook to our us-west server and we append to the customer’s `invoices` array + +Now, both of these append operations happen on the same key and are done via a read-update-set operation. Since they happen in different regions, Redis resolves the conflict through a Last-Write-Win strategy. So either the invoices or subscriptions array will be missing an item. + +To solve these types of issues, we’d often have to normalize the data. For instance, we might store the subscriptions and invoices array as separate keys, `customer_id:subscriptions` and `customer_id:invoices`. Ultimately though, we ran into these issues more often than we’d hoped, especially since it was hard to replicate a multi-region setup locally. + +2. **Infra overhead** + +The second issue we kept running into was infra overhead. It wasn’t just slowing us down; it was starting to affect reliability too. + +A couple of months ago, we had a user run a cron job every hour that spiked our Redis CPU and degraded the server. The quick fix would’ve been to spin up a separate Redis database for that user, so their load wouldn’t impact everyone else. But because of our multi-region architecture, what should have been a simple isolation fix became much more complex and delayed. + +Reliability matters more to us than latency. So when our architecture made it harder to ship reliability fixes quickly, that was a strong signal that the tradeoff no longer made sense. + +Ultimately, the thing that pushed us to move back to a single-region architecture was noticing that traffic was split roughly 95:5 between us-east and us-west. Taking on all of that complexity and giving up speed and reliability for this small slice of traffic didn’t feel worth it. + +## Conclusion + +Ever since we’ve moved back to a single-region architecture, we’ve been way more confident in our infra and reliability, and have been able to make changes, introduce new services, and ship features way faster too. Focusing on optimizing a smaller scope has felt like a huge difference. So generally, we’re very happy about our decision. Now, two concluding thoughts: + +**Don’t “move fast and break things” with infra** + +I think the mistake we made with our multi-region setup was optimizing for simplicity and speed rather than choosing the architecture that would hold up best long term. Infra is a little counterintuitive to the usual “ship fast” startup advice. These decisions affect reliability directly, and they’re often some of the hardest decisions to reverse later. So while speed still matters, infra choices deserve more upfront thought than your average product decision. + +**The “smart” choice isn’t always the best one** + +With our original approach, I think we convinced ourselves that an Active-Active Redis database would be a silver bullet, and that choosing it was the “smart” move. But infra is all about tradeoffs. There’s a reason writable database replicas aren’t common: they add a lot of complexity, and that complexity has to show up somewhere. + +We’ll definitely go back to multi-region at some point. But when we do, I think we’ll take a “less hacky” approach: route each customer to a single home region, and keep their data and traffic there. It’s much easier to reason about, and probably a lot more reliable. diff --git a/apps/website/content/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing.mdx b/apps/website/content/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing.mdx new file mode 100644 index 000000000..6d74fab83 --- /dev/null +++ b/apps/website/content/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing.mdx @@ -0,0 +1,54 @@ +--- +title: "We built a billing company but didn't replace Stripe Billing" +description: "Why Autumn builds around Stripe Billing instead of replacing subscriptions and invoices, and where the real complexity has moved for credit-based pricing." +date: "2026-06-01" +author: "John, Autumn Co-Founder" +slug: "we-built-a-billing-company-but-didnt-replace-stripe-billing" +image: "/images/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing.png" +--- + +Autumn isn't a replacement for [Stripe Billing](https://stripe.com/gb/newsroom/news/stripe-launches-billing). Your subscriptions and invoices stay exactly as they are. People often get confused when we tell them this, perhaps it's because most of the other players in the space have built their own billing engines and invoicing systems from the ground up. So when people learn that we haven't done the same, it naturally leads to the question: **what benefit do you offer over Stripe?** + +I figured I'd write an article answering this. The short answer is pretty simple - we're solving a different problem, or rather, a different layer. + +### Subscriptions and invoices + +Stripe Billing launched when the recurring payment model (aka subscriptions) started to take off. About a decade ago, people used to build this in house. Companies would run cron jobs which queried over their customers table daily, identified who was due for a payment, generated an invoice for each one, and charged their card. + +As with any in-house solution, there's always more than meets the eye. Things like managing different payment methods, plan switches, retrying failed payments, etc. That's just on the subscription management side, invoicing was a whole different beast with things like taxes, schedules and more coming into play. These were the primitives that Stripe Billing was built around. + +### Credit based pricing + +Subscriptions and invoices haven't gone anywhere. They still sit at the center of every company's billing, and Stripe's core abstractions around them have stood the test of time. What's grown complex, however, is everything companies now build on top - especially as AI brings new ways to price and package. + +This is the layer that we're solving, and it's particularly relevant for companies with credit-based pricing. To understand why, let's walk through an example by thinking about how one might build this in-house. Imagine your pro plan grants users some number of credits every month. + +You might begin by building a simple credit ledger in Postgres. Here's how it works: + +- You run a cron job every month to top users up with a number of credits +- Every time a user performs an action in your app, you insert an event into this table +- To get the number of credits used per user, you run an aggregation over this table + +This works when credit usage is infrequent, maybe a couple of times per day. But if you have to run these aggregations a couple of times per second, it gets expensive. Take Cursor for example, every time you send a message, they check whether you have enough credits and block you if you don't. So how do you solve this? You add a caching layer in front of this events table and store users' credit balances in real time. Every time a credit is used, you update this cache and add an event to the initial table. + +Next, you decide you want to start giving out credit coupons, but these are slightly different to the ones users get each month: they don't reset. So now you need to figure out a way to distinguish between these balances in your events table and cache. The list of features that you can build around this goes on and on - rollovers, usage analytics, alerts, auto top ups. Before you know it, you're maintaining an entire system alongside your core product! + +![Credit balance cache and events table architecture](/images/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing-cache.png) + +*OpenAI published a good blog [here](https://openai.com/index/beyond-rate-limits/) on building a similar system in house.* + +Just as Stripe Billing abstracted away the complexity of subscription management and invoicing, this is the layer we're focused on simplifying - the application state that companies manage around billing! And hopefully we've demonstrated by now how they're two fairly different problems. + +### Where we overlap + +All that being said, Stripe Billing has expanded since its inception and solves way more than just subscription management. So we're really more of an overlap - sometimes we don't fully agree with the primitives it has built, and choose to build our own version in house. Usage-based billing is a good example. + +Usage-based billing has evolved quite a bit since 2020. Early on, it was very "write-heavy." Infrastructure providers like Supabase charged per compute hour: they'd record how many you accrued over the billing period, then charge you at the end. There was no checking whether you'd hit a limit. You might occasionally visit your billing page to view how much usage you've accumulated, but for the most part, reads weren't critical - writes were. + +Compare that to today where pricing models look more like "X credits per month". Some people might call this as "consumption-based" billing. This type of model warrants a different architecture because you would perform a read to check that the user has enough credits before each action, and a write afterwards to record it. + +While there's a ton to say about this, the point here is that Stripe's usage-based billing was largely built for the first model, whereas these days we're seeing a lot more of the second, and therefore it's not entirely feasible to use Stripe's primitives here. + +### Conclusion + +Hopefully this gives you a better idea of how Autumn compares to Stripe - putting it into words has definitely given me a bit of clarity! If you're wondering whether we'd be a fit for your company, you can always email me [here](mailto:john@useautumn.com) - happy to advise either way :) diff --git a/apps/website/next.config.mjs b/apps/website/next.config.mjs index 423fbf187..436d89cbc 100644 --- a/apps/website/next.config.mjs +++ b/apps/website/next.config.mjs @@ -20,6 +20,15 @@ const nextConfig = { // falling back to WebP. Next.js negotiates via Accept header automatically. formats: ["image/avif", "image/webp"], }, + async redirects() { + return [ + { + source: "/docs", + destination: "https://docs.useautumn.com", + permanent: false, + }, + ]; + }, async headers() { if (!isProd) return []; diff --git a/apps/website/public/images/blog/multi-region-initial-architecture.png b/apps/website/public/images/blog/multi-region-initial-architecture.png new file mode 100644 index 000000000..41ab3f10c Binary files /dev/null and b/apps/website/public/images/blog/multi-region-initial-architecture.png differ diff --git a/apps/website/public/images/blog/multi-region-master-db-per-region-users.png b/apps/website/public/images/blog/multi-region-master-db-per-region-users.png new file mode 100644 index 000000000..7cfdc9b5d Binary files /dev/null and b/apps/website/public/images/blog/multi-region-master-db-per-region-users.png differ diff --git a/apps/website/public/images/blog/multi-region-provider-p99-checkly-benchmark.svg b/apps/website/public/images/blog/multi-region-provider-p99-checkly-benchmark.svg new file mode 100644 index 000000000..13f11bc29 --- /dev/null +++ b/apps/website/public/images/blog/multi-region-provider-p99-checkly-benchmark.svg @@ -0,0 +1,75 @@ + + + + + + + MONITOR + TYPE + LAST 24 HRS + UPTIME + SUCCESS + P99 + RESPONSE + INTERVAL + + + + + AWS ECS Get Customer (US East) + 1 minute ago + + API + + + + 100 % + 100 % + 55 + ms + 88 + ms + 1 min + + + + + + + Railway Get Customer (US East) + less than a minute ago + + API + + + + 100 % + 100 % + 120 + ms + 178 + ms + 1 min + + + + + + + Render Get Customer (US East) + less than a minute ago + + API + + + + 100 % + 100 % + 106 + ms + 154 + ms + 1 min + + + diff --git a/apps/website/public/images/blog/multi-region-railway-aws-data-hop.png b/apps/website/public/images/blog/multi-region-railway-aws-data-hop.png new file mode 100644 index 000000000..51456d70c Binary files /dev/null and b/apps/website/public/images/blog/multi-region-railway-aws-data-hop.png differ diff --git a/apps/website/public/images/blog/multi-region-region-per-customer.png b/apps/website/public/images/blog/multi-region-region-per-customer.png new file mode 100644 index 000000000..28b92f3f4 Binary files /dev/null and b/apps/website/public/images/blog/multi-region-region-per-customer.png differ diff --git a/apps/website/public/images/blog/multi-region-render-cloudflare-load-balancer.png b/apps/website/public/images/blog/multi-region-render-cloudflare-load-balancer.png new file mode 100644 index 000000000..80834b932 Binary files /dev/null and b/apps/website/public/images/blog/multi-region-render-cloudflare-load-balancer.png differ diff --git a/apps/website/public/images/blog/multi-region-route53-ecs-regional-routing.png b/apps/website/public/images/blog/multi-region-route53-ecs-regional-routing.png new file mode 100644 index 000000000..36e7e3c40 Binary files /dev/null and b/apps/website/public/images/blog/multi-region-route53-ecs-regional-routing.png differ diff --git a/apps/website/public/images/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing-cache.png b/apps/website/public/images/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing-cache.png new file mode 100644 index 000000000..430bc0d68 Binary files /dev/null and b/apps/website/public/images/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing-cache.png differ diff --git a/apps/website/public/images/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing.png b/apps/website/public/images/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing.png new file mode 100644 index 000000000..f53fabe0c Binary files /dev/null and b/apps/website/public/images/blog/we-built-a-billing-company-but-didnt-replace-stripe-billing.png differ diff --git a/bun.lock b/bun.lock index a7080fe7a..e3c8202ff 100644 --- a/bun.lock +++ b/bun.lock @@ -84,17 +84,36 @@ "typescript": "^5", }, }, - "apps/mcp-server": { - "name": "@autumn/mcp-server", + "apps/leaf": { + "name": "@autumn/leaf", + "version": "0.0.1", "dependencies": { + "@autumn/auth": "workspace:*", + "@autumn/logging": "workspace:*", "@autumn/mcp": "workspace:*", + "@autumn/shared": "workspace:*", + "@chat-adapter/slack": "^4.29.0", + "@chat-adapter/state-pg": "^4.29.0", "@hono/node-server": "^1.19.5", + "@mastra/braintrust": "^1.1.3", + "@mastra/core": "^1.36.0", + "@mastra/mcp": "^1.8.0", + "@mastra/observability": "^1.14.1", + "@mendable/firecrawl-js": "^4.25.1", + "autoevals": "^0.0.132", + "braintrust": "^3.14.0", + "chat": "^4.29.0", + "date-fns": "^4.1.0", + "drizzle-orm": "catalog:", + "e2b": "^2.8.4", "hono": "4.12.7", + "postgres": "catalog:", + "zod": "^3.25.23", }, "devDependencies": { - "@types/bun": "^1.2.13", - "@types/node": "^18.19.3", - "typescript": "~5.8.3", + "@types/bun": "^1.3.1", + "@types/node": "^25.0.7", + "typescript": "^5.7.3", }, }, "apps/sdk-test": { @@ -162,6 +181,9 @@ "packages/ai-sdk": { "name": "@useautumn/ai-sdk", "version": "0.0.1", + "dependencies": { + "@ai-sdk/provider": "^3.0.0", + }, "devDependencies": { "@types/node": "^24.9.1", "tsup": "^8.4.0", @@ -242,6 +264,18 @@ "typescript": "^5", }, }, + "packages/auth": { + "name": "@autumn/auth", + "version": "0.0.1", + "dependencies": { + "@autumn/shared": "workspace:*", + }, + "devDependencies": { + "@types/bun": "^1.2.13", + "@types/node": "^18.19.3", + "typescript": "~5.8.3", + }, + }, "packages/autumn-js": { "name": "autumn-js", "version": "1.2.17", @@ -285,10 +319,25 @@ "name": "@autumn/ksuid", "version": "1.0.0", }, + "packages/logging": { + "name": "@autumn/logging", + "version": "0.0.1", + "dependencies": { + "@axiomhq/pino": "^1.3.1", + "pino": "^9.6.0", + }, + "devDependencies": { + "@types/bun": "^1.2.13", + "@types/node": "^18.19.3", + "typescript": "~5.8.3", + }, + }, "packages/mcp": { "name": "@autumn/mcp", "version": "0.0.1", "dependencies": { + "@autumn/auth": "workspace:*", + "@autumn/logging": "workspace:*", "@autumn/shared": "workspace:*", "@axiomhq/js": "^1.6.1", "@mastra/core": "^1.36.0", @@ -382,6 +431,7 @@ "dependencies": { "@ai-sdk/anthropic": "^3.0.9", "@anthropic-ai/sdk": "^0.32.1", + "@autumn/auth": "workspace:*", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", "@autumn/stripe-sync": "workspace:*", @@ -414,7 +464,7 @@ "@opentelemetry/sdk-trace-base": "^2.6.0", "@posthog/ai": "^7.4.2", "@puzzmo/revenue-cat-webhook-types": "^1.1.0", - "@react-email/components": "^0.0.42", + "@react-email/components": "0.0.42", "@sentry/bun": "catalog:", "@supabase/supabase-js": "^2.46.2", "@tinybirdco/sdk": "^0.0.69", @@ -473,8 +523,9 @@ "posthog-node": "^5.20.0", "puppeteer-core": "^24.14.0", "qs": "^6.14.0", - "react": "^18.2.0", - "resend": "^4.1.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "resend": "4.8.0", "semver": "^7.7.2", "stripe": "catalog:", "svix": "^1.45.1", @@ -491,8 +542,8 @@ "@types/mocha": "^10.0.10", "@types/node": "^25.0.7", "@types/pg": "8.11.10", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", "@types/ws": "^8.18.1", "artillery": "^2.0.30", "cross-env": "^7.0.3", @@ -516,6 +567,7 @@ "@orpc/contract": "catalog:", "@orpc/openapi": "^1.13.4", "@orpc/zod": "^1.13.4", + "better-auth": "catalog:", "date-fns": "^4.1.0", "decimal.js": "^10.5.0", "dotenv": "^16.5.0", @@ -625,13 +677,11 @@ "unrs-resolver", ], "overrides": { - "@better-auth/core": "1.6.5", "@better-auth/passkey": "1.6.5", "@isaacs/brace-expansion": "5.0.1", "@modelcontextprotocol/sdk": "1.29.0", "@smithy/config-resolver": "^4.4.0", "@types/pg": "8.11.10", - "better-auth": "1.6.5", "diff": "8.0.3", "esbuild": "0.25.0", "fast-xml-parser": "5.3.4", @@ -665,9 +715,9 @@ "packages": { "@a2a-js/sdk": ["@a2a-js/sdk@0.3.13", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-BZr0f9JVNQs3GKOM9xINWCh6OKIJWZFPyqqVqTym5mxO2Eemc6I/0zL7zWnljHzGdaf5aZQyQN5xa6PSH62q+A=="], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.116", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k8P17w7Eho5Y4l3tZrYxqQdffkI4xwtl8GCxkZs+JdMWZhyrLLlozqWkKLaWrCSlEYQOeIhEnQLhqQgYYU86Rw=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.125", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tocl7cUDoTpmhZqeW8XVKMMznZQwwQAEunF0VyNKmf64qt8NbMIAEiet/vRMzh7Jr9WcFeb6EZjmhLTP4Qx2Og=="], "@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -681,9 +731,7 @@ "@ai-sdk/provider-v6": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "@ai-sdk/react": ["@ai-sdk/react@3.0.187", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.185", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-TJBhR18F7BOLj/mBLYoZNZVkQgDc7DBVz2ZyQEecpKnO+EAhdx3QA2q8BnEVqwNlDfOKOa6dr7ka4hU0wy/wDw=="], - - "@ai-sdk/ui-utils-v5": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], + "@ai-sdk/react": ["@ai-sdk/react@3.0.199", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.197", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-0QmG6nd1iDTTWpWbQbE5qgSpEm0XkBvrOn1L1rSzBhG5+7BasckcjTF3CQMwUxdvozMMYRNOGXLQODs/1+a3NQ=="], "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], @@ -695,7 +743,7 @@ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.32.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg=="], - "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], + "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.12.0", "", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-5F2ob4cMYezbaUGAk+YltbDvb9BFIghN92ubct9Ho/0MFx4FkChCxYV99NkU6Kx+RAgaqBV6yxKuWreQ6K8SOw=="], "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.3.1", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.8.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw=="], @@ -715,13 +763,17 @@ "@asyncapi/specs": ["@asyncapi/specs@6.8.1", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-czHoAk3PeXTLR+X8IUaD+IpT+g+zUvkcgMDJVothBsan+oHN3jfcFcFUNdOPAAFoUCQN1hXF1dWuphWy05THlA=="], + "@autumn/auth": ["@autumn/auth@workspace:packages/auth"], + "@autumn/docs": ["@autumn/docs@workspace:apps/docs"], "@autumn/ksuid": ["@autumn/ksuid@workspace:packages/ksuid"], - "@autumn/mcp": ["@autumn/mcp@workspace:packages/mcp"], + "@autumn/leaf": ["@autumn/leaf@workspace:apps/leaf"], - "@autumn/mcp-server": ["@autumn/mcp-server@workspace:apps/mcp-server"], + "@autumn/logging": ["@autumn/logging@workspace:packages/logging"], + + "@autumn/mcp": ["@autumn/mcp@workspace:packages/mcp"], "@autumn/openapi": ["@autumn/openapi@workspace:packages/openapi"], @@ -751,83 +803,75 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-cloudwatch": ["@aws-sdk/client-cloudwatch@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/middleware-compression": "^4.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-lklRpzp5ZVXcVmGmZ9LUZN2jnJm4HZoB4WVC8yDMWolf5oyQZoC68+on2+F9gwNlux+7y6SD5FhXWhOQUTDqEg=="], + "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.2", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-PIha+kauTbp6IRmOpYktPTrlfrrSqDVixvhO/EUOFOf62DPX81CaJoHJreuA1m9HYpSKyXf99BKjU1dvJPeUfw=="], - "@aws-sdk/client-cloudwatch-logs": ["@aws-sdk/client-cloudwatch-logs@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0ck+MgIMIfM+VY2LJTo3Nwwxe2skjmmCoFmuR6k6ZeLCi3xp6oKKJtJbl3UJN/vrWmEmZp8JhtBR9w09TV5O5g=="], + "@aws-sdk/client-cloudwatch": ["@aws-sdk/client-cloudwatch@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/middleware-compression": "^4.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-A/PG9D709oSFwutfP5CyATQJzc2IyhicMirByvkLkrj8ezSunB0/+ZRJdPibONolE0P2+kneQqHttanspULpAw=="], - "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-eJUSxEwhz9fKmjhRgOvFTuJ+SjCu15SpgYo3aSJ6rjs2IaWi2F8NcvOuejHb0a01a/J71/9bdjUDrQLH2kUmkg=="], + "@aws-sdk/client-cloudwatch-logs": ["@aws-sdk/client-cloudwatch-logs@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-oVYWCAjcK6hq8atqfouNSu0jygOdhMcrH2sZIxNXICLzVD5jeOHD4pgr+W08uwN5Dfzq9iEKR7VFCV6l05Dj1A=="], - "@aws-sdk/client-ec2": ["@aws-sdk/client-ec2@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/middleware-sdk-ec2": "^3.972.25", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Ajd2vwz889wEaSC37o4NndqQA6hd3nAONxu6evtkWPVM7pJyhulgcRocmdBef7N0erwTUfaPD1GD1xgNrBiG0w=="], + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fLwNblkowkRyuxdVehlHVOnr/7bBf8Y1UGYdhhpuMPHOQL2QTY6kLcQ+EV1BhTQG1p4ATwaONNJsIk44hxEGMA=="], - "@aws-sdk/client-ecs": ["@aws-sdk/client-ecs@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-HABKZRDWmyZTgRxmM2vMaTcaNyJg4PadF/Turc/t/4FVNxjiO2qjNEa13O+P9DM/vEYSgpMl5HOhWEw2acs0ww=="], + "@aws-sdk/client-ec2": ["@aws-sdk/client-ec2@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/middleware-sdk-ec2": "^3.972.32", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-RVVqRR6SBQwTHnttHXj/QbUwagA1a86tqJhyDik9/REviSfu7XGJ61RlXmDfyeU76bhv3NJ4mW2EZrhWO2/0hQ=="], - "@aws-sdk/client-iam": ["@aws-sdk/client-iam@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-p6AJofTz9ehvKf02DhW4rJd8ivqYqz3VCnt3k7cyTFoCzveaAY0XZ4XgSHrE1BEdqClQxlHz1c8NLxgUdh7FMw=="], + "@aws-sdk/client-ecs": ["@aws-sdk/client-ecs@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-SVVmsVKJpy9cGoKAr6etP2i/MkymCgesZRlaHra3M2dNxTEldYRxQ5sqC3H5qj6qhF8XtWELl3G3loTAN7eJXA=="], - "@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-ryEYNVdilyWkKsOs/7Xy/l7+qjtSz4sll8NpcWD6AtONxjG/5OMaAhxxDkQb4iBoNMKnISxsARzQAp/Wa8pXIg=="], + "@aws-sdk/client-iam": ["@aws-sdk/client-iam@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5BPya0CvFQvgI4Ru2rzoQ/GQLZ8LevVNXHPNT/q0otms1Yg4rRi2G4/yoN6zmsU3Og4CHSOSuS287zwFVNTlQA=="], - "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1048.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/middleware-bucket-endpoint": "^3.972.13", "@aws-sdk/middleware-expect-continue": "^3.972.12", "@aws-sdk/middleware-flexible-checksums": "^3.974.19", "@aws-sdk/middleware-location-constraint": "^3.972.10", "@aws-sdk/middleware-sdk-s3": "^3.972.40", "@aws-sdk/middleware-ssec": "^3.972.10", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-SrJn5FteqqtcDBgQIvqLKk3Qn/2vSsi5XR03I53EDDR4CbCdLysVSNgUnjVncEECMua9Pz+nxO0/lEx3TP+6mA=="], + "@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xn2c+C2/le5Iya243PVsH+s4yhs0Oo5wK+CopVJfhZ2uH6WNEWre+wQ6D/q8FkFZaaPP/cwejVBVUEaMsD98Kw=="], - "@aws-sdk/client-scheduler": ["@aws-sdk/client-scheduler@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-9p9bHxb5E9pRm4gPIUVS9Zz248KzIr4AWHkEMqxbfG3M1shru5a7hH98RJxNvangz5sb81EhLEtULB6/9XmKGw=="], + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1063.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/middleware-flexible-checksums": "^3.974.27", "@aws-sdk/middleware-sdk-s3": "^3.972.48", "@aws-sdk/signature-v4-multi-region": "^3.996.32", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ETn+vvmZVK1MmOZwVBXmWANpmD5iTbzojIqyEIoZ86qo+8oWy35S8QyQNE/ZDI+WHgMU1dS+VSYbpRl1QkEySg=="], - "@aws-sdk/client-sqs": ["@aws-sdk/client-sqs@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/middleware-sdk-sqs": "^3.972.24", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-GGCSdU3rFc77KxgL7LmJeY169MEjsQmbLwHpnWpopavA1nxz2htJvLSPghuyGhTVpgqnp4HqwWiczlkjV3UADw=="], + "@aws-sdk/client-scheduler": ["@aws-sdk/client-scheduler@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-QGepeFeLEkji0WNYJ5AUFd+9f/HIm0ZURjaUGCtACpyfj7Pdzbm1vsIb6MuLlQVHIV+X/+ptFlzX2blFuGuQqg=="], - "@aws-sdk/client-ssm": ["@aws-sdk/client-ssm@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Q/9t+BeHQnbsYCmvNwh7FDs5JQATuqoSRPdhYZ5hij/mG431VTDWVxqwD4Gze+4AMDVPcOTi9Jv0fXYc1BJVRg=="], + "@aws-sdk/client-sqs": ["@aws-sdk/client-sqs@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/middleware-sdk-sqs": "^3.972.29", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-2Oi4FpC1jJ10gpBDfXw0sMh6GvkYmnkaTo28AnjRUrCRe5JkvEWvDwKKksQM67UtwO5AoomKAqFKVzID1zW/fQ=="], + + "@aws-sdk/client-ssm": ["@aws-sdk/client-ssm@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-qLXNofwgCB7/3mhWR9pz/bnNgyvSxjp38SvhTazESrt3mLtX9+kMg8UOKTZol0mvZg209UVIS82xY1JhJDtGpA=="], "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.598.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.598.0", "@aws-sdk/middleware-host-header": "3.598.0", "@aws-sdk/middleware-logger": "3.598.0", "@aws-sdk/middleware-recursion-detection": "3.598.0", "@aws-sdk/middleware-user-agent": "3.598.0", "@aws-sdk/region-config-resolver": "3.598.0", "@aws-sdk/types": "3.598.0", "@aws-sdk/util-endpoints": "3.598.0", "@aws-sdk/util-user-agent-browser": "3.598.0", "@aws-sdk/util-user-agent-node": "3.598.0", "@smithy/config-resolver": "^3.0.2", "@smithy/core": "^2.2.1", "@smithy/fetch-http-handler": "^3.0.2", "@smithy/hash-node": "^3.0.1", "@smithy/invalid-dependency": "^3.0.1", "@smithy/middleware-content-length": "^3.0.1", "@smithy/middleware-endpoint": "^3.0.2", "@smithy/middleware-retry": "^3.0.4", "@smithy/middleware-serde": "^3.0.1", "@smithy/middleware-stack": "^3.0.1", "@smithy/node-config-provider": "^3.1.1", "@smithy/node-http-handler": "^3.0.1", "@smithy/protocol-http": "^4.0.1", "@smithy/smithy-client": "^3.1.2", "@smithy/types": "^3.1.0", "@smithy/url-parser": "^3.0.1", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", "@smithy/util-defaults-mode-browser": "^3.0.4", "@smithy/util-defaults-mode-node": "^3.0.4", "@smithy/util-endpoints": "^2.0.2", "@smithy/util-middleware": "^3.0.1", "@smithy/util-retry": "^3.0.1", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-nOI5lqPYa+YZlrrzwAJywJSw3MKVjvu6Ge2fCqQUNYMfxFB0NAaDFnl0EPjXi+sEbtCuz/uWE77poHbqiZ+7Iw=="], "@aws-sdk/client-sso-oidc": ["@aws-sdk/client-sso-oidc@3.600.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/client-sts": "3.600.0", "@aws-sdk/core": "3.598.0", "@aws-sdk/credential-provider-node": "3.600.0", "@aws-sdk/middleware-host-header": "3.598.0", "@aws-sdk/middleware-logger": "3.598.0", "@aws-sdk/middleware-recursion-detection": "3.598.0", "@aws-sdk/middleware-user-agent": "3.598.0", "@aws-sdk/region-config-resolver": "3.598.0", "@aws-sdk/types": "3.598.0", "@aws-sdk/util-endpoints": "3.598.0", "@aws-sdk/util-user-agent-browser": "3.598.0", "@aws-sdk/util-user-agent-node": "3.598.0", "@smithy/config-resolver": "^3.0.2", "@smithy/core": "^2.2.1", "@smithy/fetch-http-handler": "^3.0.2", "@smithy/hash-node": "^3.0.1", "@smithy/invalid-dependency": "^3.0.1", "@smithy/middleware-content-length": "^3.0.1", "@smithy/middleware-endpoint": "^3.0.2", "@smithy/middleware-retry": "^3.0.4", "@smithy/middleware-serde": "^3.0.1", "@smithy/middleware-stack": "^3.0.1", "@smithy/node-config-provider": "^3.1.1", "@smithy/node-http-handler": "^3.0.1", "@smithy/protocol-http": "^4.0.1", "@smithy/smithy-client": "^3.1.2", "@smithy/types": "^3.1.0", "@smithy/url-parser": "^3.0.1", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", "@smithy/util-defaults-mode-browser": "^3.0.4", "@smithy/util-defaults-mode-node": "^3.0.4", "@smithy/util-endpoints": "^2.0.2", "@smithy/util-middleware": "^3.0.1", "@smithy/util-retry": "^3.0.1", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-7+I8RWURGfzvChyNQSyj5/tKrqRbzRl7H+BnTOf/4Vsw1nFOi5ROhlhD4X/Y0QCTacxnaoNcIrqnY7uGGvVRzw=="], - "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-CE/RhHaIoLmmlKva/rmNB0A0/WWta+GozzTGl5kNc8fAnlR5iA0ygz8zw6VQRwFWz2b8T56qA8lapKcslztHfA=="], + "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1063.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/signature-v4-multi-region": "^3.996.32", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5SZhiVKuufk/dUcfNr6hZymQSTSnh12paXRLq+YS8OmsozpChNG0wHaKH/hXA/mdGwLxgdudqHAHxygbCiwzqQ=="], - "@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.18", "", { "dependencies": { "@aws-sdk/types": "^3.973.11", "@aws-sdk/xml-builder": "^3.972.28", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g=="], - "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA=="], + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.42", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-94W7f8xVsdLEjv3TY8R+beoFL0pIRduiGZdqMfIVMvQfn6q9IA3SgE2mIQluu3VCULn8PopB/gx7Fns8ETn/1Q=="], - "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.35", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-mMQsBJv40oi5QdqRj4Xbc9jTlWMxqWfs5zWu+RhbOuF5F0AxxWXT70hm0abOmLbF2M/Tkuygs01H4eWIQMfoMw=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.40", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-env": "^3.972.44", "@aws-sdk/credential-provider-http": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.49", "@aws-sdk/credential-provider-process": "^3.972.44", "@aws-sdk/credential-provider-sso": "^3.972.49", "@aws-sdk/credential-provider-web-identity": "^3.972.49", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-login": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.52", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.44", "@aws-sdk/credential-provider-http": "^3.972.46", "@aws-sdk/credential-provider-ini": "^3.972.50", "@aws-sdk/credential-provider-process": "^3.972.44", "@aws-sdk/credential-provider-sso": "^3.972.49", "@aws-sdk/credential-provider-web-identity": "^3.972.49", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-7QX+PbyiWBEOVipJq8Nke/TqXT6lAPLE7fvTaopa39/IVWuLfS+Fzdy71sZJONf/mLGgmtj6aU17+REw3+aRrw=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.43", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/token-providers": "3.1063.0", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/token-providers": "3.1049.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ=="], + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1063.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1063.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/credential-provider-cognito-identity": "^3.972.42", "@aws-sdk/credential-provider-env": "^3.972.44", "@aws-sdk/credential-provider-http": "^3.972.46", "@aws-sdk/credential-provider-ini": "^3.972.50", "@aws-sdk/credential-provider-login": "^3.972.49", "@aws-sdk/credential-provider-node": "^3.972.52", "@aws-sdk/credential-provider-process": "^3.972.44", "@aws-sdk/credential-provider-sso": "^3.972.49", "@aws-sdk/credential-provider-web-identity": "^3.972.49", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ApW861WX8h7wKDKRNj7Dyne7awtq/PHrJVSdr3NsE/rmuFUxSha6BFJJ1H0S1MD7hCqZjYqz2VPPmCXo3IKC9A=="], - "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1048.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-cognito-identity": "^3.972.34", "@aws-sdk/credential-provider-env": "^3.972.37", "@aws-sdk/credential-provider-http": "^3.972.39", "@aws-sdk/credential-provider-ini": "^3.972.41", "@aws-sdk/credential-provider-login": "^3.972.41", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.37", "@aws-sdk/credential-provider-sso": "^3.972.41", "@aws-sdk/credential-provider-web-identity": "^3.972.41", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-qradm+eSLJTWQLd/TOxlETL1rMQ/ozvr2iU7wga5hqoox/FiXV9VLtomv3Cqwa6GdpYGWI8ebfSu6mS18I1PyQ=="], - - "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.14", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Aaj0d+xbo1jJquBWJP0/9V/XZRYukO3LWIRp3dOLHmoFrYKb4YZ0aLefgVHfGcNOVBS2ZTq7L/n5JcrE7DaC+Q=="], - - "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-dA5pKTom/Ls9mgeyeaRBNQrRIVOLVjv4AmKOB0/e4yaiXEUy0gSz2d3liP8JHtYoCAEWySU1jWnyzwLOREN+4g=="], - - "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.20", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/crc64-nvme": "^3.972.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-NdnMVQCR1YjIcqFAiNLdBiOwr2DyQDB2IiXQrBhzolKOv32ae4d4Ll7IzLMi04eMHiq/o/Y/GjFuVjF9HuG0QA=="], + "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.27", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.2", "tslib": "^2.6.2" } }, "sha512-bZqezPLdllFC4VAeV/f+EIc/hz56ab3TD/+4zNCgOgmG5ZHAE5dMHrX1gtTwdcQXbPr3KR7x3zTC3zuCTE6+ng=="], "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.598.0", "", { "dependencies": { "@aws-sdk/types": "3.598.0", "@smithy/protocol-http": "^4.0.1", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-WiaG059YBQwQraNejLIi0gMNkX7dfPZ8hDIhvMr5aVPRbaHH8AYF3iNSsXYCHvA2Cfa1O9haYXsuMF9flXnCmA=="], - "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.598.0", "", { "dependencies": { "@aws-sdk/types": "3.598.0", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-bxBjf/VYiu3zfu8SYM2S9dQQc3tz5uBAOcPz/Bt8DyyK3GgOpjhschH/2XuUErsoUO1gDJqZSdGOmuHGZQn00Q=="], "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.598.0", "", { "dependencies": { "@aws-sdk/types": "3.598.0", "@smithy/protocol-http": "^4.0.1", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-vjT9BeFY9FeN0f8hm2l6F53tI0N5bUq6RcDkQXKNabXBnQxKptJRad6oP2X5y3FoVfBLOuDkQgiC2940GIPxtQ=="], - "@aws-sdk/middleware-sdk-ec2": ["@aws-sdk/middleware-sdk-ec2@3.972.26", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-sHc/vgigKtDZa1D19Go9jQT/IjMACinFnwg7I+vmhdie3rPFjB5VF57T9cDgcG0TAQnhBTkXSm1w4+ZlKR0bEA=="], + "@aws-sdk/middleware-sdk-ec2": ["@aws-sdk/middleware-sdk-ec2@3.972.32", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pYIQfl8jN0HVuz6iDisD4wxA59MgLLp2mAs6ziPPA4OMGXkQ5eDQgZRU1K7d5b6tFcdTmQTYD+pcqqUpeG5Plw=="], - "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-M4T2I2WPuH5WQpU8Tsp+u2bcO29zGRkU14ATzuqb9I4xh8tzsLqtp4hzaJM5aO2dhMZnHDzyQwSFVgc3XbnoGg=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/signature-v4-multi-region": "^3.996.32", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-MRTqx8wD/T3REt6LTT3/yN8rrp6+xIHrbUekkDYJTYWVch70mwtdJBovR4qKJz1jIPlbN+9R/Sn6R04BfsglzA=="], - "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.24", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-ej3vwWFzTP2B0FxlU2JelMaxplEncflFv2ARsoMQ9TXI/yfmsPEZw8zkOdsQp3rMEJ/vN7iKTYDYIcpeMmDRoQ=="], - - "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw=="], + "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.29", "", { "dependencies": { "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-huMx6RhC/tF9K82GZpnox8vK26Pt+6QASMrJiyup99ffr+HBT3asD4soa9BuD3WeLd64XYdOeuUjIjqKgVu5gA=="], "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.598.0", "", { "dependencies": { "@aws-sdk/types": "3.598.0", "@aws-sdk/util-endpoints": "3.598.0", "@smithy/protocol-http": "^4.0.1", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-4tjESlHG5B5MdjUaLK7tQs/miUtHbb6deauQx8ryqSBYOhfHVgb1ZnzvQR0bTrhpqUg0WlybSkDaZAICf9xctg=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.10", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.17", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.18", "@aws-sdk/signature-v4-multi-region": "^3.996.32", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig=="], "@aws-sdk/protocol-http": ["@aws-sdk/protocol-http@3.374.0", "", { "dependencies": { "@smithy/protocol-http": "^1.1.0", "tslib": "^2.5.0" } }, "sha512-9WpRUbINdGroV3HiZZIBoJvL2ndoWk39OfwxWs2otxByppJZNN14bg/lvCx5e8ggHUti7IBk5rb0nqQZ4m05pg=="], @@ -835,15 +879,15 @@ "@aws-sdk/signature-v4": ["@aws-sdk/signature-v4@3.374.0", "", { "dependencies": { "@smithy/signature-v4": "^1.0.1", "tslib": "^2.5.0" } }, "sha512-2xLJvSdzcZZAg0lsDLUAuSQuihzK0dcxIK7WmfuJeF7DGKJFmp9czQmz5f3qiDz6IDQzvgK1M9vtJSVCslJbyQ=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.32", "", { "dependencies": { "@aws-sdk/types": "^3.973.11", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1049.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1063.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.18", "@aws-sdk/nested-clients": "^3.997.17", "@aws-sdk/types": "^3.973.11", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + "@aws-sdk/types": ["@aws-sdk/types@3.973.11", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg=="], "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.598.0", "", { "dependencies": { "@aws-sdk/types": "3.598.0", "@smithy/types": "^3.1.0", "@smithy/util-endpoints": "^2.0.2", "tslib": "^2.6.2" } }, "sha512-Qo9UoiVVZxcOEdiOMZg3xb1mzkTxrhd4qSlg5QQrfWPJVx/QOg+Iy0NtGxPtHtVZNHZxohYwDwV/tfsnDSE2gQ=="], - "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.6", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw=="], "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.598.0", "", { "dependencies": { "@aws-sdk/types": "3.598.0", "@smithy/types": "^3.1.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-36Sxo6F+ykElaL1mWzWjlg+1epMpSe8obwhCN1yGE7Js9ywy5U6k6l+A3q3YM9YRbm740sNxncbwLklMvuhTKw=="], @@ -851,7 +895,7 @@ "@aws-sdk/util-utf8-browser": ["@aws-sdk/util-utf8-browser@3.259.0", "", { "dependencies": { "tslib": "^2.3.1" } }, "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.28", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], @@ -865,7 +909,7 @@ "@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], - "@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], + "@azure/core-client": ["@azure/core-client@1.10.2", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ=="], "@azure/core-http-compat": ["@azure/core-http-compat@2.4.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2" }, "peerDependencies": { "@azure/core-client": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0" } }, "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA=="], @@ -885,89 +929,89 @@ "@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], - "@azure/msal-browser": ["@azure/msal-browser@5.10.1", "", { "dependencies": { "@azure/msal-common": "16.6.1" } }, "sha512-hTbvOi9Ko2Jvn+G/fSmjzHf9WbNcf/o3epMtbeGx/pMwMrVAbi6OgCJVeCfsAb8IybSRpaCSc4EDRlYAhgngUQ=="], + "@azure/msal-browser": ["@azure/msal-browser@5.11.0", "", { "dependencies": { "@azure/msal-common": "16.6.2" } }, "sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg=="], - "@azure/msal-common": ["@azure/msal-common@16.6.1", "", {}, "sha512-VxKdEtUwDuLD0F1hOQP7kye0YadZxFJfv37Em440geEf/w9uggKnHpRrqwZJOdxmPUOdhZ9kyRtKuAJW8wUcRg=="], + "@azure/msal-common": ["@azure/msal-common@16.6.2", "", {}, "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA=="], - "@azure/msal-node": ["@azure/msal-node@5.2.1", "", { "dependencies": { "@azure/msal-common": "16.6.1", "jsonwebtoken": "^9.0.0" } }, "sha512-tmQiQ2HvtzaeLqYGy3BemiPOSGPY4wCy1IW5zDWITKSs/s35WEd7Zij/hCxvUdAOzj6U3qnyaGbYXY91ortFEQ=="], + "@azure/msal-node": ["@azure/msal-node@5.2.2", "", { "dependencies": { "@azure/msal-common": "16.6.2", "jsonwebtoken": "^9.0.0" } }, "sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q=="], - "@azure/storage-blob": ["@azure/storage-blob@12.31.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.3", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.2.0", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.5", "@azure/logger": "^1.1.4", "@azure/storage-common": "^12.3.0", "events": "^3.0.0", "tslib": "^2.8.1" } }, "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg=="], + "@azure/storage-blob": ["@azure/storage-blob@12.32.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.3", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.2.0", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.5", "@azure/logger": "^1.1.4", "@azure/storage-common": "^12.4.0", "events": "^3.0.0", "tslib": "^2.8.1" } }, "sha512-80LzSNnFQye2LCCBFghAJS6jJQJ7N4bfgZ6qDMgVGRtugZ7TLDKQZ2hczMigmZH3jAcMRdma/IygsC5+0gT7Tw=="], - "@azure/storage-common": ["@azure/storage-common@12.3.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.2.0", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.1.4", "events": "^3.3.0", "tslib": "^2.8.1" } }, "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ=="], + "@azure/storage-common": ["@azure/storage-common@12.4.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.2.0", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.1.4", "events": "^3.3.0", "tslib": "^2.8.1" } }, "sha512-kNhJKMxQb374KOVt63CZnGIpDcrKNzJeyANLJymxE9mCJSdRGzb+Iv9oSIiCj6tNMLypr530b9ObOiA/5OvwOg=="], - "@azure/storage-queue": ["@azure/storage-queue@12.29.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.3", "@azure/core-http-compat": "^2.0.0", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.3", "@azure/logger": "^1.1.4", "@azure/storage-common": "^12.2.0", "tslib": "^2.8.1" } }, "sha512-p02H+TbPQWSI/SQ4CG+luoDvpenM+4837NARmOE4oPNOR5vAq7qRyeX72ffyYL2YLnkcyxETh28/bp/TiVIM+g=="], + "@azure/storage-queue": ["@azure/storage-queue@12.30.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.3", "@azure/core-http-compat": "^2.0.0", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.3", "@azure/logger": "^1.1.4", "@azure/storage-common": "^12.4.0", "tslib": "^2.8.1" } }, "sha512-204lc/W0nnZy0/JXGXAVsQG9LmRWGVrh28uxkWd6lV5/G/vHlFZOLxiTS5DUdLcnZ+OHhsClRJnMWgHX2X0vdA=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.3", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.29.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA=="], + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], - "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], - "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA=="], + "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q=="], - "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-jsx": "^7.28.6", "@babel/types": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow=="], + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], - "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.27.1", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q=="], + "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], - "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA=="], + "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA=="], - "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], - "@babel/preset-react": ["@babel/preset-react@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-transform-react-display-name": "^7.28.0", "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ=="], + "@babel/preset-react": ["@babel/preset-react@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-transform-react-display-name": "^7.29.7", "@babel/plugin-transform-react-jsx": "^7.29.7", "@babel/plugin-transform-react-jsx-development": "^7.29.7", "@babel/plugin-transform-react-pure-annotations": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA=="], - "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], - "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@base-ui/react": ["@base-ui/react@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.2.9", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A=="], @@ -1019,6 +1063,20 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + "@braintrust/bt-darwin-arm64": ["@braintrust/bt-darwin-arm64@0.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jhL/X24ss4e4qMMdlXtxO8rA957Z77wJA59XXFHqpuaaVCd4pXE5JPUQBkms9dHcs4efJhY3Lx9zNQqoaeCqWg=="], + + "@braintrust/bt-darwin-x64": ["@braintrust/bt-darwin-x64@0.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-f4l25gVUpCJ99mFS9y+zmnYOcevtI7KLLvlLF/xOil2YCIx/KCM6AqS96jj6CJW74B7hYhd74dLnFKMFFL429w=="], + + "@braintrust/bt-linux-arm64": ["@braintrust/bt-linux-arm64@0.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-01GWsP/p17I3yy0kxkp+Yt8w5L28j3nFL+7iLCkQWtDdfCGkuRsnrIbrY8dbnqGo/wvgz7uPXBkCXoIuNsfoxw=="], + + "@braintrust/bt-linux-x64": ["@braintrust/bt-linux-x64@0.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-E/XwRuhPrZxD+IZgSbPCuj4gywBrim/g+tU0MjWxFohpMMkJJW2CxMCIO+6RVk8gTxlVh/ajCIjv2/Ph1Yugeg=="], + + "@braintrust/bt-linux-x64-musl": ["@braintrust/bt-linux-x64-musl@0.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-QLqlFsF6HKON5Vc0c8JfpQ4vrl4KjmInGF1Vsqy+ecVkgXVk8pwCVibb8Ea/udVpBQqctAaNMn7ZsmvWR2vO8Q=="], + + "@braintrust/bt-win32-arm64": ["@braintrust/bt-win32-arm64@0.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-0LSVXZ/tE79VVcpRjaTE+iTMQR4EKNC6tteAS01uKT34eID7OqJ7xJijZOthe11kGqMcX8nnNbdDrWHqLczDow=="], + + "@braintrust/bt-win32-x64": ["@braintrust/bt-win32-x64@0.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-JPo3xffJvW0OKowqpbh+XtlafpoZq8VXzhOcW2yQmHcN56Me2u9plPiXi4gzrpgz2cnFx6/EiJ1bTa3F25F0RA=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], "@bugsnag/cuid": ["@bugsnag/cuid@3.2.2", "", {}, "sha512-7onuYLTMqMmHE9BBPG0YER4nFsU1rB+me1/YIeMusqcLbVbKKuG9u9+BDVDpje5e0llkkrVNOKYwmzM9DRIo7A=="], @@ -1027,6 +1085,12 @@ "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + "@chat-adapter/shared": ["@chat-adapter/shared@4.30.0", "", { "dependencies": { "chat": "4.30.0" } }, "sha512-IuYtbn/p1FBXvp7JYGEMLCt07GHOMlyjx7OlZXPJwLTravcyJuP7Q6N31r6c1yubMhM8PLb8eT8l/YnjwYjs9Q=="], + + "@chat-adapter/slack": ["@chat-adapter/slack@4.30.0", "", { "dependencies": { "@chat-adapter/shared": "4.30.0", "@slack/socket-mode": "^2.0.5", "@slack/web-api": "^7.14.0", "chat": "4.30.0" } }, "sha512-ZB+G/JBKmaXzvl+DuUQPBb/gCwXP3fOtUK5Cyj6wLbbeeDYzi3NQPvpvieVum/GI4iuR8OUVcNAnVQiH6P6DOQ=="], + + "@chat-adapter/state-pg": ["@chat-adapter/state-pg@4.30.0", "", { "dependencies": { "chat": "4.30.0", "pg": "^8.20.0" } }, "sha512-8qymxX34Fg7B0PJCoYi60Bck68Gnd4cW4U8hgvJBc5ZHm8K4XT4srV8G3AeDT7rVSmrW5diidcmROo8Z1ke6iw=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], "@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="], @@ -1041,17 +1105,21 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], - "@clickhouse/client": ["@clickhouse/client@1.18.5", "", { "dependencies": { "@clickhouse/client-common": "1.18.5" } }, "sha512-4FfoyMkFWhsdNMuXsoEL6l3c12svA63BBJBtDo9SrxRZ14RdmN6jLr/rF3f84BK8cFoxETZCSeKlsbk6NNYebw=="], + "@clickhouse/client": ["@clickhouse/client@1.20.0", "", { "dependencies": { "@clickhouse/client-common": "1.20.0" } }, "sha512-LfHZ9bZZhc7KrNFVa9v73JMqwsP+m/5SgwdKMmxze4Urcw6pE0F7RNog3Lzx3GKRnJr3Hd15uDlIbqaDa9BbgA=="], - "@clickhouse/client-common": ["@clickhouse/client-common@1.18.5", "", {}, "sha512-g9LwcS1dvkatKDsIjT1PwUHldsiYzwdKAB0nXfd9APLd+t4PrNJa+my+dXcqJdmcWyhWjKLP/2/ztBwgxp+sbQ=="], + "@clickhouse/client-common": ["@clickhouse/client-common@1.20.0", "", {}, "sha512-s0oDSwxQyJO/Xwne6sNE7xTAlms72Hq2AHzHAB9oSOBfiaXTzQOyHQrhufJ9ldPJTwr4L47/RxG1i6I0I8Xy9A=="], "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@connectrpc/connect": ["@connectrpc/connect@2.0.0-rc.3", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }, "sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ=="], + + "@connectrpc/connect-web": ["@connectrpc/connect-web@2.0.0-rc.3", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0", "@connectrpc/connect": "2.0.0-rc.3" } }, "sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@datadog/datadog-api-client": ["@datadog/datadog-api-client@1.58.0", "", { "dependencies": { "@types/buffer-from": "^1.1.0", "@types/node": "*", "@types/pako": "^1.0.3", "buffer-from": "^1.1.2", "cross-fetch": "^3.1.5", "form-data": "^4.0.4", "loglevel": "^1.8.1", "pako": "^2.0.4" } }, "sha512-aDCMu+qEXjr8PHkT8XvY7FTzVNi2z7yVJy39I4s0lVtb+sdth4ZppMC1aCDwGJQMHK4J0aect+xKScuHy/Xilg=="], - "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], "@date-fns/utc": ["@date-fns/utc@2.1.1", "", {}, "sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA=="], @@ -1075,7 +1143,7 @@ "@depot/cli-win32-x64": ["@depot/cli-win32-x64@0.0.1-cli.2.80.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9CRcc7D0/x4UrBkDuc35WVPMQG5gKMD1JckGLEl6VREE0Ppdny6n+hunQ8prwVc8aqzKG134XCC2U4DUjYg18A=="], - "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.66.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-qlQFhHUjhRDybrinqLAD0MClVZDOrsq80O8eD5iSjz3Qa/4f3Jg7SQrOaSobrRyP1QaWIYLGtGpj2c7H0D8NUw=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.71.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -1181,7 +1249,7 @@ "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], @@ -1219,6 +1287,8 @@ "@hyperbrowser/sdk": ["@hyperbrowser/sdk@0.54.0", "", { "dependencies": { "form-data": "^4.0.1", "node-fetch": "2.7.0", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.1" } }, "sha512-QmbwMG6niqInlS0FfUWWzvv+DfyINHGnF8i/0oNuyyuiIdqSXnp+UG/74Ci+yz8QuyezmuKzBSp6dBXlVG0Glw=="], + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="], @@ -1307,9 +1377,9 @@ "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], - "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -1341,15 +1411,19 @@ "@kubiks/otel-drizzle": ["@kubiks/otel-drizzle@2.1.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "drizzle-orm": ">=0.28.0" } }, "sha512-9UHb0od3jwa6zTWMyEYPIZcUq5PDaziCmQLMLakSK2zeqy12SFZ3SAGWXJTgEr8valn/Wa+DKVs+Z3aqKQUpvg=="], - "@langchain/core": ["@langchain/core@1.1.47", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", "zod": "^3.25.76 || ^4" } }, "sha512-+fiPu6ZFnJMrZyKeM77OIVPoMPAY6OKWacnPlojHtXTbMMzb2cEOKAJV0U07cDl86NHSCIYYa0i4CyKZzXbHQQ=="], + "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], - "@langchain/langgraph": ["@langchain/langgraph@1.3.2", "", { "dependencies": { "@langchain/langgraph-checkpoint": "^1.0.2", "@langchain/langgraph-sdk": "~1.9.4", "@langchain/protocol": "^0.0.15", "@standard-schema/spec": "1.1.0", "uuid": "^10.0.0" }, "peerDependencies": { "@langchain/core": "^1.1.44", "zod": "^3.25.32 || ^4.2.0", "zod-to-json-schema": "^3.x" }, "optionalPeers": ["zod-to-json-schema"] }, "sha512-SL7Ktsr681R7da+1b2MVOWEbaCoFJOXEJPTGOjg4JIG4C7quWbTYC8DzxhcCxte6D/8cGp0rYDBnbKLXEpNqlA=="], + "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], - "@langchain/langgraph-checkpoint": ["@langchain/langgraph-checkpoint@1.0.2", "", { "dependencies": { "uuid": "^10.0.0" }, "peerDependencies": { "@langchain/core": "^1.1.44" } }, "sha512-F4E5Tr0nt8FGghgdscJtHw+ABzChOHeI80R7Y1pjIHdiJom6c2ieo76vL+FWiny80JmoGqhrVAEIWrw0cXKPxg=="], + "@langchain/core": ["@langchain/core@1.1.48", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", "zod": "^3.25.76 || ^4" } }, "sha512-fQU6Guyb1pwc2fEplmA8FPbKfOMAofjnyJzExevro0FxEiuGHE18Ov/ZHmT9trWCDTZRI9eW1VIc6aChxV8pAQ=="], - "@langchain/langgraph-sdk": ["@langchain/langgraph-sdk@1.9.4", "", { "dependencies": { "@langchain/protocol": "^0.0.15", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1", "uuid": "^13.0.0" }, "peerDependencies": { "@langchain/core": "^1.1.44", "react": "^18 || ^19", "react-dom": "^18 || ^19", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-hhASJGKa2MDJDtDkuIFdWGysMTog/HkYe0r6B6Gn1XqsURWnF7FIFl9diITAPOv1tB8YpyjnbpsBj/NkT5d+jQ=="], + "@langchain/langgraph": ["@langchain/langgraph@1.3.5", "", { "dependencies": { "@langchain/langgraph-checkpoint": "^1.0.4", "@langchain/langgraph-sdk": "~1.9.16", "@langchain/protocol": "^0.0.16", "@standard-schema/spec": "1.1.0", "uuid": "^14.0.0" }, "peerDependencies": { "@langchain/core": "^1.1.48", "zod": "^3.25.32 || ^4.2.0", "zod-to-json-schema": "^3.x" }, "optionalPeers": ["zod-to-json-schema"] }, "sha512-4LXO2VP+wNQTJlUhqr7PdsWLC7z2RF3CkfMq6WqYYWml1NQjnRvYddlVlviFtxs/DAVwlTaChx4QCa99BD8Hqg=="], - "@langchain/protocol": ["@langchain/protocol@0.0.15", "", {}, "sha512-MllvbpMjqHevUm+v94M422mH7XKN+wGCvJRBVROTWBotEDOATYB4Ktk2UheYP859y9o2LlhtPek5t1T9eyfAbQ=="], + "@langchain/langgraph-checkpoint": ["@langchain/langgraph-checkpoint@1.0.4", "", { "dependencies": { "uuid": "^14.0.0" }, "peerDependencies": { "@langchain/core": "^1.1.44" } }, "sha512-1y5MgZ0gXXrtmoy56e3kaBChI3GwFPIKl27xkrHwN+VE/3iUsyr9gO3Jtp7kdKAe6diZGbcas5bdC/r0yUwTZA=="], + + "@langchain/langgraph-sdk": ["@langchain/langgraph-sdk@1.9.16", "", { "dependencies": { "@langchain/protocol": "^0.0.16", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1", "uuid": "^14.0.0" }, "peerDependencies": { "@langchain/core": "^1.1.48", "react": "^18 || ^19", "react-dom": "^18 || ^19", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-wshd/aD3b32164a1oAuBCkORkD9jIYngluTozZJPUEXiubHG2blLk5pfxhtpcKem8AYyrC74zT05+OA1Vo8kfA=="], + + "@langchain/protocol": ["@langchain/protocol@0.0.16", "", {}, "sha512-ws+J7MaHyhO5dG7f0vdyHQiUn9hoCnki0f3crJPa4MCTGzcRC39jYSCghyrGtBPYQnZbUQiGyRVpW3z3M8IpJg=="], "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], @@ -1359,11 +1433,15 @@ "@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="], - "@mastra/core": ["@mastra/core@1.36.0", "", { "dependencies": { "@a2a-js/sdk": "~0.3.13", "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.25", "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.27", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.3", "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.10", "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", "@isaacs/ttlcache": "^2.1.4", "@lukeed/uuid": "^2.0.1", "@mastra/schema-compat": "1.2.10", "@modelcontextprotocol/sdk": "^1.29.0", "@sindresorhus/slugify": "^2.2.1", "@standard-schema/spec": "^1.1.0", "ajv": "^8.18.0", "chat": "^4.29.0", "croner": "^10.0.1", "dotenv": "^17.3.1", "execa": "^9.6.1", "fastq": "^1.19.1", "gray-matter": "^4.0.3", "hono": "^4.12.8", "hono-openapi": "^1.3.0", "ignore": "^7.0.5", "json-schema": "^0.4.0", "lru-cache": "^11.2.7", "p-map": "^7.0.4", "p-retry": "^7.1.1", "picomatch": "^4.0.3", "posthog-node": "^5.30.6", "tokenx": "^1.3.0", "ws": "^8.20.0", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-BEhDZPQeDcJ6jQRHtpfFLuoRiWAuv9dTCIjeWbXokzwDamI3D9jkyNzpBFJwFwy2S/a4jBTu4+d61nOaP7knTQ=="], + "@mastra/braintrust": ["@mastra/braintrust@1.1.3", "", { "dependencies": { "@mastra/observability": "1.14.1", "braintrust": "^2.2.2" }, "peerDependencies": { "@mastra/core": ">=1.16.0-0 <2.0.0-0", "zod": "^3.25.34 || ^4.0.0" } }, "sha512-5NxE+7gFPXR3p+K947Dri0Ta7gDl53wsYCGj6KBfXsuEIaLlxwCu+vsWdAiuXDoyTj22bVQkr2jX92CfqitI8g=="], - "@mastra/mcp": ["@mastra/mcp@1.8.0", "", { "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.1", "@modelcontextprotocol/sdk": "^1.29.0", "exit-hook": "^5.1.0", "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "@mastra/core": ">=1.0.0-0 <2.0.0-0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-kA1YhDa/W/ZuhZ/AZpUFuKKFhINSVvLf+hDNmbCZMsM46rYjyqqgQR0xgqNaysCwv3Anta6KqDz8fp6mJ7RyuA=="], + "@mastra/core": ["@mastra/core@1.41.0", "", { "dependencies": { "@a2a-js/sdk": "~0.3.13", "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.25", "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.27", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.3", "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.10", "@isaacs/ttlcache": "^2.1.4", "@lukeed/uuid": "^2.0.1", "@mastra/schema-compat": "1.2.11", "@modelcontextprotocol/sdk": "^1.29.0", "@sindresorhus/slugify": "^2.2.1", "@standard-schema/spec": "^1.1.0", "ajv": "^8.18.0", "chat": "^4.29.0", "croner": "^10.0.1", "dotenv": "^17.3.1", "execa": "^9.6.1", "fastq": "^1.19.1", "gray-matter": "^4.0.3", "ignore": "^7.0.5", "json-schema": "^0.4.0", "lru-cache": "^11.2.7", "p-map": "^7.0.4", "p-retry": "^7.1.1", "picomatch": "^4.0.3", "posthog-node": "^5.30.6", "tokenx": "^1.3.0", "ws": "^8.20.0", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-A3gV8kdyO3xf4zIFgzUYutVIOhN4595mGoz0IpMrQPuGTYSVTMwSoUhqpKhvyRXt1UYZAOCt88qqI66lscNQtQ=="], - "@mastra/schema-compat": ["@mastra/schema-compat@1.2.10", "", { "dependencies": { "json-schema-to-zod": "^2.7.0", "zod-from-json-schema": "^0.5.2", "zod-from-json-schema-v3": "npm:zod-from-json-schema@^0.0.5", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-8Fg8PeO7GsRPOrEZAzc5udZgsF9ZDxih5JSoxjgnR79d0ImjKffhcoysPW6wIYXPEZ5i6/QDNR7rCazZZSD5Tg=="], + "@mastra/mcp": ["@mastra/mcp@1.9.1", "", { "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.1", "@modelcontextprotocol/sdk": "^1.29.0", "exit-hook": "^5.1.0", "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "@mastra/core": ">=1.0.0-0 <2.0.0-0" } }, "sha512-tQOBxBBpxWeLKxCpv6QgKUxwiH7bEDMkvSat126UIn9L5rr0+4igJ0zmJuGdFCBKPvz7CPo4ykQjkmu7rc/01w=="], + + "@mastra/observability": ["@mastra/observability@1.14.1", "", { "peerDependencies": { "@mastra/core": ">=1.16.0-0 <2.0.0-0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-VKfn3mE1mNFOXgY9Pr9sy5L4q4xLnoLj501gFMOr0teNnRMvAiANVB+p4rnooDfM4i8nzLDG9icM80uMsgXTzQ=="], + + "@mastra/schema-compat": ["@mastra/schema-compat@1.2.11", "", { "dependencies": { "json-schema-to-zod": "^2.7.0", "zod-from-json-schema": "^0.5.2", "zod-from-json-schema-v3": "npm:zod-from-json-schema@^0.0.5", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-wN8eTy/g14Mg3kWukhoIjd5SpFtLQ8gOltbELe9nM2Ruzm4jK8tFr1ZZNZwvLYnpq7NJG5a8F0ZFCjXFOEwx0w=="], "@mdx-js/loader": ["@mdx-js/loader@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "source-map": "^0.7.0" }, "peerDependencies": { "webpack": ">=5" }, "optionalPeers": ["webpack"] }, "sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ=="], @@ -1371,33 +1449,35 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + "@mendable/firecrawl-js": ["@mendable/firecrawl-js@4.25.2", "", { "dependencies": { "axios": "1.16.1", "firecrawl": "4.16.0", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-1dRs5qpfjfievBsUAxAWtnhtNiIVdXshAXdhAUQVmFUFOrOKR4XWs/76CxDIcEPQ44iD8ckInLwyewo6EIs+iQ=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="], "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], - "@mintlify/cli": ["@mintlify/cli@4.0.1172", "", { "dependencies": { "@inquirer/prompts": "7.9.0", "@mintlify/common": "1.0.901", "@mintlify/link-rot": "3.0.1080", "@mintlify/models": "0.0.311", "@mintlify/prebuild": "1.0.1045", "@mintlify/previewing": "4.0.1106", "@mintlify/validation": "0.1.707", "adm-zip": "0.5.16", "chalk": "5.2.0", "color": "4.2.3", "detect-port": "1.5.1", "front-matter": "4.0.2", "fs-extra": "11.2.0", "ink": "6.3.0", "inquirer": "12.3.0", "js-yaml": "4.1.1", "mdast-util-mdx-jsx": "3.2.0", "open": "8.4.2", "openid-client": "6.8.2", "posthog-node": "5.17.2", "react": "19.2.3", "semver": "7.7.2", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "4.3.6" }, "optionalDependencies": { "keytar": "7.9.0" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-Ic3dUoeeOzSR+S9Izmbfr8eM1dMDVTkRp5TGlwa6/k9/BKExYFECvMfzZcN++W/+BDgMSbWqXRc76EwMXLv4Qw=="], + "@mintlify/cli": ["@mintlify/cli@4.0.1199", "", { "dependencies": { "@inquirer/prompts": "7.9.0", "@mintlify/common": "1.0.924", "@mintlify/link-rot": "3.0.1105", "@mintlify/models": "0.0.317", "@mintlify/prebuild": "1.0.1069", "@mintlify/previewing": "4.0.1130", "@mintlify/validation": "0.1.722", "adm-zip": "0.5.16", "chalk": "5.2.0", "color": "4.2.3", "detect-port": "1.5.1", "front-matter": "4.0.2", "fs-extra": "11.2.0", "ink": "6.3.0", "inquirer": "12.3.0", "js-yaml": "4.1.1", "mdast-util-mdx-jsx": "3.2.0", "open": "8.4.2", "openid-client": "6.8.2", "posthog-node": "5.17.2", "react": "19.2.3", "semver": "7.7.2", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "4.3.6" }, "optionalDependencies": { "keytar": "7.9.0" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-hyJufOa6NlW0mjdMsH2h5R95q9vm9LsCYR9g1lwJU2fDWoo2/N1RybgKVPjPSoDM9PKLF982z4EKn8kaOTvnBQ=="], - "@mintlify/common": ["@mintlify/common@1.0.901", "", { "dependencies": { "@asyncapi/parser": "3.4.0", "@asyncapi/specs": "6.8.1", "@mintlify/mdx": "3.0.4", "@mintlify/models": "0.0.311", "@mintlify/openapi-parser": "0.0.8", "@mintlify/validation": "0.1.707", "@sindresorhus/slugify": "2.2.0", "@types/mdast": "4.0.4", "acorn": "8.11.2", "acorn-jsx": "5.3.2", "color-blend": "4.0.0", "estree-util-to-js": "2.0.0", "estree-walker": "3.0.3", "front-matter": "4.0.2", "hast-util-from-html": "2.0.3", "hast-util-to-html": "9.0.4", "hast-util-to-text": "4.0.2", "hex-rgb": "5.0.0", "ignore": "7.0.5", "js-yaml": "4.1.1", "lodash": "4.18.1", "mdast-util-from-markdown": "2.0.2", "mdast-util-gfm": "3.0.0", "mdast-util-mdx": "3.0.0", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-gfm": "3.0.0", "micromark-extension-mdx-jsx": "3.0.1", "micromark-extension-mdxjs": "3.0.0", "openapi-types": "12.1.3", "postcss": "8.5.14", "rehype-stringify": "10.0.1", "remark": "15.0.1", "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.0", "remark-math": "6.0.0", "remark-mdx": "3.1.0", "remark-parse": "11.0.0", "remark-rehype": "11.1.1", "remark-stringify": "11.0.0", "sucrase": "3.34.0", "tailwindcss": "3.4.17", "unified": "11.0.5", "unist-builder": "4.0.0", "unist-util-map": "4.0.0", "unist-util-remove": "4.0.0", "unist-util-remove-position": "5.0.0", "unist-util-visit": "5.0.0", "unist-util-visit-parents": "6.0.1", "vfile": "6.0.3", "xss": "1.0.15" } }, "sha512-8CnUsvKT4hayjH2PooWJM9djPO6w/y6RBz5SEA1kRNu1YZt/kPhSRb7kQQCFHa2p8Q2qUnJ9KWI11SwerHdeMA=="], + "@mintlify/common": ["@mintlify/common@1.0.924", "", { "dependencies": { "@asyncapi/parser": "3.4.0", "@asyncapi/specs": "6.8.1", "@mintlify/mdx": "3.0.4", "@mintlify/models": "0.0.317", "@mintlify/openapi-parser": "0.0.8", "@mintlify/validation": "0.1.722", "@sindresorhus/slugify": "2.2.0", "@types/mdast": "4.0.4", "acorn": "8.11.2", "acorn-jsx": "5.3.2", "color-blend": "4.0.0", "estree-util-to-js": "2.0.0", "estree-walker": "3.0.3", "front-matter": "4.0.2", "hast-util-from-html": "2.0.3", "hast-util-to-html": "9.0.4", "hast-util-to-text": "4.0.2", "hex-rgb": "5.0.0", "ignore": "7.0.5", "js-yaml": "4.1.1", "lodash": "4.18.1", "mdast-util-from-markdown": "2.0.2", "mdast-util-gfm": "3.0.0", "mdast-util-mdx": "3.0.0", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-gfm": "3.0.0", "micromark-extension-mdx-jsx": "3.0.1", "micromark-extension-mdxjs": "3.0.0", "openapi-types": "12.1.3", "postcss": "8.5.14", "rehype-stringify": "10.0.1", "remark": "15.0.1", "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.0", "remark-math": "6.0.0", "remark-mdx": "3.1.0", "remark-parse": "11.0.0", "remark-rehype": "11.1.1", "remark-stringify": "11.0.0", "sucrase": "3.34.0", "tailwindcss": "3.4.17", "unified": "11.0.5", "unist-builder": "4.0.0", "unist-util-map": "4.0.0", "unist-util-remove": "4.0.0", "unist-util-remove-position": "5.0.0", "unist-util-visit": "5.0.0", "unist-util-visit-parents": "6.0.1", "vfile": "6.0.3", "xss": "1.0.15" } }, "sha512-IcUPQUNe32VE3bNaGriWplsviDqh0ZRjeg9EIsLi3j0a7DY73knxPfEG502vbcOiss6aKSmACtPt9IFdZTJyWw=="], - "@mintlify/link-rot": ["@mintlify/link-rot@3.0.1080", "", { "dependencies": { "@mintlify/common": "1.0.901", "@mintlify/models": "0.0.311", "@mintlify/prebuild": "1.0.1045", "@mintlify/previewing": "4.0.1106", "@mintlify/scraping": "4.0.765", "@mintlify/validation": "0.1.707", "fs-extra": "11.1.0", "unist-util-visit": "4.1.2" } }, "sha512-vJwBkgNj/2J1AARviYSDca0BiQWbRet2Yv/QCCGK4ChMOGLvHUfbC4+Ay2RaGduHO7Xyi5e8m48gIa5UykwaUw=="], + "@mintlify/link-rot": ["@mintlify/link-rot@3.0.1105", "", { "dependencies": { "@mintlify/common": "1.0.924", "@mintlify/models": "0.0.317", "@mintlify/prebuild": "1.0.1069", "@mintlify/previewing": "4.0.1130", "@mintlify/scraping": "4.0.788", "@mintlify/validation": "0.1.722", "fs-extra": "11.1.0", "unist-util-visit": "4.1.2" } }, "sha512-6viTjsJCU6TIkIhBd/R7CoOGeJUxroou6H2ZMY6mlXBFDI0swMgfo6Sjk/pc25T+vZBgpNxqXujNZX0FY7NLQw=="], "@mintlify/mdx": ["@mintlify/mdx@3.0.4", "", { "dependencies": { "@shikijs/transformers": "^3.11.0", "@shikijs/twoslash": "^3.12.2", "arktype": "^2.1.26", "hast-util-to-string": "^3.0.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^3.1.0", "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-hast": "^13.2.0", "next-mdx-remote-client": "^1.0.3", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-smartypants": "^3.0.2", "shiki": "^3.11.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@radix-ui/react-popover": "^1.1.15", "react": "^18.3.1", "react-dom": "^18.3.1" } }, "sha512-tJhdpnM5ReJLNJ2fuDRIEr0zgVd6id7/oAIfs26V46QlygiLsc8qx4Rz3LWIX51rUXW/cfakjj0EATxIciIw+g=="], - "@mintlify/models": ["@mintlify/models@0.0.311", "", { "dependencies": { "axios": "1.16.1", "openapi-types": "12.1.3" } }, "sha512-WHvTcVxFpRnzHQewzk0RgfEZWKuYZ5ryZ8vJQRS5WtfIYhZ/wTZz7PtIsRaZsw/CQhTo1QTakne6tTrsmJuOwg=="], + "@mintlify/models": ["@mintlify/models@0.0.317", "", { "dependencies": { "axios": "1.16.1", "openapi-types": "12.1.3" } }, "sha512-FyRvuXTUsyC+KGa9uGtUSl6vYg+PLPC20POfmTO/8PkvfLiUIGXPLhFLNz6yPuLliExQo6CDOFhsSPZZUvWmew=="], "@mintlify/openapi-parser": ["@mintlify/openapi-parser@0.0.8", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-9MBRq9lS4l4HITYCrqCL7T61MOb20q9IdU7HWhqYMNMM1jGO1nHjXasFy61yZ8V6gMZyyKQARGVoZ0ZrYN48Og=="], - "@mintlify/prebuild": ["@mintlify/prebuild@1.0.1045", "", { "dependencies": { "@mintlify/common": "1.0.901", "@mintlify/openapi-parser": "0.0.8", "@mintlify/scraping": "4.0.765", "@mintlify/validation": "0.1.707", "chalk": "5.3.0", "favicons": "7.2.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "js-yaml": "4.1.1", "openapi-types": "12.1.3", "sharp": "0.33.5", "sharp-ico": "0.1.5", "unist-util-visit": "4.1.2", "uuid": "11.1.1" } }, "sha512-uJQtqUo8cWvAFa9j5ndI29bIyOHFFxPmVeyWhTTDfqelrJNmqcO0p6eGQf2EAFZ/ulewavEvn7IanWpq9Ob1Zw=="], + "@mintlify/prebuild": ["@mintlify/prebuild@1.0.1069", "", { "dependencies": { "@mintlify/common": "1.0.924", "@mintlify/openapi-parser": "0.0.8", "@mintlify/scraping": "4.0.788", "@mintlify/validation": "0.1.722", "chalk": "5.3.0", "favicons": "7.2.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "js-yaml": "4.1.1", "openapi-types": "12.1.3", "sharp": "0.33.5", "sharp-ico": "0.1.5", "unist-util-visit": "4.1.2", "uuid": "11.1.1" } }, "sha512-iU/1ldDVqxHKyswW0rOxvNSfto5AHKqcTLRBo+Jf6D/wPO7GEJGhqHEE0SlvWpeqH1pHsrDBiAqlA/R0xQBlAw=="], - "@mintlify/previewing": ["@mintlify/previewing@4.0.1106", "", { "dependencies": { "@mintlify/common": "1.0.901", "@mintlify/prebuild": "1.0.1045", "@mintlify/validation": "0.1.707", "adm-zip": "0.5.16", "better-opn": "3.0.2", "chalk": "5.2.0", "chokidar": "3.5.3", "express": "4.22.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "got": "13.0.0", "ink": "6.3.0", "ink-spinner": "5.0.0", "is-online": "10.0.0", "js-yaml": "4.1.1", "openapi-types": "12.1.3", "react": "19.2.3", "socket.io": "4.8.0", "tar": "7.5.15", "unist-util-visit": "4.1.2", "yargs": "17.7.1" } }, "sha512-MtqFCHFGUUfF8RjRtaifeMLIzjDC30FIR8Wwqbo5NxwaNsraKErNsFhhdJczzQYP/GuCYcIIui8FQ4iJTN8/DA=="], + "@mintlify/previewing": ["@mintlify/previewing@4.0.1130", "", { "dependencies": { "@mintlify/common": "1.0.924", "@mintlify/prebuild": "1.0.1069", "@mintlify/validation": "0.1.722", "adm-zip": "0.5.16", "better-opn": "3.0.2", "chalk": "5.2.0", "chokidar": "3.5.3", "express": "4.22.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "got": "13.0.0", "ink": "6.3.0", "ink-spinner": "5.0.0", "is-online": "10.0.0", "js-yaml": "4.1.1", "openapi-types": "12.1.3", "react": "19.2.3", "socket.io": "4.8.0", "tar": "7.5.15", "unist-util-visit": "4.1.2", "yargs": "17.7.1" } }, "sha512-UeoL2oNaZyAmQS11ZhfimShOetpI3CtSK12pvKvMMqYubRPfxhRsUod7ii+1kRyPa2rpLUme6MWHF19reZuaLw=="], - "@mintlify/scraping": ["@mintlify/scraping@4.0.765", "", { "dependencies": { "@mintlify/common": "1.0.901", "@mintlify/openapi-parser": "0.0.8", "fs-extra": "11.1.1", "hast-util-to-mdast": "10.1.0", "js-yaml": "4.1.1", "mdast-util-mdx-jsx": "3.1.3", "neotraverse": "0.6.18", "puppeteer": "22.14.0", "rehype-parse": "9.0.1", "remark-gfm": "4.0.0", "remark-mdx": "3.0.1", "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "unified": "11.0.5", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "3.24.0" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-CkghRhHaQWrw1SvGREvIaWVGiZ5AMsqKHPe1/y98xJe+pPofMTw0k1p9tgoJEbrNlwDa6L4ACatfF5a3au3AYg=="], + "@mintlify/scraping": ["@mintlify/scraping@4.0.788", "", { "dependencies": { "@mintlify/common": "1.0.924", "@mintlify/openapi-parser": "0.0.8", "fs-extra": "11.1.1", "hast-util-to-mdast": "10.1.0", "js-yaml": "4.1.1", "mdast-util-mdx-jsx": "3.1.3", "neotraverse": "0.6.18", "puppeteer": "22.14.0", "rehype-parse": "9.0.1", "remark-gfm": "4.0.0", "remark-mdx": "3.0.1", "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "unified": "11.0.5", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "3.24.0" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-aS38dhwizx4zDd6VScQighHxHzsQhb0yzUaggkon8RKHn0qQjuvy9HalBfhwZYgjsU2sFbM7xC2w4uz49E2wIA=="], - "@mintlify/validation": ["@mintlify/validation@0.1.707", "", { "dependencies": { "@mintlify/mdx": "3.0.4", "@mintlify/models": "0.0.311", "arktype": "2.1.27", "js-yaml": "4.1.1", "lcm": "0.0.3", "lodash": "4.18.1", "neotraverse": "0.6.18", "object-hash": "3.0.0", "openapi-types": "12.1.3", "uuid": "11.1.1", "zod": "3.24.0", "zod-to-json-schema": "3.20.4" } }, "sha512-4SyIGXaaz/7N3Slctr9CQI238tCOltbYIpmFwRRDBK8nPWeTBbF5x6DVQuqYtcgRShZ5BT1zuwJ4idaCxZt9bg=="], + "@mintlify/validation": ["@mintlify/validation@0.1.722", "", { "dependencies": { "@mintlify/mdx": "3.0.4", "@mintlify/models": "0.0.317", "arktype": "2.1.27", "js-yaml": "4.1.1", "lcm": "0.0.3", "lodash": "4.18.1", "neotraverse": "0.6.18", "object-hash": "3.0.0", "openapi-types": "12.1.3", "uuid": "11.1.1", "zod": "3.24.0", "zod-to-json-schema": "3.20.4" } }, "sha512-fOYmCpp2jDMix/5aKcqsWczUggeZb//C7DuwrbMBw4I/7tnsupKCfp0m+bF7LBEs1BDft1wgggEh41aTQtJD+g=="], "@mishieck/ink-titled-box": ["@mishieck/ink-titled-box@0.3.0", "", { "peerDependencies": { "ink": "^6.0.0", "react": "^19.1.0", "typescript": "^5" } }, "sha512-ugzVH9hixp3hwKfQ8On/qnsrdAxS3y9rTu/aGOFed4zVUvtZyGZNIR4rxAwXult8HKI4vJEh0OM8wib9NPrwUg=="], - "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-OOWKDxdAjYDcgHkmzVzccyyag3FK+jBWPaWu4WvTxFsU4R/cgOX4eep66zPRA5n4v6WfxUNibPyvX4iJ7egYTg=="], + "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -1407,27 +1487,27 @@ "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "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-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="], "@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=="], + "@next/env": ["@next/env@14.2.35", "", {}, "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.1", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA=="], - "@next/mdx": ["@next/mdx@16.2.6", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-0hdoSkzRbyud1dNRRDiyqD9FrxR2wwdiW+ffhYx+n+fXrFOJ7Nwpi8o7nUz2LiiM44BB9M0eIO1Evy3BBrS50A=="], + "@next/mdx": ["@next/mdx@16.2.7", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-4RmM0KISxvfHr37/cn9TAGD2oy1nvTQ+ycgknz2xpd8IrY980N7XDU3CXhfKOXPhIVgbshxFF9HQEQC32ZVa9A=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="], @@ -1453,8 +1533,6 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -1463,11 +1541,11 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], - "@oclif/core": ["@oclif/core@4.11.3", "", { "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", "clean-stack": "^3.0.1", "cli-spinners": "^2.9.2", "debug": "^4.4.3", "ejs": "^3.1.10", "get-package-type": "^0.1.0", "indent-string": "^4.0.0", "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", "minimatch": "^10.2.5", "semver": "^7.8.0", "string-width": "^4.2.3", "supports-color": "^8", "tinyglobby": "^0.2.16", "widest-line": "^3.1.0", "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-gQCSYAtUhJilGKaSaZhqejH9X1dDu+jWQjLmtGOgN/XcKaAEPPSeT2mu1UvlvtPox1/NNRdlBcUa8KRKo2HnJQ=="], + "@oclif/core": ["@oclif/core@4.11.4", "", { "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", "clean-stack": "^3.0.1", "cli-spinners": "^2.9.2", "debug": "^4.4.3", "ejs": "^3.1.10", "get-package-type": "^0.1.0", "indent-string": "^4.0.0", "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", "minimatch": "^10.2.5", "semver": "^7.8.1", "string-width": "^4.2.3", "supports-color": "^8", "tinyglobby": "^0.2.16", "widest-line": "^3.1.0", "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-URwiQ5ALx/sJ2iH4vzXEd+H4K6NAI7LRs6Jag3hrgKEpGmaE6alfRC8qjO4GIgb6A3ACaJumqP9twi/M9ywdHQ=="], - "@oclif/plugin-help": ["@oclif/plugin-help@6.2.49", "", { "dependencies": { "@oclif/core": "^4" } }, "sha512-fEsO0YU7ThtzHE1RGuoHxFu/OGlqxm7PCfFp+U1PS8sde4E0cDqjVDuv78+VKrr45LpC5lWOApj7pm3FNfHrVA=="], + "@oclif/plugin-help": ["@oclif/plugin-help@6.2.50", "", { "dependencies": { "@oclif/core": "^4" } }, "sha512-rNCG4hUm+kPXFbhJfAVk/fZ3OdWJYwBDASlyX8CqOLP0MssjIGl7iEgfZz7TMuZFa+KucupKU5NRSc0KWfPTQA=="], - "@oclif/plugin-not-found": ["@oclif/plugin-not-found@3.2.86", "", { "dependencies": { "@inquirer/prompts": "^7.10.1", "@oclif/core": "^4.11.3", "ansis": "^3.17.0", "fast-levenshtein": "^3.0.0" } }, "sha512-BJhJSahwsYayZpo18f0fPTg8tKb9dIvydaz03NCK3eMfmcsT1MmXhXqh1KEV8J7mz0sQ6f0qFEb6BXy490/iUg=="], + "@oclif/plugin-not-found": ["@oclif/plugin-not-found@3.2.87", "", { "dependencies": { "@inquirer/prompts": "^7.10.1", "@oclif/core": "^4.11.4", "ansis": "^3.17.0", "fast-levenshtein": "^3.0.0" } }, "sha512-lKyZ4INrx5vB14HNWIkM6Vla/4rWVhOA2U7uCAj6gEBg36/KVmwYXxpZ9ckzZS0+jtLE84TVqS8NCYEhQiQojw=="], "@onkernel/sdk": ["@onkernel/sdk@0.36.1", "", {}, "sha512-DbpPja/+sYiZxBKS4bM8HOpFYCV1DirrTflElbMtLDCPzqO1oFQLtLGfgfAg7T4wnmth3/QYtekJpnTjxh0VGg=="], @@ -1589,35 +1667,35 @@ "@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="], - "@orpc/client": ["@orpc/client@1.14.3", "", { "dependencies": { "@orpc/shared": "1.14.3", "@orpc/standard-server": "1.14.3", "@orpc/standard-server-fetch": "1.14.3", "@orpc/standard-server-peer": "1.14.3" } }, "sha512-0HzeD/BgPctvFnd6ltjuQvx4/POXo0K01Tee/3whAm3ohXnlGqCfhzR2VMN8zBaGs1SYe6AFzjmGG928Ej3pAg=="], + "@orpc/client": ["@orpc/client@1.14.5", "", { "dependencies": { "@orpc/shared": "1.14.5", "@orpc/standard-server": "1.14.5", "@orpc/standard-server-fetch": "1.14.5", "@orpc/standard-server-peer": "1.14.5" } }, "sha512-7kDiGJDyiwdHUnWJGGY2/evUenfAjvw6skrfuIKX+ZNtNrJecNNl3ytURmWOoZbcNZ4bX/1NKViO8BrtPgY0KA=="], - "@orpc/contract": ["@orpc/contract@1.14.3", "", { "dependencies": { "@orpc/client": "1.14.3", "@orpc/shared": "1.14.3", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-docXs4ALK3TADAnscEywjqvV1Dy+4+B6ihfo33hayvJdxZdpVmxjHOf7pcAYaJFJ6+LgKYoskaVVKad6LLxFlg=="], + "@orpc/contract": ["@orpc/contract@1.14.5", "", { "dependencies": { "@orpc/client": "1.14.5", "@orpc/shared": "1.14.5", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-DARmTs1w5Z+bRtccMptc+d/k2DmZCY2pqtHhF3I2DxmN5IXEp9nch+MO/b5cnq1khjxy4XK6CIFL2psXyaXYTQ=="], - "@orpc/interop": ["@orpc/interop@1.14.3", "", {}, "sha512-B8ANHAGVI8Mjw7Co0p+qBlkFG84i38WTKjR01HMkMXd6g9bHbgaqOfHcMpJMFaZzqvxBnXH4zPra2w6J8sQmhQ=="], + "@orpc/interop": ["@orpc/interop@1.14.5", "", {}, "sha512-0cZuUVmBCkX1AsyjhDb5nG+odE/Zggl+kr3LZo3x+X4czUWlSSmPMomJ0sqtQdzv1LVyIRHmfHMWt5/9dY4BDQ=="], - "@orpc/json-schema": ["@orpc/json-schema@1.14.3", "", { "dependencies": { "@orpc/contract": "1.14.3", "@orpc/interop": "1.14.3", "@orpc/openapi": "1.14.3", "@orpc/server": "1.14.3", "@orpc/shared": "1.14.3", "json-schema-typed": "^8.0.2" } }, "sha512-Qcz2PzyZG2etpfB8ywy4Upf4SaI2x6x4fA8utVQXf5GcTPWfbTVz78MDDWnNtYEXSPj1BS95HqHC0AeGvYIB+g=="], + "@orpc/json-schema": ["@orpc/json-schema@1.14.5", "", { "dependencies": { "@orpc/contract": "1.14.5", "@orpc/interop": "1.14.5", "@orpc/openapi": "1.14.5", "@orpc/server": "1.14.5", "@orpc/shared": "1.14.5", "json-schema-typed": "^8.0.2" } }, "sha512-8vabQ3eWFpdk+ivwTm5lCy7mhFaTa7BlatjUIO0hiGlixgjtSc41v6zqK8bzV2QsyvPrHDuD1Q4Uasjzmk77+A=="], - "@orpc/openapi": ["@orpc/openapi@1.14.3", "", { "dependencies": { "@orpc/client": "1.14.3", "@orpc/contract": "1.14.3", "@orpc/interop": "1.14.3", "@orpc/openapi-client": "1.14.3", "@orpc/server": "1.14.3", "@orpc/shared": "1.14.3", "@orpc/standard-server": "1.14.3", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-0tZN91VoT6MEkOfw+ERKyozsnDXzmDSsBeMgEHN3Hl1WVU97T9l4aZzLlZILSNl3fat3HmduEDy/boNGmAWJkQ=="], + "@orpc/openapi": ["@orpc/openapi@1.14.5", "", { "dependencies": { "@orpc/client": "1.14.5", "@orpc/contract": "1.14.5", "@orpc/interop": "1.14.5", "@orpc/openapi-client": "1.14.5", "@orpc/server": "1.14.5", "@orpc/shared": "1.14.5", "@orpc/standard-server": "1.14.5", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-/wbjotmAbOKRxKEWW31jIp8/WwcbwTKEPtny+BNWGD2PrLWK+xiVKkmjjGFxbqIgETk3MZ2vLGRi1iwA6kZVyg=="], - "@orpc/openapi-client": ["@orpc/openapi-client@1.14.3", "", { "dependencies": { "@orpc/client": "1.14.3", "@orpc/contract": "1.14.3", "@orpc/shared": "1.14.3", "@orpc/standard-server": "1.14.3" } }, "sha512-1vp+hi858XDrCwYdhONl15YKNlHtj5F5gI3dq/McRRZ45tZ9/Ma03hxzABOOcayaT1L9nX7gPxhX7l5EuRf2zw=="], + "@orpc/openapi-client": ["@orpc/openapi-client@1.14.5", "", { "dependencies": { "@orpc/client": "1.14.5", "@orpc/contract": "1.14.5", "@orpc/shared": "1.14.5", "@orpc/standard-server": "1.14.5" } }, "sha512-wfxXmQSXHdEsMjMr+mMxg6NKbtsyBCeCvbTAWdo9mr4I59HErGaiH/lVEuPpIH5G980Y5kewcdHexm+hNBefFw=="], - "@orpc/server": ["@orpc/server@1.14.3", "", { "dependencies": { "@orpc/client": "1.14.3", "@orpc/contract": "1.14.3", "@orpc/interop": "1.14.3", "@orpc/shared": "1.14.3", "@orpc/standard-server": "1.14.3", "@orpc/standard-server-aws-lambda": "1.14.3", "@orpc/standard-server-fastify": "1.14.3", "@orpc/standard-server-fetch": "1.14.3", "@orpc/standard-server-node": "1.14.3", "@orpc/standard-server-peer": "1.14.3", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-VQG1sgruPhWdzT/ChltJ5Ju9v1A8F+s8EQ1MMSI33z0AthZ3IuuMZdqMIOo5YSuHROoFxzMJgCShOWYR9qXhQA=="], + "@orpc/server": ["@orpc/server@1.14.5", "", { "dependencies": { "@orpc/client": "1.14.5", "@orpc/contract": "1.14.5", "@orpc/interop": "1.14.5", "@orpc/shared": "1.14.5", "@orpc/standard-server": "1.14.5", "@orpc/standard-server-aws-lambda": "1.14.5", "@orpc/standard-server-fastify": "1.14.5", "@orpc/standard-server-fetch": "1.14.5", "@orpc/standard-server-node": "1.14.5", "@orpc/standard-server-peer": "1.14.5", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-+yT4LEPnGdYCveBVPUwAwSTSuwsIsU7QywGEu+ug51KYKwtm5qCPNBMyoA7R1/J/Q4Q5a+qaHWBTDq8bElT0Ew=="], - "@orpc/shared": ["@orpc/shared@1.14.3", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-S7qmhZT4vchKEF6F6YduG5ub5lWnvQRVNq1/f5/kJkSnYMG5q6rWLcK7c3wYfDkeap05ZIiWTwksH+fv+yJOrw=="], + "@orpc/shared": ["@orpc/shared@1.14.5", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-D3rYULnYfTYC/ZcgSP6A2Fdypwkfm/vZQ7i2ycXXXfYNrnpAINQJfN32p6+M8GTDqmNkt0R7eALV1rrvxjmZCg=="], - "@orpc/standard-server": ["@orpc/standard-server@1.14.3", "", { "dependencies": { "@orpc/shared": "1.14.3" } }, "sha512-qO6xJy+S15Wx0elQeVojo3p5EgBLJDTEtElPcUF9o4ac8hrikYZJBeSg7qGgu/elCIrVbaFk/16Lu8P4qatPWg=="], + "@orpc/standard-server": ["@orpc/standard-server@1.14.5", "", { "dependencies": { "@orpc/shared": "1.14.5" } }, "sha512-bCd1BkShxknFjIDixUEjOj3fvP4hxN0IIV/hvTdtk6sdWApadBjw3UuK3iprkECgoBt/31zTOKwgHB/wnnUfhw=="], - "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.3", "", { "dependencies": { "@orpc/shared": "1.14.3", "@orpc/standard-server": "1.14.3", "@orpc/standard-server-fetch": "1.14.3", "@orpc/standard-server-node": "1.14.3" } }, "sha512-/JpBBpLVcKTrALyhOB2zi5FfQi+X0uKNVkaZzGKd0iNLGLMYAQvfuWzdQRqWfnJb30yAPNVIjia+HFQgjyZBDA=="], + "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.5", "", { "dependencies": { "@orpc/shared": "1.14.5", "@orpc/standard-server": "1.14.5", "@orpc/standard-server-fetch": "1.14.5", "@orpc/standard-server-node": "1.14.5" } }, "sha512-qHMSWp5d85WZzch2BAL8egrz7xQNorAElHNXC1S4q5W/Ssib5Krv5zLAXVKki4IUxwAHqfrO/EYnWgEdufQzNQ=="], - "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.3", "", { "dependencies": { "@orpc/shared": "1.14.3", "@orpc/standard-server": "1.14.3", "@orpc/standard-server-node": "1.14.3" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-hDQCazvnlXR8+27qkm/uBwGd82l8UAz3LbBGmJyhDK96Cfuyx9QX5oECC21CeRZJylpbdvSuwwDlSpm6IQ0uRQ=="], + "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.5", "", { "dependencies": { "@orpc/shared": "1.14.5", "@orpc/standard-server": "1.14.5", "@orpc/standard-server-node": "1.14.5" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-agxnwypSaS/LUkUFRzVxy+wO2EMBD0+paZw9SXk2rAsW+Gxl9/XTUundUqUoukusdhgZ7R2Fp7gQLt2p0wH3Qg=="], - "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.3", "", { "dependencies": { "@orpc/shared": "1.14.3", "@orpc/standard-server": "1.14.3" } }, "sha512-IHpBpyd+CTav7ycftKkQax6qrMGdpQfYKCuTLK+P3xsBl1A07UXvlpzPi/8MjyNGDCRAQkTaN7JIr/uqLL1B8A=="], + "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.5", "", { "dependencies": { "@orpc/shared": "1.14.5", "@orpc/standard-server": "1.14.5" } }, "sha512-7h83p6/TgogOpaTuEXMVMB+UkMuAMUyFQ0zw4HM2B5/XSiEOzVO1YyFzuC2EmeuuLIV11sEI3vcw5oWbNAQROQ=="], - "@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.3", "", { "dependencies": { "@orpc/shared": "1.14.3", "@orpc/standard-server": "1.14.3", "@orpc/standard-server-fetch": "1.14.3" } }, "sha512-jDMfxmicxwJq+UT3X9Ls/ijR1Inwv07Dkz+YIFiZ2MKlp3sXVZlxhleLqH5nxlsrANmNMpIENqosSBnaCcbQjg=="], + "@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.5", "", { "dependencies": { "@orpc/shared": "1.14.5", "@orpc/standard-server": "1.14.5", "@orpc/standard-server-fetch": "1.14.5" } }, "sha512-GrRGpCaZ8uQQRRCPCq/IWf30k/22gVsske3EP/vU93cQ8I+ArKHhmvnRQIiG2y4V/QvSTwvGpj/X279LmdnV4A=="], - "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.3", "", { "dependencies": { "@orpc/shared": "1.14.3", "@orpc/standard-server": "1.14.3" } }, "sha512-Pk2Sccy+rnMYEDZnbO23NE6gP7ltk8pmlKABE4xlD1l87I/vENip94tiTG0QqmAmKCZ20Gec9vHtuLxtUqMOjQ=="], + "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.5", "", { "dependencies": { "@orpc/shared": "1.14.5", "@orpc/standard-server": "1.14.5" } }, "sha512-zp2QyfVzw4/Be5/D1NWB+r7+WDerxC39XZYsvD1+I1sT13P5HlVWfZpBIlF6eEig416O4wMwHYLOfnN4Ngom0g=="], - "@orpc/zod": ["@orpc/zod@1.14.3", "", { "dependencies": { "@orpc/json-schema": "1.14.3", "@orpc/openapi": "1.14.3", "@orpc/shared": "1.14.3", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.4" }, "peerDependencies": { "@orpc/contract": "1.14.3", "@orpc/server": "1.14.3", "zod": ">=3.25.0" } }, "sha512-+SIDmqfkTLCeeZVN6Cic4aWeiBqf2O9F4Vto9npqOEXT1szIpHKJtCmdZBsTOSD46LV5Tcg1emOde9eUeY2EBg=="], + "@orpc/zod": ["@orpc/zod@1.14.5", "", { "dependencies": { "@orpc/json-schema": "1.14.5", "@orpc/openapi": "1.14.5", "@orpc/shared": "1.14.5", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.4" }, "peerDependencies": { "@orpc/contract": "1.14.5", "@orpc/server": "1.14.5", "zod": ">=3.25.0" } }, "sha512-Al0Tim0CxO7o4UkZCmIPWJyNXnB0f0qL2YzB/MA0E65ss7qJydBmvyPUES9b3z8GeXgPQAmIOYl/Jq2jtx8DfQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -1629,87 +1707,85 @@ "@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.130.0", "", { "os": "android", "cpu": "arm" }, "sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.133.0", "", { "os": "android", "cpu": "arm" }, "sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA=="], - "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.130.0", "", { "os": "android", "cpu": "arm64" }, "sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg=="], + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.133.0", "", { "os": "android", "cpu": "arm64" }, "sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg=="], - "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.130.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig=="], + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.133.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg=="], - "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.130.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg=="], + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.133.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw=="], - "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.130.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw=="], + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.133.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A=="], - "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.130.0", "", { "os": "linux", "cpu": "arm" }, "sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA=="], + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.133.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A=="], - "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.130.0", "", { "os": "linux", "cpu": "arm" }, "sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.133.0", "", { "os": "linux", "cpu": "arm" }, "sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ=="], - "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.130.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.133.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA=="], - "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.130.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.133.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg=="], - "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.130.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.133.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw=="], - "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.130.0", "", { "os": "linux", "cpu": "none" }, "sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.133.0", "", { "os": "linux", "cpu": "none" }, "sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA=="], - "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.130.0", "", { "os": "linux", "cpu": "none" }, "sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.133.0", "", { "os": "linux", "cpu": "none" }, "sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA=="], - "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.130.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.133.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA=="], - "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.130.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.133.0", "", { "os": "linux", "cpu": "x64" }, "sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA=="], - "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.130.0", "", { "os": "linux", "cpu": "x64" }, "sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.133.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g=="], - "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.130.0", "", { "os": "none", "cpu": "arm64" }, "sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.133.0", "", { "os": "none", "cpu": "arm64" }, "sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ=="], - "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.130.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA=="], + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.133.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA=="], - "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.130.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA=="], + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.133.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg=="], - "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.130.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA=="], + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.133.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A=="], - "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.130.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ=="], + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.133.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg=="], - "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], - "@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-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="], - "@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-android-arm64": ["@oxc-resolver/binding-android-arm64@11.20.0", "", { "os": "android", "cpu": "arm64" }, "sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q=="], - "@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-arm64": ["@oxc-resolver/binding-darwin-arm64@11.20.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ=="], - "@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-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.20.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg=="], - "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.19.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw=="], + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.20.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ=="], - "@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-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg=="], - "@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-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg=="], - "@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-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg=="], - "@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-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw=="], - "@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-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.20.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ=="], - "@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-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw=="], - "@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-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg=="], - "@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-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.20.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g=="], - "@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-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g=="], - "@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-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ=="], - "@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-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.20.0", "", { "os": "none", "cpu": "arm64" }, "sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ=="], - "@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-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.20.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg=="], - "@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-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.20.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA=="], - "@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=="], + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.20.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], @@ -1751,11 +1827,11 @@ "@polka/url": ["@polka/url@0.5.0", "", {}, "sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw=="], - "@posthog/ai": ["@posthog/ai@7.18.10", "", { "dependencies": { "@anthropic-ai/sdk": "^0.78.0", "@google/genai": "^1.43.0", "@langchain/core": "^1.1.29", "@posthog/core": "1.29.5", "langchain": "^1.2.28", "openai": "^6.25.0", "uuid": "^11.1.0", "zod": "^4.1.13" }, "peerDependencies": { "@ai-sdk/provider": "^2.0.0 || ^3.0.0", "@openai/agents": "^0.8.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.200.0 <1.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "posthog-node": "^5.0.0" }, "optionalPeers": ["@ai-sdk/provider", "@openai/agents", "@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/sdk-trace-base"] }, "sha512-FSZOsU5QjoLH0hsRCkUO0pPeXyIWLqk50O/B0RBnhhhsFMnC2XpKDBstqmMmEVFIEaBd/sK4vQkhaLNaHjgnxw=="], + "@posthog/ai": ["@posthog/ai@7.20.13", "", { "dependencies": { "@anthropic-ai/sdk": "^0.78.0", "@google/genai": "^1.43.0", "@langchain/core": "^1.1.29", "@posthog/core": "1.30.9", "langchain": "^1.2.28", "openai": "^6.25.0", "uuid": "^11.1.0", "zod": "^4.1.13" }, "peerDependencies": { "@ai-sdk/provider": "^2.0.0 || ^3.0.0", "@openai/agents": "^0.8.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.200.0 <1.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "posthog-node": "^5.0.0" }, "optionalPeers": ["@ai-sdk/provider", "@openai/agents", "@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/sdk-trace-base"] }, "sha512-7Lmsy1qkFWgY0fNpgUIDri9rWeNvejB80NHnQRQO6YJ26udyI4xndwiojlpaLKVc2QMmuBP3y3GDx2Z2smL5+g=="], - "@posthog/core": ["@posthog/core@1.29.5", "", { "dependencies": { "@posthog/types": "1.374.2" } }, "sha512-Jm5AE95EwBRqO6J8+skDufyf5rnEcmOvjYArCKCOzD4mWdH1xGpfcRXj5TEyZII3mD04Kr7pw9aP2ZbAHQGu2A=="], + "@posthog/core": ["@posthog/core@1.30.9", "", { "dependencies": { "@posthog/types": "1.381.0" } }, "sha512-Cn004VJ7ZWQRaVQG7efh+pMtRMIVZznktngXe5I3E8wClRdMwCZaSa6jo/X04Oc04z8PeMp4GEcWVkdEhmAEXw=="], - "@posthog/types": ["@posthog/types@1.374.2", "", {}, "sha512-ZghQSFMi+HFJNPvPjBoyY/jWQ+q6mSQVtWQxOHMSbBidUZjsyYbxYxBFbHy2qWLNe4mEpX+Wqir2Q4I/4AVvJQ=="], + "@posthog/types": ["@posthog/types@1.381.0", "", {}, "sha512-AW68BovKFCNbPdq3VjOzfQeSQRYMvQVv+46LDywWFXO/oOTXFKwjY92FaJQSTXWgTNgDpqigCw3yUFDinK3hZA=="], "@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], @@ -1771,7 +1847,7 @@ "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], - "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], @@ -1951,61 +2027,61 @@ "@react-email/text": ["@react-email/text@0.1.5", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg=="], - "@react-grab/cli": ["@react-grab/cli@0.1.37", "", { "dependencies": { "commander": "^14.0.3", "ignore": "^7.0.5", "jsonc-parser": "^3.3.1", "ora": "^9.4.0", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "smol-toml": "^1.6.1", "tinyexec": "^1.1.2" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-1ln28VkVHUbd5qy+ccXG68voWc0mgZMhBnwG0umxfD+wbkXUcvRzVrLjSqao7N8hCrDqp+Pt5j9Tsqef+9yQQQ=="], + "@react-grab/cli": ["@react-grab/cli@0.1.44", "", { "dependencies": { "agent-install": "^0.0.5", "commander": "^14.0.3", "ignore": "^7.0.5", "ora": "^9.4.0", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "tinyexec": "^1.1.2" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-gMDYY2rw6OWajCcDlXSIgs2LC432YJXSb3Lm5yM187uhRgBYddoEVULi36h+IolX3r7jSb3ew7vn9FfI8NSo0A=="], "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.61.1", "", { "os": "android", "cpu": "arm" }, "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.61.1", "", { "os": "android", "cpu": "arm64" }, "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.61.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.61.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.61.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.61.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.61.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.61.1", "", { "os": "linux", "cpu": "arm" }, "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.61.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.61.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.61.1", "", { "os": "linux", "cpu": "none" }, "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.61.1", "", { "os": "linux", "cpu": "none" }, "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.61.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.61.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.61.1", "", { "os": "linux", "cpu": "none" }, "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.61.1", "", { "os": "linux", "cpu": "none" }, "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.61.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.61.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.61.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.61.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.61.1", "", { "os": "none", "cpu": "arm64" }, "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.61.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.61.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.61.1", "", { "os": "win32", "cpu": "x64" }, "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.61.1", "", { "os": "win32", "cpu": "x64" }, "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], @@ -2015,39 +2091,39 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], - "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.53.1", "", { "dependencies": { "@sentry/core": "10.53.1" } }, "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.56.0", "", { "dependencies": { "@sentry/core": "10.56.0" } }, "sha512-I8tZWAFg8SZpD8BFUpglEtSTzhZjacmcThB5/Mlq/iFiiT8mBPG4ZWDWssSfmIBKvZywJZJ83uDA0+uiJU73Tw=="], - "@sentry-internal/feedback": ["@sentry-internal/feedback@10.53.1", "", { "dependencies": { "@sentry/core": "10.53.1" } }, "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA=="], + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.56.0", "", { "dependencies": { "@sentry/core": "10.56.0" } }, "sha512-fkRR9JroESTIlErkht3OrH4DXKd/DbPozr2KLdX7boMo31hPu4cL9fuqzwOrwyDPRq9B4j+qEgIWB8JrTbgvmg=="], - "@sentry-internal/replay": ["@sentry-internal/replay@10.53.1", "", { "dependencies": { "@sentry-internal/browser-utils": "10.53.1", "@sentry/core": "10.53.1" } }, "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw=="], + "@sentry-internal/replay": ["@sentry-internal/replay@10.56.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.56.0", "@sentry/core": "10.56.0" } }, "sha512-DjF09hpy3TF7Km/kOZc73YJmBqcbPCxuZ5rtRs+KtVHu3Vq48xeW83qKUcFEZv20ur9UD99OAJ/gaEt//1Qbwg=="], - "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.53.1", "", { "dependencies": { "@sentry-internal/replay": "10.53.1", "@sentry/core": "10.53.1" } }, "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ=="], + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.56.0", "", { "dependencies": { "@sentry-internal/replay": "10.56.0", "@sentry/core": "10.56.0" } }, "sha512-SDg2K0CAZT/TnhrixQGwXoi6ZsWUB+DQy3UUk0bSQm6c/5k5zFBpGOiughQN+DYsDilKREfPKmUEEnqvUjm1HQ=="], "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.9.1", "", {}, "sha512-0gEoi2Lb54MFYPOmdTfxlNKxI7kCOvNV7gP8lxMXJ7nCazF5OqOOZIVshfWjDLrc0QrSV6XdVvwPV9GDn4wBMg=="], - "@sentry/browser": ["@sentry/browser@10.53.1", "", { "dependencies": { "@sentry-internal/browser-utils": "10.53.1", "@sentry-internal/feedback": "10.53.1", "@sentry-internal/replay": "10.53.1", "@sentry-internal/replay-canvas": "10.53.1", "@sentry/core": "10.53.1" } }, "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA=="], + "@sentry/browser": ["@sentry/browser@10.56.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.56.0", "@sentry-internal/feedback": "10.56.0", "@sentry-internal/replay": "10.56.0", "@sentry-internal/replay-canvas": "10.56.0", "@sentry/core": "10.56.0" } }, "sha512-80X3NmsGB6tLmfzXYdjzWWdVAdL5CRukGKLcRWIcNhgGjtskOmnzaGb93egEZGI5bUTbtONJ0oyscQ3Z9yoAtQ=="], "@sentry/bun": ["@sentry/bun@10.38.0", "", { "dependencies": { "@sentry/core": "10.38.0", "@sentry/node": "10.38.0" } }, "sha512-8a2s+FVeqI2l12RNMFFEjAXpAUkqNZeGXTvHtjzcyWASW9szBNhOpiKN8oy0R/wUeIWgHpdnUeOSBhVKzH5YfQ=="], "@sentry/bundler-plugin-core": ["@sentry/bundler-plugin-core@4.9.1", "", { "dependencies": { "@babel/core": "^7.18.5", "@sentry/babel-plugin-component-annotate": "4.9.1", "@sentry/cli": "^2.57.0", "dotenv": "^16.3.1", "find-up": "^5.0.0", "glob": "^10.5.0", "magic-string": "0.30.8", "unplugin": "1.0.1" } }, "sha512-moii+w7N8k8WdvkX7qCDY9iRBlhgHlhTHTUQwF2FNMhBHuqlNpVcSJJqJMjFUQcjYMBDrZgxhfKV18bt5ixwlQ=="], - "@sentry/cli": ["@sentry/cli@2.58.5", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.5", "@sentry/cli-linux-arm": "2.58.5", "@sentry/cli-linux-arm64": "2.58.5", "@sentry/cli-linux-i686": "2.58.5", "@sentry/cli-linux-x64": "2.58.5", "@sentry/cli-win32-arm64": "2.58.5", "@sentry/cli-win32-i686": "2.58.5", "@sentry/cli-win32-x64": "2.58.5" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg=="], + "@sentry/cli": ["@sentry/cli@2.58.6", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.6", "@sentry/cli-linux-arm": "2.58.6", "@sentry/cli-linux-arm64": "2.58.6", "@sentry/cli-linux-i686": "2.58.6", "@sentry/cli-linux-x64": "2.58.6", "@sentry/cli-win32-arm64": "2.58.6", "@sentry/cli-win32-i686": "2.58.6", "@sentry/cli-win32-x64": "2.58.6" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg=="], - "@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.5", "", { "os": "darwin" }, "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ=="], + "@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.6", "", { "os": "darwin" }, "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA=="], - "@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw=="], + "@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@2.58.6", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw=="], - "@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ=="], + "@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@2.58.6", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g=="], - "@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw=="], + "@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@2.58.6", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg=="], - "@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g=="], + "@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@2.58.6", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q=="], - "@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@2.58.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA=="], + "@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@2.58.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A=="], - "@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@2.58.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g=="], + "@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@2.58.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg=="], - "@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg=="], + "@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.6", "", { "os": "win32", "cpu": "x64" }, "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA=="], "@sentry/core": ["@sentry/core@10.38.0", "", {}, "sha512-1pubWDZE5y5HZEPMAZERP4fVl2NH3Ihp1A+vMoVkb3Qc66Diqj1WierAnStlZP7tCx0TBa0dK85GTW/ZFYyB9g=="], @@ -2057,7 +2133,7 @@ "@sentry/opentelemetry": ["@sentry/opentelemetry@10.38.0", "", { "dependencies": { "@sentry/core": "10.38.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-YPVhWfYmC7nD3EJqEHGtjp4fp5LwtAbE5rt9egQ4hqJlYFvr8YEz9sdoqSZxO0cZzgs2v97HFl/nmWAXe52G2Q=="], - "@sentry/react": ["@sentry/react@10.53.1", "", { "dependencies": { "@sentry/browser": "10.53.1", "@sentry/core": "10.53.1" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA=="], + "@sentry/react": ["@sentry/react@10.56.0", "", { "dependencies": { "@sentry/browser": "10.56.0", "@sentry/core": "10.56.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-HfPLyvnrydfyjRXw9Q0GMzj7w2YtEwuC9z5RrPUfarA2qpA0/J8cfGLzyFX2v0jBmA/kkj6J1uBUoSVhCTxFHg=="], "@sentry/vite-plugin": ["@sentry/vite-plugin@4.9.1", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.9.1", "unplugin": "1.0.1" } }, "sha512-Tlyg2cyFYp/icX58GWvfpvZr9NLdLs2/xyFVyS8pQ0faZWmoXic3FMzoXYHV1gsdMbL1Yy5WQvGJy8j1rS8LGA=="], @@ -2069,7 +2145,7 @@ "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - "@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="], + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], @@ -2087,9 +2163,13 @@ "@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="], + "@simple-git/args-pathspec": ["@simple-git/args-pathspec@1.0.3", "", {}, "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA=="], + + "@simple-git/argv-parser": ["@simple-git/argv-parser@1.1.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.3" } }, "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw=="], + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], - "@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="], + "@simplewebauthn/server": ["@simplewebauthn/server@13.3.1", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w=="], "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], @@ -2101,17 +2181,25 @@ "@sindresorhus/tsconfig": ["@sindresorhus/tsconfig@3.0.1", "", {}, "sha512-0/gtPNTY3++0J2BZM5nHHULg0BIMw886gqdn8vWN+Av6bgF5ZU2qIcHubAn+Z9KNvJhO8WFE+9kDOU3n6OcKtA=="], + "@slack/logger": ["@slack/logger@4.0.1", "", { "dependencies": { "@types/node": ">=18" } }, "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ=="], + + "@slack/socket-mode": ["@slack/socket-mode@2.0.7", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/web-api": "^7.15.0", "@types/node": ">=18", "@types/ws": "^8", "eventemitter3": "^5", "ws": "^8" } }, "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ=="], + + "@slack/types": ["@slack/types@2.21.1", "", {}, "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ=="], + + "@slack/web-api": ["@slack/web-api@7.16.0", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.21.0", "@types/node": ">=18", "@types/retry": "0.12.0", "axios": "^1.16.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-68SAV77uuGKuhyyaRytX8UijVnqSLsTSKslGXw17cjQYXn+jtNl7gbaEjHgC5x2rhCuFdahBrEC2VCLppbzReg=="], + "@smithy/abort-controller": ["@smithy/abort-controller@3.1.9", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-yiW0WI30zj8ZKoSYNx90no7ugVn3khlyH/z5W8qtKBtVE6awRALbhSG+2SAHA1r6bO/6M9utxYKVZ3PCJ1rWxw=="], - "@smithy/config-resolver": ["@smithy/config-resolver@4.5.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "tslib": "^2.6.2" } }, "sha512-TpS6Am5zSEtx3ow7VynThEL7UwRM06zZZcmFaP6Ij9hqKPfsFhTYCLcgU7gjFjw9QAI2kzwXrfS7InH8BivJTA=="], + "@smithy/config-resolver": ["@smithy/config-resolver@4.5.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-AXbvUX9aNY2qCLOMCikpl1Df5w2CNFEqbEb6XafG81FJbAbB8avIT7BOx1KDqiO86J/38qKQ3YuakfAfY3iBkQ=="], - "@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="], + "@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="], - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="], "@smithy/eventstream-codec": ["@smithy/eventstream-codec@1.1.0", "", { "dependencies": { "@aws-crypto/crc32": "3.0.0", "@smithy/types": "^1.2.0", "@smithy/util-hex-encoding": "^1.1.0", "tslib": "^2.5.0" } }, "sha512-3tEbUb8t8an226jKB6V/Q2XU/J53lCwCzULuBPEaF4JjSh+FlCMp7TmogE/Aij5J9DwlsZ4VAD/IRDuQ/0ZtMw=="], - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="], "@smithy/hash-node": ["@smithy/hash-node@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "@smithy/util-buffer-from": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-emP23rwYyZhQBvklqTtwetkQlqbNYirDiEEwXl2v0GYWMnCzxst7ZaRAnWuy28njp5kAH54lvkdG37MblZzaHA=="], @@ -2119,7 +2207,7 @@ "@smithy/is-array-buffer": ["@smithy/is-array-buffer@1.1.0", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-twpQ/n+3OWZJ7Z+xu43MJErmhB/WO/mMTnqR6PwWQShvSJ/emx5d1N59LQZk6ZpTAeuRWrc+eHhkzTp9NFjNRQ=="], - "@smithy/middleware-compression": ["@smithy/middleware-compression@4.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "fflate": "0.8.1", "tslib": "^2.6.2" } }, "sha512-IuZ+ebi3OteVFprY33vV7oLfZxRx0YACjoGhex59PX7+sHgG0f75wyb5FZuOZhJoQPnWaDD5piirEwWzyAmb3A=="], + "@smithy/middleware-compression": ["@smithy/middleware-compression@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "fflate": "0.8.1", "tslib": "^2.6.2" } }, "sha512-wZQpnjrGSO2IFxhwWNaeRzHh2swSwRGWaCVgQN9zqYdtP98tcNYyqI7YvPeVTwf9CvQTas7xlmR3NY5L1i32mg=="], "@smithy/middleware-content-length": ["@smithy/middleware-content-length@3.0.13", "", { "dependencies": { "@smithy/protocol-http": "^4.1.8", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-zfMhzojhFpIX3P5ug7jxTjfUcIPcGjcQYzB9t+rv0g1TX7B0QdwONW+ATouaLoD7h7LOw/ZlXfkq4xJ/g2TrIw=="], @@ -2133,7 +2221,7 @@ "@smithy/node-config-provider": ["@smithy/node-config-provider@3.1.12", "", { "dependencies": { "@smithy/property-provider": "^3.1.11", "@smithy/shared-ini-file-loader": "^3.1.12", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-O9LVEu5J/u/FuNlZs+L7Ikn3lz7VB9hb0GtPT9MQeiBmtK8RSY3ULmsZgXhe6VAlgTw0YO+paQx4p8xdbs43vQ=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="], "@smithy/property-provider": ["@smithy/property-provider@3.1.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-I/+TMc4XTQ3QAjXfOcUWbSS073oOEAxgx4aZy8jHaf8JQnRkq2SZWw8+PfDtBvLUjcGMdxl+YwtzWe6i5uhL/A=="], @@ -2147,11 +2235,11 @@ "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@3.1.12", "", { "dependencies": { "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-1xKSGI+U9KKdbG2qDvIR9dGrw3CNx+baqJfyr0igKEpjbHL5stsqAesYBzHChYHlelWtb87VnLWlhvfCz13H8Q=="], - "@smithy/signature-v4": ["@smithy/signature-v4@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="], "@smithy/smithy-client": ["@smithy/smithy-client@3.7.0", "", { "dependencies": { "@smithy/core": "^2.5.7", "@smithy/middleware-endpoint": "^3.2.8", "@smithy/middleware-stack": "^3.0.11", "@smithy/protocol-http": "^4.1.8", "@smithy/types": "^3.7.2", "@smithy/util-stream": "^3.3.4", "tslib": "^2.6.2" } }, "sha512-9wYrjAZFlqWhgVo3C4y/9kpc68jgiSsKUnsFPzr/MSiRL93+QRDafGTfhhKAb2wsr69Ru87WTiqSfQusSmWipA=="], - "@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="], + "@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="], "@smithy/url-parser": ["@smithy/url-parser@3.0.11", "", { "dependencies": { "@smithy/querystring-parser": "^3.0.11", "@smithy/types": "^3.7.2", "tslib": "^2.6.2" } }, "sha512-TmlqXkSk8ZPhfc+SQutjmFr5FjC0av3GZP4B/10caK1SbRwe/v+Wzu/R6xEKxoNqL+8nY18s1byiy6HqPG37Aw=="], @@ -2195,10 +2283,6 @@ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], - - "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -2233,21 +2317,21 @@ "@stoplight/yaml-ast-parser": ["@stoplight/yaml-ast-parser@0.0.50", "", {}, "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ=="], - "@supabase/auth-js": ["@supabase/auth-js@2.106.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-JY7602OvjK2l3BjsQkpePpxR+6P0iG37gCrZNWAMhAuNh1iFnhGRwj/y5EshUG0INMPGFrj0UA9MErQ/kOEKFg=="], + "@supabase/auth-js": ["@supabase/auth-js@2.107.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ=="], - "@supabase/functions-js": ["@supabase/functions-js@2.106.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-ADIkJYH5w7HbnGVAAlCbyKoLF5QdfyezBLfYXpUqhxZOacK6YepOvnP/8p4p+50bhTPWp6VhDxu19KO7e/qU2g=="], + "@supabase/functions-js": ["@supabase/functions-js@2.107.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w=="], "@supabase/phoenix": ["@supabase/phoenix@0.4.2", "", {}, "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A=="], - "@supabase/postgrest-js": ["@supabase/postgrest-js@2.106.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-vNKFAXQrtmUn7J3LbN+uMlt0jciAwRIBpdy6Do4DKrpf1xj0kJhbqXTX4y8ziewWUEEx8G5GPnDmprXXaO9f3w=="], + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.107.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ=="], - "@supabase/realtime-js": ["@supabase/realtime-js@2.106.0", "", { "dependencies": { "@supabase/phoenix": "^0.4.2", "tslib": "2.8.1" } }, "sha512-mYZoaYpkyjlecixbvxCu0h3jw12uHfEcUqNdaRATNI8zQVI5arels+VJzAGcHwNiD+/Juv0OXIuk+M7SHsdI4A=="], + "@supabase/realtime-js": ["@supabase/realtime-js@2.107.0", "", { "dependencies": { "@supabase/phoenix": "^0.4.2", "tslib": "2.8.1" } }, "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A=="], - "@supabase/storage-js": ["@supabase/storage-js@2.106.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-BHc3nIjD3zfdDxBenphXrLJSoQ+qwo24VD96cVzmjBFbQVk5krvwRNUXrA5ozPplA3Vhlst2d/hy9R9ViqH2lg=="], + "@supabase/storage-js": ["@supabase/storage-js@2.107.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw=="], "@supabase/stripe-sync-engine": ["@supabase/stripe-sync-engine@0.48.5", "", { "dependencies": { "pg": "^8.20.0", "pg-node-migrations": "0.0.8", "yesql": "^7.0.0" }, "peerDependencies": { "stripe": "> 18" } }, "sha512-+LbtJH8n5Xiu289AL3FuWFdKXd0K7kDF0z4Lm+zMYoImWmOuGd3TgSx9gm/nv4nzLooOmIxGZh6LojoYBcJM+g=="], - "@supabase/supabase-js": ["@supabase/supabase-js@2.106.0", "", { "dependencies": { "@supabase/auth-js": "2.106.0", "@supabase/functions-js": "2.106.0", "@supabase/postgrest-js": "2.106.0", "@supabase/realtime-js": "2.106.0", "@supabase/storage-js": "2.106.0" } }, "sha512-OOoo3sLj9iVXNp6b+fkyOfFeQrvvNy7nQbaONNf72dOaictUeS39hFDS9argIRTag6M3ZxIypNWcrDAwLgUihQ=="], + "@supabase/supabase-js": ["@supabase/supabase-js@2.107.0", "", { "dependencies": { "@supabase/auth-js": "2.107.0", "@supabase/functions-js": "2.107.0", "@supabase/postgrest-js": "2.107.0", "@supabase/realtime-js": "2.107.0", "@supabase/storage-js": "2.107.0" } }, "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -2289,27 +2373,27 @@ "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], - "@tanstack/form-core": ["@tanstack/form-core@1.32.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-Tn5VRDSjyqjmaet2tJMuEWDRFyrCaon03vxXPlSSaiSs6C/N7lCIwGCXJbZXEUq1kTj8jYN9qyXHbsz4LQHcow=="], + "@tanstack/form-core": ["@tanstack/form-core@1.33.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.11.0" } }, "sha512-AV4Pw9Dk4orFsuPBcDssfWMJFs+yMYBae7zZ4oTqrCf4ftNGQKxvrQRZeqKHG6A4TkiLeSvf2kzIjcVkrW7E6w=="], "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], "@tanstack/query-core": ["@tanstack/query-core@5.85.6", "", {}, "sha512-hCj0TktzdCv2bCepIdfwqVwUVWb+GSHm1Jnn8w+40lfhQ3m7lCO7ADRUJy+2unxQ/nzjh2ipC6ye69NDW3l73g=="], - "@tanstack/react-form": ["@tanstack/react-form@1.32.0", "", { "dependencies": { "@tanstack/form-core": "1.32.0", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "@tanstack/react-start": "*", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@tanstack/react-start"] }, "sha512-6WP5SQTA6/H9crCpvpq3ZppYWqtrdE5NjOy6ebABi6uAQPqhfTzrdjS9t40mCZCFtGI5585OhJV6zBP/KN2zcw=="], + "@tanstack/react-form": ["@tanstack/react-form@1.33.0", "", { "dependencies": { "@tanstack/form-core": "1.33.0", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-unaee+VS4MvKo+s1dmgGUXI4902VeAhuaUbKsQbhFe3MceOpB3JpAUGCDpyzjQPXVFkFY0COKfLrUNX2XZYW4g=="], "@tanstack/react-query": ["@tanstack/react-query@5.85.6", "", { "dependencies": { "@tanstack/query-core": "5.85.6" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA=="], - "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/react-store": ["@tanstack/react-store@0.11.0", "", { "dependencies": { "@tanstack/store": "0.11.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w=="], "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.2", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ=="], - "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], "@tinybirdco/sdk": ["@tinybirdco/sdk@0.0.69", "", { "dependencies": { "@clack/prompts": "^1.0.0", "chokidar": "^4.0.0", "commander": "^12.0.0", "dotenv": "^16.0.0", "esbuild": "^0.24.0", "picocolors": "^1.1.1", "zod": "^3.25.0" }, "bin": { "tinybird": "bin/tinybird.js" } }, "sha512-ScwHmj/bIjjxc7skTv+lRES8x0OnCQ8s8CHhS8kVjL5R1A0nrLsoCamBbFQUe0R1RL5Cm+ylWkTTCLyvCqkjnw=="], @@ -2335,17 +2419,17 @@ "@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.9.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw=="], - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw=="], + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw=="], - "@turbo/linux-64": ["@turbo/linux-64@2.9.14", "", { "os": "linux", "cpu": "x64" }, "sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA=="], + "@turbo/linux-64": ["@turbo/linux-64@2.9.16", "", { "os": "linux", "cpu": "x64" }, "sha512-vAEf1H6l26lTpl9FJ/peQo1NUB8RC0sbEJJz5mPcUhHA2bPDup2x3CZPgo/bH8S4cUcBLm4FN3UHd5iUO2RAew=="], - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g=="], + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-xDBLR2PZg4BrQOchfG6svgpv5FCNJ2TOtT2psLdEJcdKo1BH+pnPs9Xj6pvUjgfkHbuvBOfeE4R6tvxMoQKDHQ=="], - "@turbo/windows-64": ["@turbo/windows-64@2.9.14", "", { "os": "win32", "cpu": "x64" }, "sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A=="], + "@turbo/windows-64": ["@turbo/windows-64@2.9.16", "", { "os": "win32", "cpu": "x64" }, "sha512-NBAJnaUiGdgkSzQwUIdOvkCkcpTSu58G/sBGa0mvBtzfvFOOgrQwepKOOQ8cp6sWM6OcKDNFj2p1dsZA1OWjPg=="], - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g=="], + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-Y7SJppD0Z8wjO3Ec0ZGd9KQ4Yv0BMnA8CIowj5Vp+OEVsosXDG2weK6/t1RRLfJmc2Ozrnd6y4DOgQys+mn3WQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], @@ -2449,8 +2533,6 @@ "@types/eslint": ["@types/eslint@7.29.0", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng=="], - "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], - "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -2497,12 +2579,14 @@ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], - "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + "@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/nunjucks": ["@types/nunjucks@3.2.6", "", {}, "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w=="], + "@types/pako": ["@types/pako@1.0.7", "", {}, "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A=="], "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], @@ -2521,7 +2605,7 @@ "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], - "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + "@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], @@ -2541,7 +2625,7 @@ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], - "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], + "@types/superagent": ["@types/superagent@8.1.10", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg=="], "@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="], @@ -2561,25 +2645,25 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/type-utils": "8.60.1", "@typescript-eslint/utils": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.60.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.1", "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1" } }, "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.60.1", "", {}, "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.60.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.1", "@typescript-eslint/tsconfig-utils": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag=="], "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260220.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260220.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260220.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260220.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260220.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260220.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260220.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260220.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-trYXlG98/C7Q7pqnPrKo+ksXrWqWVMncCy2x0VftD2llfL99Z//g2mpB9TmzWeKgb4d1659ESvxTowCGnzMccw=="], @@ -2599,7 +2683,7 @@ "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], - "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="], + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.6", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], @@ -2663,9 +2747,11 @@ "@vercel/analytics": ["@vercel/analytics@2.0.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "nuxt": ">= 3", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "nuxt", "react", "svelte", "vue", "vue-router"] }, "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g=="], + "@vercel/functions": ["@vercel/functions@1.6.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-R6FKQrYT5MZs5IE1SqeCJWxMuBdHawFcCZboKKw8p7s+6/mcd55Gx6tWmyKnQTyrSEA04NH73Tc9CbqpEle8RA=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], - "@vercel/sdk": ["@vercel/sdk@1.21.5", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "zod": "^3.25.0 || ^4.0.0" }, "bin": { "mcp": "bin/mcp-server.js" } }, "sha512-R1/j1ixylaHQ+d3y+QhG9848Ruv8XBH0g2MChzNek6F1SKNGvgHaG2hX0crndIYTVplB11Ynt3g2mKIkEKBAPQ=="], + "@vercel/sdk": ["@vercel/sdk@1.21.9", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "zod": "^3.25.0 || ^4.0.0" }, "bin": { "mcp": "bin/mcp-server.js" } }, "sha512-HbmrcF/uwio8HwVA7oyvfzyqk2QxM2yqP2EakLqQ/9ZHA1VWi05D9mBZW5J/PnLs5sJLdFa5+J6mzGey6lq8KQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], @@ -2707,6 +2793,8 @@ "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + "a-sync-waterfall": ["a-sync-waterfall@1.0.1", "", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "abort-controller-x": ["abort-controller-x@0.4.3", "", {}, "sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA=="], @@ -2739,11 +2827,13 @@ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "aggregate-error": ["aggregate-error@4.0.1", "", { "dependencies": { "clean-stack": "^4.0.0", "indent-string": "^5.0.0" } }, "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w=="], - "ai": ["ai@6.0.185", "", { "dependencies": { "@ai-sdk/gateway": "3.0.116", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oGsqscREaTlo75KHZLtwZxRyI+ZBwHV2wRX9B8smHjgOs13WwoCvUyr5aPUWpIBRz406wmIKy1RzoUEq0/WKJw=="], + "ai": ["ai@6.0.197", "", { "dependencies": { "@ai-sdk/gateway": "3.0.125", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-U3KsjkqwQXGHC0u0VeUDqUaNaBS/uQc7v4Vj92Cjv5lPx5DIyRBQYk4Hipy5vwD9AQKIG8uRvdaN9R+pAvrtcQ=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -2755,6 +2845,10 @@ "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -2771,7 +2865,7 @@ "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -2857,6 +2951,8 @@ "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "autoevals": ["autoevals@0.0.132", "", { "dependencies": { "ajv": "^8.17.1", "compute-cosine-similarity": "^1.1.0", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "linear-sum-assignment": "^1.0.7", "mustache": "^4.2.0", "openai": "^6.3.0", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6" } }, "sha512-x033hXLO1Vyggbv68Y1QeoZlrdKHcNexcutPhVaDhDJ4SO6TU1rG6vME77g/zKvH4VWFVBPDWM1pRbPxAFF+sA=="], + "autumn-js": ["autumn-js@workspace:packages/autumn-js"], "ava": ["ava@5.3.1", "", { "dependencies": { "acorn": "^8.8.2", "acorn-walk": "^8.2.0", "ansi-styles": "^6.2.1", "arrgv": "^1.0.2", "arrify": "^3.0.0", "callsites": "^4.0.0", "cbor": "^8.1.0", "chalk": "^5.2.0", "chokidar": "^3.5.3", "chunkd": "^2.0.1", "ci-info": "^3.8.0", "ci-parallel-vars": "^1.0.1", "clean-yaml-object": "^0.1.0", "cli-truncate": "^3.1.0", "code-excerpt": "^4.0.0", "common-path-prefix": "^3.0.0", "concordance": "^5.0.4", "currently-unhandled": "^0.4.1", "debug": "^4.3.4", "emittery": "^1.0.1", "figures": "^5.0.0", "globby": "^13.1.4", "ignore-by-default": "^2.1.0", "indent-string": "^5.0.0", "is-error": "^2.2.2", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", "matcher": "^5.0.0", "mem": "^9.0.2", "ms": "^2.1.3", "p-event": "^5.0.1", "p-map": "^5.5.0", "picomatch": "^2.3.1", "pkg-conf": "^4.0.0", "plur": "^5.1.0", "pretty-ms": "^8.0.0", "resolve-cwd": "^3.0.0", "stack-utils": "^2.0.6", "strip-ansi": "^7.0.1", "supertap": "^3.0.1", "temp-dir": "^3.0.0", "write-file-atomic": "^5.0.1", "yargs": "^17.7.2" }, "peerDependencies": { "@ava/typescript": "*" }, "optionalPeers": ["@ava/typescript"], "bin": { "ava": "entrypoints/cli.mjs" } }, "sha512-Scv9a4gMOXB6+ni4toLuhAm9KYWEjsgBglJl+kMGI5+IVDt120CCDZyB5HNU9DjmLI2t4I0GbnxGLmmRfGTJGg=="], @@ -2865,9 +2961,9 @@ "avsc": ["avsc@5.7.9", "", {}, "sha512-yOA4wFeI7ET3v32Di/sUybQ+ttP20JHSW3mxLuNGeO0uD6PPcvLrIQXSvy/rhJOWU5JrYh7U4OHplWMmtAtjMg=="], - "axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], + "axe-core": ["axe-core@4.12.0", "", {}, "sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg=="], - "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], + "axios": ["axios@1.17.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -2877,17 +2973,17 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "bare-events": ["bare-events@2.8.3", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw=="], + "bare-events": ["bare-events@2.9.1", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg=="], - "bare-fs": ["bare-fs@4.7.1", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw=="], + "bare-fs": ["bare-fs@4.7.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg=="], "bare-os": ["bare-os@3.9.1", "", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="], - "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + "bare-path": ["bare-path@3.0.1", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ=="], "bare-stream": ["bare-stream@2.13.1", "", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow=="], - "bare-url": ["bare-url@2.4.3", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="], + "bare-url": ["bare-url@2.4.5", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ=="], "base-convert-int-array": ["base-convert-int-array@1.0.1", "", {}, "sha512-NWqzaoXx8L/SS32R+WmKqnQkVXVYl2PwNJ68QV3RAlRRL1uV+yxJT66abXI1cAvqCXQTyXr7/9NN4Af90/zDVw=="], @@ -2895,7 +2991,7 @@ "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="], "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], @@ -2915,6 +3011,8 @@ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "binary-search": ["binary-search@1.3.6", "", {}, "sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], "bintrees": ["bintrees@1.0.2", "", {}, "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="], @@ -2931,10 +3029,14 @@ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "braintrust": ["braintrust@3.17.0", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.12.0", "@next/env": "^14.2.3", "@vercel/functions": "^1.0.2", "ajv": "^8.20.0", "argparse": "^2.0.1", "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "cors": "^2.8.5", "dc-browser": "^1.0.4", "dotenv": "^16.4.5", "esbuild": "0.28.0", "eventsource-parser": "^1.1.2", "express": "^5.2.1", "http-errors": "^2.0.0", "minimatch": "^10.2.5", "module-details-from-path": "^1.0.4", "mustache": "^4.2.0", "pluralize": "^8.0.0", "simple-git": "^3.36.0", "source-map": "^0.7.4", "termi-link": "^1.0.1", "unplugin": "^2.3.5", "uuid": "^11.1.1", "zod-to-json-schema": "^3.25.0" }, "optionalDependencies": { "@braintrust/bt-darwin-arm64": "0.11.1", "@braintrust/bt-darwin-x64": "0.11.1", "@braintrust/bt-linux-arm64": "0.11.1", "@braintrust/bt-linux-x64": "0.11.1", "@braintrust/bt-linux-x64-musl": "0.11.1", "@braintrust/bt-win32-arm64": "0.11.1", "@braintrust/bt-win32-x64": "0.11.1" }, "peerDependencies": { "zod": "^3.25.34 || ^4.0" }, "bin": { "braintrust": "dist/cli.js", "bt": "bin/bt" } }, "sha512-nyV+j/FJJJsWnkiSn9tAoNSTsMtDfbH4v8EQpBTYGj1120eXFPcPPs66kkkKcYuN0tEo/Ai7VO8Ujcy5j3SrUQ=="], + "browser-stdout": ["browser-stdout@1.3.1", "", {}, "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -2951,7 +3053,7 @@ "builtins": ["builtins@5.1.0", "", { "dependencies": { "semver": "^7.0.0" } }, "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg=="], - "bullmq": ["bullmq@5.76.10", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", "msgpackr": "2.0.1", "node-abort-controller": "3.1.1", "semver": "7.8.0", "tslib": "2.8.1" } }, "sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ=="], + "bullmq": ["bullmq@5.78.0", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", "msgpackr": "2.0.2", "node-abort-controller": "3.1.1", "semver": "7.8.0", "tslib": "2.8.1" }, "peerDependencies": { "redis": ">=5.0.0" }, "optionalPeers": ["redis"] }, "sha512-tT9jJmbobk9ueEfFc22egLmgwCcMGgOjZ5Y1cvgczBPv1JUmC7iHQVbQtqku2YBE5dE9uzdVpxIrBvL/YAjGwA=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -3011,7 +3113,7 @@ "charset": ["charset@1.0.1", "", {}, "sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg=="], - "chat": ["chat@4.29.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw=="], + "chat": ["chat@4.30.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-8LXrauKckMmR83FcYC/R8nNEda5VJDDdIhZwUUu+hzaSbk4lqsro0IWm7rB1GGYXONRrUOG2XJlkNr4C15vgMA=="], "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], @@ -3021,6 +3123,8 @@ "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + "cheminfo-types": ["cheminfo-types@1.15.0", "", {}, "sha512-shv45WN2u0yN9EHH1bisNrv+fy4Cw+eLM5lOoriP67mePrwbHZ1kJqg90C8GEU7K1A8gJsicEoVZHcuBbuul/w=="], + "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -3053,6 +3157,8 @@ "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], @@ -3079,7 +3185,7 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -3109,8 +3215,16 @@ "common-path-prefix": ["common-path-prefix@3.0.0", "", {}, "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w=="], + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], + "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], + "compute-cosine-similarity": ["compute-cosine-similarity@1.1.0", "", { "dependencies": { "compute-dot": "^1.1.0", "compute-l2norm": "^1.1.0", "validate.io-array": "^1.0.5", "validate.io-function": "^1.0.2" } }, "sha512-FXhNx0ILLjGi9Z9+lglLzM12+0uoTnYkHm7GiadXDAr0HGVLm25OivUS1B/LPkbzzvlcXz/1EvWg9ZYyJSdhTw=="], + + "compute-dot": ["compute-dot@1.1.0", "", { "dependencies": { "validate.io-array": "^1.0.3", "validate.io-function": "^1.0.2" } }, "sha512-L5Ocet4DdMrXboss13K59OK23GXjiSia7+7Ukc7q4Bl+RVpIXK2W9IHMbWDZkh+JUEvJAwOKRaJDiFUa1LTnJg=="], + + "compute-l2norm": ["compute-l2norm@1.1.0", "", { "dependencies": { "validate.io-array": "^1.0.3", "validate.io-function": "^1.0.2" } }, "sha512-6EHh1Elj90eU28SXi+h2PLnTQvZmkkHWySpoFz+WOlVNLz3DQoC4ISUHSV9n5jMxPHtKGJ01F4uu2PsXBB8sSg=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concordance": ["concordance@5.0.4", "", { "dependencies": { "date-time": "^3.1.0", "esutils": "^2.0.3", "fast-diff": "^1.2.0", "js-string-escape": "^1.0.1", "lodash": "^4.17.15", "md5-hex": "^3.0.1", "semver": "^7.3.2", "well-known-symbols": "^2.0.0" } }, "sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw=="], @@ -3187,7 +3301,7 @@ "currently-unhandled": ["currently-unhandled@0.4.1", "", { "dependencies": { "array-find-index": "^1.0.1" } }, "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng=="], - "cytoscape": ["cytoscape@3.33.3", "", {}, "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g=="], + "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], @@ -3271,19 +3385,21 @@ "datadog-metrics": ["datadog-metrics@0.12.1", "", { "dependencies": { "@datadog/datadog-api-client": "^1.17.0", "debug": "^4.1.0" } }, "sha512-Gy+17ia7m9Uy+nKQHDd7fljdq0fqqfpgkpxlwW0x1oFKI7RcgDV32pMCfHtv4HKychP6fHtncj3Lf4VN/g4G6A=="], - "date-fns": ["date-fns@4.2.1", "", {}, "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "date-time": ["date-time@3.1.0", "", { "dependencies": { "time-zone": "^1.0.0" } }, "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg=="], "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], - "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], + + "dc-browser": ["dc-browser@1.0.4", "", {}, "sha512-7oEtnzNlcE+hr4OvO3GR6Gndgw8BhW+wKOEwMqSleyY7N29jbAxzyW5BaJl7qBCw+6OIxfMWtY0T+6dxq8RWLw=="], "debounce": ["debounce@2.2.0", "", {}, "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw=="], "debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decamelize": ["decamelize@4.0.0", "", {}, "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ=="], @@ -3379,6 +3495,8 @@ "dns-socket": ["dns-socket@4.2.2", "", { "dependencies": { "dns-packet": "^5.2.4" } }, "sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg=="], + "dockerfile-ast": ["dockerfile-ast@0.7.1", "", { "dependencies": { "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3" } }, "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dogapi": ["dogapi@2.8.4", "", { "dependencies": { "extend": "^3.0.2", "json-bigint": "^1.0.0", "lodash": "^4.17.21", "minimist": "^1.2.5", "rc": "^1.2.8" }, "bin": { "dogapi": "bin/dogapi" } }, "sha512-065fsvu5dB0o4+ENtLjZILvXMClDNH/yA9H6L8nsdcNiz9l0Hzpn7aQaCOPYXxqyzq4CRPOdwkFXUjDOXfRGbg=="], @@ -3389,7 +3507,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="], + "dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -3401,10 +3519,12 @@ "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], - "drizzle-orm": ["drizzle-orm@0.43.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA=="], + "drizzle-orm": ["drizzle-orm@0.43.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "e2b": ["e2b@2.27.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.11", "undici": "^7.25.0" } }, "sha512-xZ1vXSl4dpWxbvan5vihE2embXzHdlpK1N0CmFUIcj5kdGLpiQXGoQYsz1Dhy8wr9VO724DyRC7Y3iblMElLPQ=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -3417,7 +3537,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.358", "", {}, "sha512-EO7tKm3QxRqTs1lSuPXzl6yRAwznehp0AH9OoMOIC+4mQzTFday8FJCO5KU6J/TFSQXEOahNq4vTKpz1jmCVOA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="], "emittery": ["emittery@1.2.1", "", {}, "sha512-sFz64DCRjirhwHLxofFqxYQm6DCp6o0Ix7jwKQvuCHPn4GMRZNuBZyLPu9Ccmk/QSCAMZt6FOUqA8JZCQvA9fw=="], @@ -3431,7 +3551,7 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "engine.io": ["engine.io@6.6.7", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3" } }, "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ=="], + "engine.io": ["engine.io@6.6.8", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1" } }, "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g=="], "engine.io-client": ["engine.io-client@6.5.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.0.0" } }, "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ=="], @@ -3439,7 +3559,9 @@ "enhance-visitors": ["enhance-visitors@1.0.0", "", { "dependencies": { "lodash": "^4.13.1" } }, "sha512-+29eJLiUixTEDRaZ35Vu8jP3gPLNcQQkQkOQjLp2X+6cZGGPDD/uasbFzvLsJKnGZnvmyZ0srxudwOtskHeIDA=="], - "enhanced-resolve": ["enhanced-resolve@5.21.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A=="], + "enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], + + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "ensure-posix-path": ["ensure-posix-path@1.1.1", "", {}, "sha512-VWU0/zXzVbeJNXvME/5EmLuEj2TauvoaTz6aFYK1Z92JCBlDlZ3Gu0tuGR42kpW1754ywTs+QB0g5TP0oj9Zaw=="], @@ -3467,7 +3589,7 @@ "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -3475,7 +3597,7 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], - "es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], + "es-toolkit": ["es-toolkit@1.47.0", "", {}, "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw=="], "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], @@ -3513,7 +3635,7 @@ "eslint-import-resolver-webpack": ["eslint-import-resolver-webpack@0.13.11", "", { "dependencies": { "debug": "^3.2.7", "enhanced-resolve": "^0.9.1", "find-root": "^1.1.0", "hasown": "^2.0.2", "interpret": "^1.4.0", "is-core-module": "^2.16.1", "is-regex": "^1.2.1", "lodash": "^4.18.1", "resolve": "^2.0.0-next.6", "semver": "^5.7.2" }, "peerDependencies": { "eslint-plugin-import": ">=1.4.0", "webpack": ">=1.11.0" } }, "sha512-RGFDrCHSmCKGuaoI1zmZT028weIFIEyfSy0nAwzp5rplutWDC+BBjvZS2l4bEgSOfjc+ILkSLxeszkslyNO6fQ=="], - "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" }, "peerDependencies": { "eslint": "*" }, "optionalPeers": ["eslint"] }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + "eslint-module-utils": ["eslint-module-utils@2.13.0", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ=="], "eslint-plugin-ava": ["eslint-plugin-ava@13.2.0", "", { "dependencies": { "enhance-visitors": "^1.0.0", "eslint-utils": "^3.0.0", "espree": "^9.0.0", "espurify": "^2.1.1", "import-modules": "^2.1.0", "micro-spelling-correcter": "^1.1.1", "pkg-dir": "^5.0.0", "resolve-from": "^5.0.0" }, "peerDependencies": { "eslint": ">=7.22.0" } }, "sha512-i5B5izsEdERKQLruk1nIWzTTE7C26/ju8qQf7JeyRv32XT2lRMW0zMFZNhIrEf5/5VvpSz2rqrV7UcjClGbKsw=="], @@ -3529,7 +3651,7 @@ "eslint-plugin-no-use-extend-native": ["eslint-plugin-no-use-extend-native@0.5.0", "", { "dependencies": { "is-get-set-prop": "^1.0.0", "is-js-type": "^2.0.0", "is-obj-prop": "^1.0.0", "is-proto-prop": "^2.0.0" } }, "sha512-dBNjs8hor8rJgeXLH4HTut5eD3RGWf9JUsadIfuL7UosVQ/dnvOKwxEcRrXrFxrMZ8llUVWT+hOimxJABsAUzQ=="], - "eslint-plugin-prettier": ["eslint-plugin-prettier@4.2.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.0" }, "peerDependencies": { "eslint": ">=7.28.0", "eslint-config-prettier": "*", "prettier": ">=2.0.0" }, "optionalPeers": ["eslint-config-prettier"] }, "sha512-9Ni+xgemM2IWLq6aXEpP2+V/V30GeA/46Ar629vcMqVPodFFWC9skHu/D1phvuqtS8bJCFnNf01/qcmqYEwNfg=="], + "eslint-plugin-prettier": ["eslint-plugin-prettier@4.2.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.0" }, "peerDependencies": { "eslint": ">=7.28.0", "prettier": ">=2.0.0" } }, "sha512-9Ni+xgemM2IWLq6aXEpP2+V/V30GeA/46Ar629vcMqVPodFFWC9skHu/D1phvuqtS8bJCFnNf01/qcmqYEwNfg=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], @@ -3591,7 +3713,7 @@ "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "eventsource-parser": ["eventsource-parser@1.1.2", "", {}, "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA=="], "evt": ["evt@2.5.9", "", { "dependencies": { "minimal-polyfills": "^2.2.3", "run-exclusive": "^2.2.19", "tsafe": "^1.8.5" } }, "sha512-GpjX476FSlttEGWHT8BdVMoI8wGXQGbEOtKcP4E+kggg+yJzXBZN2n4x7TS/zPBJ1DZqWI+rguZZApjjzQ0HpA=="], @@ -3645,7 +3767,7 @@ "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], "fast-xml-parser": ["fast-xml-parser@5.3.4", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA=="], @@ -3669,6 +3791,8 @@ "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "fft.js": ["fft.js@4.0.4", "", {}, "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw=="], + "figures": ["figures@5.0.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "is-unicode-supported": "^1.2.0" } }, "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -3691,6 +3815,8 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "firecrawl": ["firecrawl@4.16.0", "", { "dependencies": { "axios": "^1.13.5", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-7SJ/FWhZBtW2gTCE/BsvU+gbfIpfTq+D9IH82l9MacauLVptaY6EdYAhrK3YSMC9yr5NxvxRcpZKcXG/nqjiiQ=="], + "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], "flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="], @@ -3699,7 +3825,7 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -3723,7 +3849,7 @@ "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], - "framer-motion": ["framer-motion@12.39.0", "", { "dependencies": { "motion-dom": "^12.39.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w=="], + "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], @@ -3751,7 +3877,7 @@ "fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="], - "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + "gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], "gcd": ["gcd@0.0.1", "", {}, "sha512-VNx3UEGr+ILJTiMs1+xc5SX1cMgJCrXezKPa003APUWNqQqaF6n25W8VcR7nHN6yRWbvvUTwCpZCFJeWC2kXlw=="], @@ -3795,7 +3921,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -3809,7 +3935,7 @@ "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], - "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + "google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="], "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], @@ -3823,7 +3949,7 @@ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], - "graphql": ["graphql@16.14.0", "", {}, "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q=="], + "graphql": ["graphql@16.14.1", "", {}, "sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg=="], "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], @@ -3845,7 +3971,7 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hast": ["hast@1.0.0", "", {}, "sha512-vFUqlRV5C+xqP76Wwq2SrM0kipnmpxJm7OfvVXpB35Fp+Fn4MV+ozr+JZr5qFvyR1q/U+Foim2x+3P+x9S1PLA=="], @@ -3913,8 +4039,6 @@ "hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="], - "hono-openapi": ["hono-openapi@1.3.0", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig=="], - "hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="], "hosted-git-info": ["hosted-git-info@5.2.1", "", { "dependencies": { "lru-cache": "^7.5.1" } }, "sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw=="], @@ -4017,7 +4141,7 @@ "interpret": ["interpret@1.4.0", "", {}, "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA=="], - "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + "ioredis": ["ioredis@5.11.1", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A=="], "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], @@ -4035,6 +4159,8 @@ "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + "is-any-array": ["is-any-array@3.0.0", "", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="], + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -4069,6 +4195,8 @@ "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="], + "is-error": ["is-error@2.2.2", "", {}, "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg=="], "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -4139,7 +4267,7 @@ "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], - "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], @@ -4171,7 +4299,7 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], - "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], @@ -4187,6 +4315,8 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], + "js-string-escape": ["js-string-escape@1.0.1", "", {}, "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg=="], "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], @@ -4195,7 +4325,7 @@ "js-types": ["js-types@1.0.0", "", {}, "sha512-bfwqBW9cC/Lp7xcRpug7YrXm0IVw+T9e3g4mCYnv0Pjr3zIzU9PCQElYU9oSGAWzXlbdl9X5SAMPejO9sxkeUw=="], - "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], @@ -4257,15 +4387,15 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "knip": ["knip@6.14.1", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "minimist": "^1.2.8", "oxc-parser": "^0.130.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.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-SN3Ly0ixzj5CQkY/rc4OPHpWrCC0XRIIjgdP76G9Cni5k72ur5jBYOyvJuF5oPTM14v8eHcMUgPbElHa+lnR0g=="], + "knip": ["knip@6.15.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "minimist": "^1.2.8", "oxc-parser": "^0.133.0", "oxc-resolver": "^11.20.0", "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.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-uBaKFEGcu/HG4EY2gWFBMr+fBF43Jftoc2riJX51TKME1Z46C8UQIbNEusenYbEWihphxe2PY0Kns0yPvPYz4A=="], "ksuid": ["ksuid@3.0.0", "", { "dependencies": { "base-convert-int-array": "^1.0.1" } }, "sha512-81CkBGn/06ZVAjGvFZi6fVG8VcPeMH0JpJ4V1Z9VwrMMaGIeAjY4jrVdrIcxhL9I2ZUU6t5uiyswcmkk+KZegA=="], "kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], - "langchain": ["langchain@1.4.1", "", { "dependencies": { "@langchain/langgraph": "^1.3.0", "@langchain/langgraph-checkpoint": "^1.0.1", "langsmith": ">=0.5.0 <1.0.0", "zod": "^3.25.76 || ^4" }, "peerDependencies": { "@langchain/core": "^1.1.47" } }, "sha512-LHGdj0OQV5pgyZgC2WWiEvNg5g16dg+c3j7pw7Iuw7tJXEvltNLVl6DjC6egxSsWT03FJN0eUJxJ13Dxhz2bBA=="], + "langchain": ["langchain@1.4.4", "", { "dependencies": { "@langchain/langgraph": "^1.3.2", "@langchain/langgraph-checkpoint": "^1.0.1", "langsmith": ">=0.5.0 <1.0.0", "zod": "^3.25.76 || ^4" }, "peerDependencies": { "@langchain/core": "^1.1.48" } }, "sha512-tepOCwUDaIZOYJ9Eo0O6o5dXEN/0KJheiFDnHHFL8Tx8rfkDLL4cOTSTln4Vpn9LpWzXYkjQ8lkHnnNDQWZPeg=="], - "langsmith": ["langsmith@0.7.1", "", { "dependencies": { "p-queue": "6.6.2" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*", "ws": ">=7" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai", "ws"] }, "sha512-Wjk90UjNoY5cBHMlNAC/eZx5clI8jnjBOBW8uJu8+MWBtx0QesNjsUiLtjI+I3UnrpxFFpDqGXcnhBjH654Mqg=="], + "langsmith": ["langsmith@0.7.5", "", { "dependencies": { "p-queue": "6.6.2" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*", "ws": ">=7" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai", "ws"] }, "sha512-OeD6+yKtWwy6sAboq25kD5DICzYv7j2KgtV2n4LsJ8nU2LpEdt1UwbjA6BON/zTggmS/YjV2TtLTHd7VEJhtEA=="], "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], @@ -4281,7 +4411,7 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "libphonenumber-js": ["libphonenumber-js@1.13.2", "", {}, "sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg=="], + "libphonenumber-js": ["libphonenumber-js@1.13.5", "", {}, "sha512-7/kRezHmQlMfO6pmvt34orO/g3j1C47k8FCBXFgj/mklTLwQdBca1LkhDK6RM8UyM6JqHFAIikMdkKkyfQy39A=="], "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], @@ -4311,6 +4441,8 @@ "line-column-path": ["line-column-path@3.0.0", "", { "dependencies": { "type-fest": "^2.0.0" } }, "sha512-Atocnm7Wr9nuvAn97yEPQa3pcQI5eLQGBz+m6iTb+CVw+IOzYB9MrYK7jI7BfC9ISnT4Fu0eiwhAScV//rp4Hw=="], + "linear-sum-assignment": ["linear-sum-assignment@1.0.9", "", { "dependencies": { "cheminfo-types": "^1.8.1", "ml-matrix": "^6.12.1", "ml-spectra-processing": "^14.18.0" } }, "sha512-1T2Ek3sxpt2mBHeBFMRJEikiIK/yIOwf+mrxv/DkAU/5ddnCMndZL//hFH7QuHa1tbaQADzsf9t7rkGZKqoFfQ=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], @@ -4377,7 +4509,7 @@ "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], - "lru-cache": ["lru-cache@11.4.0", "", {}, "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], @@ -4465,6 +4597,8 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], + "mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="], "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], @@ -4479,7 +4613,7 @@ "micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.3.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gSPnxgHDDqXYOBvQRq6lerrq9mjDhdtKn+7XETuXjxWcL62yZEfUdA28Ml1I2vDIPfAOIKLa0h2XDSGkInGHFQ=="], - "micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark-util-types": "*" }, "optionalPeers": ["micromark-util-types"] }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="], + "micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="], "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], @@ -4581,7 +4715,7 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - "mintlify": ["mintlify@4.2.569", "", { "dependencies": { "@mintlify/cli": "4.0.1172" }, "bin": { "mintlify": "index.js" } }, "sha512-RadGvZlURzMpdr6Yy608a/EAm9lPT4rGpjOX3mTR0AvGwMRZjoEg6qxWcqbbjUsKKa5q7KERkcCNIRH09/bn3A=="], + "mintlify": ["mintlify@4.2.596", "", { "dependencies": { "@mintlify/cli": "4.0.1199" }, "bin": { "mintlify": "index.js" } }, "sha512-MAfomcvBgEkxauJYZsuHVj59nLD4kbIk2ZIspDHQGdOFZELUStDB/NP9qURIh2Hsn6nMDwfLyrGceh87Ce7UOA=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], @@ -4591,9 +4725,21 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "ml-array-max": ["ml-array-max@2.0.0", "", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="], + + "ml-array-min": ["ml-array-min@2.0.0", "", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="], + + "ml-array-rescale": ["ml-array-rescale@2.0.0", "", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="], + + "ml-matrix": ["ml-matrix@6.12.2", "", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-GC+BnW+pBh8Auap8goAxY0senAmF0IEoc3HNVSfnfbvGw0buuDIYb9kAKMS1l+GiwJ1rfK2bzJ8IHhwjzATSFA=="], + + "ml-spectra-processing": ["ml-spectra-processing@14.29.0", "", { "dependencies": { "binary-search": "^1.3.6", "cheminfo-types": "^1.15.0", "fft.js": "^4.0.4", "is-any-array": "^3.0.0", "ml-matrix": "^6.12.2", "ml-xsadd": "^3.0.1" } }, "sha512-825CS864krbjMv7OB0mbjgAmyOL5ymj1OGa0gAzz1h1Dcd3Eeol2DaOimSiPYmRhW+iYhpeQnb7cSU0mlSK6+g=="], + + "ml-xsadd": ["ml-xsadd@3.0.1", "", {}, "sha512-Fz2q6dwgzGM8wYKGArTUTZDGa4lQFA2Vi6orjGeTVRy22ZnQFKlJuwS9n8NRviqz1KHAHAzdKJwbnYhdo38uYg=="], + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], - "mocha": ["mocha@11.7.5", "", { "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", "debug": "^4.3.5", "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", "minimatch": "^9.0.5", "ms": "^2.1.3", "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { "mocha": "bin/mocha.js", "_mocha": "bin/_mocha" } }, "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig=="], + "mocha": ["mocha@11.7.6", "", { "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", "debug": "^4.3.5", "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", "minimatch": "^9.0.5", "ms": "^2.1.3", "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { "mocha": "bin/mocha.js", "_mocha": "bin/_mocha" } }, "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA=="], "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], @@ -4601,17 +4747,17 @@ "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], - "motion": ["motion@12.39.0", "", { "dependencies": { "framer-motion": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA=="], + "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], - "motion-dom": ["motion-dom@12.39.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ=="], + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@2.0.1", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA=="], + "msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="], - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], @@ -4647,7 +4793,7 @@ "next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="], - "next-mdx-remote-client": ["next-mdx-remote-client@1.1.7", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "remark-mdx-remove-esm": "^1.3.1", "serialize-error": "^13.0.1", "vfile": "^6.0.3", "vfile-matter": "^5.0.1" }, "peerDependencies": { "react": ">= 18.3.0 < 19.0.0", "react-dom": ">= 18.3.0 < 19.0.0" } }, "sha512-12Ap5Z/tFIETMXFSBTH2IFEhJAso7MvOJ5ICyesA4q6FM4vtAcmb+4ZKa4tV1IVQJLBVqOhaEfIESZzdwjmrQQ=="], + "next-mdx-remote-client": ["next-mdx-remote-client@1.1.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "@types/mdx": "^2.0.13", "remark-mdx-remove-esm": "^1.3.2", "serialize-error": "^13.0.1", "vfile": "^6.0.3", "vfile-matter": "^5.0.1" }, "peerDependencies": { "react": ">= 18.3.0 < 19.0.0", "react-dom": ">= 18.3.0 < 19.0.0" } }, "sha512-IElOrn02JjGQZxx+re7wMx/1AUG+Arte9aDImAtxjAfMw6xuSCaH5mTCunKelkWzFyFdRb565jO8jRICvvh96g=="], "ngrok": ["ngrok@5.0.0-beta.2", "", { "dependencies": { "extract-zip": "^2.0.1", "got": "^11.8.5", "lodash.clonedeep": "^4.5.0", "uuid": "^7.0.0 || ^8.0.0", "yaml": "^2.2.2" }, "optionalDependencies": { "hpagent": "^0.1.2" }, "bin": { "ngrok": "bin/ngrok" } }, "sha512-UzsyGiJ4yTTQLCQD11k1DQaMwq2/SsztBg2b34zAqcyjS25qjDpogMKPaCKHwe/APRTHeel3iDXcVctk5CNaCQ=="], @@ -4675,7 +4821,7 @@ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], - "node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], "nodemon": ["nodemon@3.1.14", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw=="], @@ -4693,6 +4839,8 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "nunjucks": ["nunjucks@3.2.4", "", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="], + "nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="], "nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="], @@ -4743,8 +4891,12 @@ "openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], + "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], + "openid-client": ["openid-client@6.8.2", "", { "dependencies": { "jose": "^6.1.3", "oauth4webapi": "^3.8.4" } }, "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA=="], "opentracing": ["opentracing@0.14.7", "", {}, "sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q=="], @@ -4759,9 +4911,9 @@ "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.130.0", "", { "dependencies": { "@oxc-project/types": "^0.130.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.130.0", "@oxc-parser/binding-android-arm64": "0.130.0", "@oxc-parser/binding-darwin-arm64": "0.130.0", "@oxc-parser/binding-darwin-x64": "0.130.0", "@oxc-parser/binding-freebsd-x64": "0.130.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", "@oxc-parser/binding-linux-arm64-musl": "0.130.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", "@oxc-parser/binding-linux-x64-gnu": "0.130.0", "@oxc-parser/binding-linux-x64-musl": "0.130.0", "@oxc-parser/binding-openharmony-arm64": "0.130.0", "@oxc-parser/binding-wasm32-wasi": "0.130.0", "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", "@oxc-parser/binding-win32-x64-msvc": "0.130.0" } }, "sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw=="], + "oxc-parser": ["oxc-parser@0.133.0", "", { "dependencies": { "@oxc-project/types": "^0.133.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.133.0", "@oxc-parser/binding-android-arm64": "0.133.0", "@oxc-parser/binding-darwin-arm64": "0.133.0", "@oxc-parser/binding-darwin-x64": "0.133.0", "@oxc-parser/binding-freebsd-x64": "0.133.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.133.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.133.0", "@oxc-parser/binding-linux-arm64-gnu": "0.133.0", "@oxc-parser/binding-linux-arm64-musl": "0.133.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.133.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.133.0", "@oxc-parser/binding-linux-riscv64-musl": "0.133.0", "@oxc-parser/binding-linux-s390x-gnu": "0.133.0", "@oxc-parser/binding-linux-x64-gnu": "0.133.0", "@oxc-parser/binding-linux-x64-musl": "0.133.0", "@oxc-parser/binding-openharmony-arm64": "0.133.0", "@oxc-parser/binding-wasm32-wasi": "0.133.0", "@oxc-parser/binding-win32-arm64-msvc": "0.133.0", "@oxc-parser/binding-win32-ia32-msvc": "0.133.0", "@oxc-parser/binding-win32-x64-msvc": "0.133.0" } }, "sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw=="], - "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=="], + "oxc-resolver": ["oxc-resolver@11.20.0", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.20.0", "@oxc-resolver/binding-android-arm64": "11.20.0", "@oxc-resolver/binding-darwin-arm64": "11.20.0", "@oxc-resolver/binding-darwin-x64": "11.20.0", "@oxc-resolver/binding-freebsd-x64": "11.20.0", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-musl": "11.20.0", "@oxc-resolver/binding-openharmony-arm64": "11.20.0", "@oxc-resolver/binding-wasm32-wasi": "11.20.0", "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" } }, "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g=="], "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=="], @@ -4837,7 +4989,7 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], @@ -4897,6 +5049,8 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], @@ -4945,9 +5099,9 @@ "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], - "posthog-js": ["posthog-js@1.374.2", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.29.5", "@posthog/types": "1.374.2", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-6z1xGlVocd3NmSZlJNFfpedLIHLcejuuQPxvrpHDvtyVI9tN1NPqbM7T7coXw2It6gdZ/nAgDuZkNxfIut+Spw=="], + "posthog-js": ["posthog-js@1.381.0", "", { "dependencies": { "@posthog/core": "1.30.9", "@posthog/types": "1.381.0", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-botkF0PUSd19qUTB7lJxKRQAc+9b9v3XcAZnqG/4LMDXJPvMxPeSCj6OVx8e9GCZN38kOg6yUcXMXDTcBqKdlw=="], - "posthog-node": ["posthog-node@5.34.6", "", { "dependencies": { "@posthog/core": "1.29.5" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-oDjagFRkmCbWJBxG1FVU3kOGC6dxNpR849q8ARrZSBK3zWz4zJox6V5EjrATKM9RXKvAmbCSFoxYaOYTzp3phA=="], + "posthog-node": ["posthog-node@5.36.3", "", { "dependencies": { "@posthog/core": "1.30.9" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-cbAB8w3lJf+rQlxgBVkbQha/yJnt7NFe8l23iE5brLl4YLv10y+Xk6a0SzquZzk5tebznW0qpz3WEUJpf2OI9w=="], "powershell-utils": ["powershell-utils@0.2.0", "", {}, "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw=="], @@ -4979,11 +5133,11 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "proto-props": ["proto-props@2.0.0", "", {}, "sha512-2yma2tog9VaRZY2mn3Wq51uiSW4NcPYT1cQdBagwyrznrilKSZwIZ0UG3ZPL/mx+axEns0hE35T5ufOYZXEnBQ=="], - "protobufjs": ["protobufjs@7.6.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ=="], + "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -5011,11 +5165,9 @@ "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], - "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], - "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], - "query-string": ["query-string@9.3.1", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw=="], + "query-string": ["query-string@9.4.0", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-ivvWyHqU9K1Log4hJFhqVIIMoEi0nzmlRhvk2pPcTuQH/Y0K5iTTMxEx7R0PRHD2Z1hMVbWnjfsEWbIKIK+3IA=="], "queue-lit": ["queue-lit@1.5.2", "", {}, "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw=="], @@ -5049,7 +5201,7 @@ "react-email": ["react-email@4.0.16", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chalk": "^5.0.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "next": "^15.3.1", "normalize-path": "^3.0.0", "ora": "^8.0.0", "socket.io": "^4.8.1" }, "bin": { "email": "dist/cli/index.mjs" } }, "sha512-auhFU+nQxAkKkP6lQhPyGsa9exwfUEzp2BwZnjHokCwphZlg30tu4t1LgdKRwGPYsi7XNGy6asbVLAUhOVpzzg=="], - "react-grab": ["react-grab@0.1.37", "", { "dependencies": { "@react-grab/cli": "0.1.37", "bippy": "^0.5.41" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-XVAc/qPyxsDT8Putu9UnP7iVHmAORMzvaN/3GDTOfbuLIRXWdOt7vebPOzM9RVTVUjucXv0135JI++kSVPSfYg=="], + "react-grab": ["react-grab@0.1.44", "", { "dependencies": { "@react-grab/cli": "0.1.44", "bippy": "^0.5.41" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-bDEwBdI90ljq2lhUtPqmWis/HwYB/CvfT0m5i+P9F83Pt0Ot8o9XL8v00s9jcWzdQUlsFDzmq2FO2CHUe8JY8A=="], "react-hotkeys-hook": ["react-hotkeys-hook@4.6.2", "", { "peerDependencies": { "react": ">=16.8.1", "react-dom": ">=16.8.1" } }, "sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q=="], @@ -5067,11 +5219,11 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-resizable-panels": ["react-resizable-panels@4.11.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-kA4w58V6wYdRLm2rg9pzroZwGlqBLul1FjMP0J8kqTo3zSHtjeH+LXmZaldCo6+HWqs1e5hOcPoajKXdOze37Q=="], + "react-resizable-panels": ["react-resizable-panels@4.11.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg=="], - "react-router": ["react-router@7.15.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="], + "react-router": ["react-router@7.17.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ=="], - "react-router-dom": ["react-router-dom@7.15.1", "", { "dependencies": { "react-router": "7.15.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg=="], + "react-router-dom": ["react-router-dom@7.17.0", "", { "dependencies": { "react-router": "7.17.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], @@ -5167,7 +5319,7 @@ "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], - "remark-mdx-remove-esm": ["remark-mdx-remove-esm@1.3.1", "", { "dependencies": { "@types/mdast": "^4.0.4", "mdast-util-mdxjs-esm": "^2.0.1", "unist-util-remove": "^4.0.0" }, "peerDependencies": { "unified": "^11" } }, "sha512-POa8abdiuicD2e+zQkclxzJa5JEGLtV8XIOFVvisnGuw4l4xd6dfQozedwqR8JTeXQmxLebvYhlbwHoQP9RWkw=="], + "remark-mdx-remove-esm": ["remark-mdx-remove-esm@1.3.2", "", { "dependencies": { "@types/mdast": "^4.0.4", "unist-util-remove": "^4.0.0" }, "peerDependencies": { "unified": "^11" } }, "sha512-BvL8VSdVXy9S7NlHP56nUJAHFc45h5E9HnHiLUGHe5tw3Yvm/3cVZvAzlkEEh2i+fkq2uKrf2xn5VmItBhMypA=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -5177,7 +5329,7 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "remend": ["remend@1.0.1", "", {}, "sha512-152puVH0qMoRJQFnaMG+rVDdf01Jq/CaED+MBuXExurJgdbkLp0c3TIe4R12o28Klx8uyGsjvFNG05aFG69G9w=="], + "remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -5225,7 +5377,7 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + "rollup": ["rollup@4.61.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="], "rou3": ["rou3@0.6.3", "", {}, "sha512-1HSG1ENTj7Kkm5muMnXuzzfdDOf7CFnbSYFA+H3Fp/rB9lOCxCPgy1jlZxTKyFoC5jJay8Mmc+VbPLYRjzYLrA=="], @@ -5277,7 +5429,9 @@ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "semifies": ["semifies@1.0.0", "", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], + + "semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], @@ -5325,6 +5479,8 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-git": ["simple-git@3.36.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "^1.0.3", "@simple-git/argv-parser": "^1.1.0", "debug": "^4.4.0" } }, "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q=="], + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -5343,7 +5499,7 @@ "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=="], + "socket.io-adapter": ["socket.io-adapter@2.5.7", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.20.1" } }, "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg=="], "socket.io-client": ["socket.io-client@4.7.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", "engine.io-client": "~6.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ=="], @@ -5405,7 +5561,7 @@ "streamdown": ["streamdown@1.6.11", "", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y38fwRx5kCKTluwM+Gf27jbbi9q6Qy+WC9YrC1YbCpMkktT3PsRBJHMWiqYeF8y/JzLpB1IzDoeaB6qkQEDnAA=="], - "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], + "streamx": ["streamx@2.27.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA=="], "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], @@ -5457,7 +5613,7 @@ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], @@ -5477,7 +5633,7 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svix": ["svix@1.93.0", "", { "dependencies": { "standardwebhooks": "1.0.0" } }, "sha512-AeCcSs+CrHNejZytBuvD4hw2B14rB7+Sq7ggwYgF22TgXh0uJJ3T4uVJSbSYKFSbO1AA4o470XoGgOYqu2fbSA=="], + "svix": ["svix@1.95.1", "", { "dependencies": { "standardwebhooks": "1.0.0" } }, "sha512-Vtsbzsvs4lzXJneruB5HiZmV7dlhAjbo6dGid2Qxi9bv+LutLz7Yt3NORI4SYqRTNWPhVoFAA8TG/WXB+mIzNQ=="], "svix-react": ["svix-react@1.13.9", "", { "peerDependencies": { "react": ">=16", "react-dom": ">=16", "svix": ">=1.26.0" } }, "sha512-upKI64EwMiEUpG4+LRAOxlWzz/qR9wGcYCt0firKajqyPYblTp8Yfca1gf5a+rYdoE5MharloHNiQcZGdhemGw=="], @@ -5501,7 +5657,7 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], + "tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="], "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], @@ -5515,63 +5671,65 @@ "tempy": ["tempy@3.1.0", "", { "dependencies": { "is-stream": "^3.0.0", "temp-dir": "^3.0.0", "type-fest": "^2.12.2", "unique-string": "^3.0.0" } }, "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g=="], + "termi-link": ["termi-link@1.1.0", "", {}, "sha512-2qSN6TnomHgVLtk+htSWbaYs4Rd2MH/RU7VpHTy6MBstyNyWbM4yKd1DCYpE3fDg8dmGWojXCngNi/MHCzGuAA=="], + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], - "terser": ["terser@5.47.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw=="], + "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], - "terser-webpack-plugin": ["terser-webpack-plugin@5.6.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "@minify-html/node": "*", "@swc/core": "*", "@swc/css": "*", "@swc/html": "*", "clean-css": "*", "cssnano": "*", "csso": "*", "esbuild": "*", "html-minifier-terser": "*", "lightningcss": "*", "postcss": "*", "uglify-js": "*", "webpack": "^5.1.0" }, "optionalPeers": ["@minify-html/node", "@swc/core", "@swc/css", "@swc/html", "clean-css", "cssnano", "csso", "esbuild", "html-minifier-terser", "lightningcss", "postcss", "uglify-js"] }, "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA=="], + "terser-webpack-plugin": ["terser-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ=="], - "text-camel-case": ["text-camel-case@1.2.10", "", { "dependencies": { "text-pascal-case": "1.2.10" } }, "sha512-KNrWeZzQT+gh73V1LnmgTkjK7V+tMRjLCc6VrGwkqbiRdnGVIWBUgIvVnvnaVCxIvZ/2Ke8DCmgPirlQcCqD3Q=="], + "text-camel-case": ["text-camel-case@1.2.11", "", { "dependencies": { "text-pascal-case": "^1.2.11" } }, "sha512-2ZsM/gOlB1tyza+8lGLvs6gtPuZ9qEYuKPa+gwo38m65wkY4k323SK4hT7ku8r5wIKyspUYIWSk1aB9/Jjxr7A=="], - "text-capital-case": ["text-capital-case@1.2.10", "", { "dependencies": { "text-no-case": "1.2.10", "text-upper-case-first": "1.2.10" } }, "sha512-yvViUJKSSQcRO58je224bhPHg/Hij9MEY43zuKShtFzrPwW/fOAarUJ5UkTMSB81AOO1m8q+JiFdxMF4etKZbA=="], + "text-capital-case": ["text-capital-case@1.2.11", "", { "dependencies": { "text-no-case": "^1.2.11", "text-upper-case-first": "^1.2.11" } }, "sha512-30A7B7+VUvevEmPE0xWK1Z2z0ncl/JTjSUBLfjpoXrkwuPpmNTVbjHShRTN3cX9GIuZn/P3jvR+TO9JiTZcl8A=="], - "text-case": ["text-case@1.2.10", "", { "dependencies": { "text-camel-case": "1.2.10", "text-capital-case": "1.2.10", "text-constant-case": "1.2.10", "text-dot-case": "1.2.10", "text-header-case": "1.2.10", "text-is-lower-case": "1.2.10", "text-is-upper-case": "1.2.10", "text-kebab-case": "1.2.10", "text-lower-case": "1.2.10", "text-lower-case-first": "1.2.10", "text-no-case": "1.2.10", "text-param-case": "1.2.10", "text-pascal-case": "1.2.10", "text-path-case": "1.2.10", "text-sentence-case": "1.2.10", "text-snake-case": "1.2.10", "text-swap-case": "1.2.10", "text-title-case": "1.2.10", "text-upper-case": "1.2.10", "text-upper-case-first": "1.2.10" } }, "sha512-5bY3Ks/u7OJ5YO69iyXrG5Xf2wUZeyko7U78nPUnYoSeuNeAfA5uAix5hTspfkl6smm3yCBObrex+kFvzeIcJg=="], + "text-case": ["text-case@1.2.11", "", { "dependencies": { "text-camel-case": "^1.2.11", "text-capital-case": "^1.2.11", "text-constant-case": "^1.2.11", "text-dot-case": "^1.2.11", "text-header-case": "^1.2.11", "text-is-lower-case": "^1.2.11", "text-is-upper-case": "^1.2.11", "text-kebab-case": "^1.2.11", "text-lower-case": "^1.2.11", "text-lower-case-first": "^1.2.11", "text-no-case": "^1.2.11", "text-param-case": "^1.2.11", "text-pascal-case": "^1.2.11", "text-path-case": "^1.2.11", "text-sentence-case": "^1.2.11", "text-snake-case": "^1.2.11", "text-swap-case": "^1.2.11", "text-title-case": "^1.2.11", "text-upper-case": "^1.2.11", "text-upper-case-first": "^1.2.11" } }, "sha512-LbdWNQeuXWbfav+pxBxvaefkziffMYeSA53BHp52cgJa9rjiC0dkjum9AKrH8iQWoQJ4InGPSGexLeerGFaZ1Q=="], - "text-constant-case": ["text-constant-case@1.2.10", "", { "dependencies": { "text-no-case": "1.2.10", "text-upper-case": "1.2.10" } }, "sha512-/OfU798O2wrwKN9kQf71WhJeAlklGnbby0Tupp+Ez9NXymW+6oF9LWDRTkN+OreTmHucdvp4WQd6O5Rah5zj8A=="], + "text-constant-case": ["text-constant-case@1.2.11", "", { "dependencies": { "text-no-case": "^1.2.11", "text-upper-case": "^1.2.11" } }, "sha512-XnTBILsa7UpMWncUCchqIybZlg15FUcrlyNaWIJ8ybPy54qcoN513EXFswueyizuAgyJFXPCwwSFbSji6kw/Uw=="], "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], - "text-dot-case": ["text-dot-case@1.2.10", "", { "dependencies": { "text-no-case": "1.2.10" } }, "sha512-vf4xguy5y6e39RlDZeWZFMDf2mNkR23VTSVb9e68dUSpfJscG9/1YWWpW3n8TinzQxBZlsn5sT5olL33MvvQXw=="], + "text-dot-case": ["text-dot-case@1.2.11", "", { "dependencies": { "text-no-case": "^1.2.11" } }, "sha512-7SLKiT45KZO0qad0+p+GvC0+F+6pZ851HJcTcBJiSF88HsK/e1qErlGLtVBT6hkTHIaAj48WfSyQr4lZRv1xJQ=="], - "text-header-case": ["text-header-case@1.2.10", "", { "dependencies": { "text-capital-case": "1.2.10" } }, "sha512-sVb1NY9bwxtu+Z7CVyWbr+I0AkWtF0kEHL/Zz5V2u/WdkjK5tKBwl5nXf0NGy9da4ZUYTBb+TmQpOIqihzvFMQ=="], + "text-header-case": ["text-header-case@1.2.11", "", { "dependencies": { "text-capital-case": "^1.2.11" } }, "sha512-7OBHd2g7X+aH6rXMC3cANFh6yvhXjXkyumw2NaRwJRIk343pP2e1SQCTCfowPDmmi8wkZVqz1fdWNq5LwvcBOQ=="], - "text-is-lower-case": ["text-is-lower-case@1.2.10", "", {}, "sha512-dMTeTgrdWWfYf3fKxvjMkDPuXWv96cWbd1Uym6Zjv9H855S1uHxjkFsGbTYJ2tEK0NvAylRySTQlI6axlcMc4w=="], + "text-is-lower-case": ["text-is-lower-case@1.2.11", "", {}, "sha512-dBqPAkNmX7eTM7ZbS3D/UBCQ5i9EXt5tujF2wIGGbZ1+aN8bY7Qda4mDpxgd6Hbzf/z10uQWRNzupl99wFQ8CQ=="], - "text-is-upper-case": ["text-is-upper-case@1.2.10", "", {}, "sha512-PGD/cXoXECGAY1HVZxDdmpJUW2ZUAKQ6DTamDfCHC9fc/z4epOz0pB/ThBnjJA3fz+d2ApkMjAfZDjuZFcodzg=="], + "text-is-upper-case": ["text-is-upper-case@1.2.11", "", {}, "sha512-MZeUIYEYfKZ2FSeg0vnHCH4mHXLgGzes+iz2K+4BYnhnkEa2svKA1nNjQAqTUiVNHOPqPCuzmUr1LsyQZ73uyA=="], - "text-kebab-case": ["text-kebab-case@1.2.10", "", { "dependencies": { "text-no-case": "1.2.10" } }, "sha512-3XZJAApx5JQpUO7eXo7GQ2TyRcGw3OVbqxz6QJb2h+N8PbLLbz3zJVeXdGrhTkoUIbkSZ6PmHx6LRDaHXTdMcA=="], + "text-kebab-case": ["text-kebab-case@1.2.11", "", { "dependencies": { "text-no-case": "^1.2.11" } }, "sha512-RIg9iN6VwH+JrX9dFdm1nd1efPGR9LjNc0CiQz496sQETeKGkDEzxES/ZzxbkerrAL2DFEMGdLXckzDz1OEDBQ=="], - "text-lower-case": ["text-lower-case@1.2.10", "", {}, "sha512-c9j5pIAN3ObAp1+4R7970e1bgtahTRF/5ZQdX2aJBuBngYTYZZIck0NwFXUKk5BnYpLGsre5KFHvpqvf4IYKgg=="], + "text-lower-case": ["text-lower-case@1.2.11", "", {}, "sha512-txTy6y0y8M23Lhf0mk8WcvXTqlf4OQ3AGnDsRB6o3uMNfIa0CJDol2s1PdKNa63rt5B2277zkZCCn6Xeq//big=="], - "text-lower-case-first": ["text-lower-case-first@1.2.10", "", {}, "sha512-Oro84jZPDLD9alfdZWmtFHYTvCaaSz2o4thPtjMsK4GAkTyVg9juYXWj0y0YFyjLYGH69muWsBe4/MR5S7iolw=="], + "text-lower-case-first": ["text-lower-case-first@1.2.11", "", {}, "sha512-QR483XLyuyIpq8tKu1ds3Q1jfsgfaa/p9rtoQKHe6Rv5ah9ic/SUzTGN0MQ7UIS9APADd8SUPn5TTh1Z2/ACyg=="], - "text-no-case": ["text-no-case@1.2.10", "", { "dependencies": { "text-lower-case": "1.2.10" } }, "sha512-4/m79pzQrywrwEG5lCULY1lQvFY+EKjhH9xSMT6caPK5plqzm9Y7rXyv+UXPd3s9qH6QODZnvsAYWW3M0JgxRA=="], + "text-no-case": ["text-no-case@1.2.11", "", { "dependencies": { "text-lower-case": "^1.2.11" } }, "sha512-wazS7FEq0Ct3aJzeE8MEMcSs0eW4+/X/fwdotv/rG66bLS+g1T0pa0gUsbBGjjLFs191AIXVIry+bYE0uaaBBQ=="], - "text-param-case": ["text-param-case@1.2.10", "", { "dependencies": { "text-dot-case": "1.2.10" } }, "sha512-hkavcLsRRzZcGryPAshct1AwIOMj/FexYjMaLpGZCYYBn1lcZEeyMzJZPSckzkOYpq35LYSQr3xZto9XU5OAsw=="], + "text-param-case": ["text-param-case@1.2.11", "", { "dependencies": { "text-dot-case": "^1.2.11" } }, "sha512-3EMMAMLSz/mJXOnATNnrS+dZAvghpq09VhOVYDOkUnbm5zlYc6iU5AZOKVDpiAVVllQ9P1h5IKVZzsEYrdIRGw=="], - "text-pascal-case": ["text-pascal-case@1.2.10", "", { "dependencies": { "text-no-case": "1.2.10" } }, "sha512-/kynZD8vTYOmm/RECjIDaz3qYEUZc/N/bnC79XuAFxwXjdNVjj/jGovKJLRzqsYK/39N22XpGcVmGg7yIrbk6w=="], + "text-pascal-case": ["text-pascal-case@1.2.11", "", { "dependencies": { "text-no-case": "^1.2.11" } }, "sha512-BNhQ1O/g/Q4dH5gPyLIJLDLDknl2dipBwV629ScsiZCKJaCLGXYhTXp23rp9Htg3O5OSSsiU3mqDKq+pBmwTSw=="], - "text-path-case": ["text-path-case@1.2.10", "", { "dependencies": { "text-dot-case": "1.2.10" } }, "sha512-vbKdRCaVEeOaW6sm24QP9NbH7TS9S4ZQ3u19H8eylDox7m2HtFwYIBjAPv+v3z4I/+VjrMy9LB54lNP1uEqRHw=="], + "text-path-case": ["text-path-case@1.2.11", "", { "dependencies": { "text-dot-case": "^1.2.11" } }, "sha512-FsJU4BmMdtLtmnBK/XRJPqTwLF8yFiTEClHjxlQjSAG5Xt9R4p6D1WNaM1CI2dG5Lr4rsFM4jiVC620m0AsRbw=="], - "text-sentence-case": ["text-sentence-case@1.2.10", "", { "dependencies": { "text-no-case": "1.2.10", "text-upper-case-first": "1.2.10" } }, "sha512-NO4MRlbfxFhl9QgQLuCL4xHmvE7PUWHVPWsZxQ5nzRtDjXOUllWvtsvl8CP5tBEvBmzg0kwfflxfhRtr5vBQGg=="], + "text-sentence-case": ["text-sentence-case@1.2.11", "", { "dependencies": { "text-no-case": "^1.2.11", "text-upper-case-first": "^1.2.11" } }, "sha512-ApiVsvdLy+Wb8x7mZRVuoy8VO12jJ22G2djVM3ZZbUhXVIkqGgHxmiXRwdhRPoWGojK9n53m7jviJgBVNdRn+g=="], - "text-snake-case": ["text-snake-case@1.2.10", "", { "dependencies": { "text-dot-case": "1.2.10" } }, "sha512-6ttMZ+B9jkHKun908HYr4xSvEtlbfJJ4MvpQ06JEKRGhwjMI0x8t2Wywp+MEzN6142O6E/zKhra18KyBL6cvXA=="], + "text-snake-case": ["text-snake-case@1.2.11", "", { "dependencies": { "text-dot-case": "^1.2.11" } }, "sha512-NOEQvjyyVABB41SS8dUx423Y6hWS+Z4TrAAJg1xzCkOD3q9y0JtdJjCvCA1FWI8oDu+HiIOp/N446uDM8j54XQ=="], - "text-swap-case": ["text-swap-case@1.2.10", "", {}, "sha512-vO3jwInIk0N77oEFakYZ2Hn/llTmRwf2c3RvkX/LfvmLWVp+3QcIc6bwUEtbqGQ5Xh2okjFhYrfkHZstVc3N4Q=="], + "text-swap-case": ["text-swap-case@1.2.11", "", {}, "sha512-PBmC5xvZdDZ4suikydpeXH0s4JV2XHelMj9/OEXEbA3oLpdV2A+B4BspVDWVw7C2Gi5eCareqk/7EE8I1/WwgQ=="], "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], - "text-title-case": ["text-title-case@1.2.10", "", { "dependencies": { "text-no-case": "1.2.10", "text-upper-case-first": "1.2.10" } }, "sha512-bqA+WWexUMWu9A3fdNar+3GXXW+c5xOvMyuK5hOx/w0AlqhyQptyCrMFjGB8Fd9dxbryBNmJ+5rWtC1OBDxlaA=="], + "text-title-case": ["text-title-case@1.2.11", "", { "dependencies": { "text-no-case": "^1.2.11", "text-upper-case-first": "^1.2.11" } }, "sha512-V1GZy0XlqdkYUQm0tqm1jqtYlXJqFVMreBCTUReOaz8d/JbozTSpZrcakIeV8+1bN7LsvfhPFhA5zREiax6YIA=="], - "text-upper-case": ["text-upper-case@1.2.10", "", {}, "sha512-L1AtZ8R+jtSMTq0Ffma9R4Rzbrc3iuYW89BmWFH41AwnDfRmEBlBOllm1ZivRLQ/6pEu2p+3XKBHx9fsMl2CWg=="], + "text-upper-case": ["text-upper-case@1.2.11", "", {}, "sha512-BfTL7yB1YIRlVGNdZUvno013hOq2cRs07fDR2ApppOXRDuKrEmsLDEY82xXlDzQHELp0jexqkI+NeyPIl6MtMw=="], - "text-upper-case-first": ["text-upper-case-first@1.2.10", "", {}, "sha512-VXs7j7BbpKwvolDh5fwpYRmMrUHGkxbY8E90fhBzKUoKfadvWmPT/jFieoZ4UPLzr208pXvQEFbb2zO9Qzs9Fg=="], + "text-upper-case-first": ["text-upper-case-first@1.2.11", "", {}, "sha512-vgfbwKo8TEJbRsapR9LWWvIJRnv8u9aXVa6cyYOAQQmurCx54Cnt59x5fKdiq+hFaBJ51AbzCgMpbP3p65/pHQ=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], - "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + "thread-stream": ["thread-stream@3.2.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw=="], "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], @@ -5587,7 +5745,7 @@ "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="], @@ -5659,13 +5817,13 @@ "tsutils": ["tsutils@3.21.0", "", { "dependencies": { "tslib": "^1.8.1" }, "peerDependencies": { "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA=="], - "tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="], + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.9.14", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.14", "@turbo/darwin-arm64": "2.9.14", "@turbo/linux-64": "2.9.14", "@turbo/linux-arm64": "2.9.14", "@turbo/windows-64": "2.9.14", "@turbo/windows-arm64": "2.9.14" }, "bin": { "turbo": "bin/turbo" } }, "sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw=="], + "turbo": ["turbo@2.9.16", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.16", "@turbo/darwin-arm64": "2.9.16", "@turbo/linux-64": "2.9.16", "@turbo/linux-arm64": "2.9.16", "@turbo/windows-64": "2.9.16", "@turbo/windows-arm64": "2.9.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -5685,13 +5843,15 @@ "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], - "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="], + "typescript-eslint": ["typescript-eslint@8.60.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.60.1", "@typescript-eslint/parser": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/utils": "8.60.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA=="], + + "typescript-event-target": ["typescript-event-target@1.1.2", "", {}, "sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -5711,9 +5871,9 @@ "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], - "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "undici": ["undici@7.27.1", "", {}, "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -5753,7 +5913,7 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "unrs-resolver": ["unrs-resolver@1.12.2", "", { "dependencies": { "napi-postinstall": "^0.3.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.12.2", "@unrs/resolver-binding-android-arm64": "1.12.2", "@unrs/resolver-binding-darwin-arm64": "1.12.2", "@unrs/resolver-binding-darwin-x64": "1.12.2", "@unrs/resolver-binding-freebsd-x64": "1.12.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-musl": "1.12.2", "@unrs/resolver-binding-openharmony-arm64": "1.12.2", "@unrs/resolver-binding-wasm32-wasi": "1.12.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ=="], @@ -5775,7 +5935,7 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - "use-stick-to-bottom": ["use-stick-to-bottom@1.1.4", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2w/lydkrwhWMv1vCaEhYbzMDhgbwIodHpAHPV0/xKJErRkbjDEUe1EWmvr6Fwb+qhiERjc1EWgAEZaSaF69CpA=="], + "use-stick-to-bottom": ["use-stick-to-bottom@1.1.6", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-z3Up8jYQGTkUCsGBnwg6/wj70KgXoW5Kz1AAc1j8MtQuYMBo6ZsdhrIXoegxa7gaMMilgQYyTohTrt3p94jHog=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], @@ -5793,6 +5953,10 @@ "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + "validate.io-array": ["validate.io-array@1.0.6", "", {}, "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg=="], + + "validate.io-function": ["validate.io-function@1.0.2", "", {}, "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], @@ -5811,6 +5975,10 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="], + "vscode-oniguruma": ["vscode-oniguruma@2.0.1", "", {}, "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ=="], "vscode-textmate": ["vscode-textmate@9.3.2", "", {}, "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q=="], @@ -5827,17 +5995,17 @@ "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], - "web-vitals": ["web-vitals@5.2.0", "", {}, "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA=="], + "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="], "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "webpack": ["webpack@5.106.2", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "loader-runner": "^4.3.1", "mime-db": "^1.54.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "peerDependencies": { "webpack-cli": "*" }, "optionalPeers": ["webpack-cli"], "bin": { "webpack": "bin/webpack.js" } }, "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA=="], + "webpack": ["webpack@5.107.2", "", { "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.22.0", "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "loader-runner": "^4.3.2", "mime-db": "^1.54.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.5.0", "watchpack": "^2.5.1", "webpack-sources": "^3.5.0" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ=="], - "webpack-sources": ["webpack-sources@3.4.1", "", {}, "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A=="], + "webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "well-known-symbols": ["well-known-symbols@2.0.0", "", {}, "sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q=="], @@ -5857,7 +6025,7 @@ "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], @@ -5879,7 +6047,7 @@ "write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -5945,73 +6113,87 @@ "zod-validation-error": ["zod-validation-error@1.5.0", "", { "peerDependencies": { "zod": "^3.18.0" } }, "sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw=="], - "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "@ai-sdk/provider-utils-v5/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], - "@ai-sdk/ui-utils-v5/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + "@ai-sdk/provider-utils-v5/eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], - "@ai-sdk/ui-utils-v5/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + "@ai-sdk/provider-utils-v6/eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], - "@antfu/install-pkg/tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "@antfu/install-pkg/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "@antfu/ni/ansis": ["ansis@4.3.0", "", {}, "sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg=="], + "@antfu/ni/ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], - "@antfu/ni/tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "@antfu/ni/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@apm-js-collab/code-transformer/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "@apm-js-collab/tracing-hooks/@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], + "@artilleryio/int-core/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "@artilleryio/int-core/csv-parse": ["csv-parse@4.16.3", "", {}, "sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg=="], "@artilleryio/int-core/socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], - "@artilleryio/int-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "@artilleryio/int-core/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], "@asyncapi/parser/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], - "@asyncapi/parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], + "@autumn/auth/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "@autumn/leaf/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@autumn/leaf/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@autumn/logging/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@autumn/mcp/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@autumn/mcp/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - - "@autumn/mcp-server/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - - "@autumn/mcp-server/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "@autumn/openapi/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], - "@autumn/server/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@autumn/scripts/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@autumn/server/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@autumn/server/@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], "@autumn/server/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260511.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260511.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA=="], - "@autumn/server/autumn-js": ["autumn-js@0.1.85", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "convex": "^1.25.4", "react": "*" }, "optionalPeers": ["better-auth", "better-call", "convex", "react"] }, "sha512-PDud/t8z5bDJcD7ptyHzTaoJ0A8zkxvQ4TYcJ48RtgKDdOkVY36D1T6udVLwLDnWw4J5KXwJgEuGxHdd+cuABw=="], + "@autumn/server/autumn-js": ["autumn-js@0.1.85", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "convex": "^1.25.4" }, "optionalPeers": ["better-auth", "better-call", "convex"] }, "sha512-PDud/t8z5bDJcD7ptyHzTaoJ0A8zkxvQ4TYcJ48RtgKDdOkVY36D1T6udVLwLDnWw4J5KXwJgEuGxHdd+cuABw=="], "@autumn/server/ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], + "@autumn/server/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="], + "@autumn/shared/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@autumn/vite/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], "@autumn/vite/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], - "@autumn/website/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@autumn/website/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - "@autumn/website/eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], + "@autumn/website/eslint": ["eslint@10.4.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw=="], "@autumn/website/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "@autumn/website/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - "@autumn/website/shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="], + "@autumn/website/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "@autumn/website/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -6121,11 +6303,15 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@better-auth/cli/@better-auth/core": ["@better-auth/core@1.4.21", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-R4s7pwShkqB21fZ599QASbXxqFcoxanLyz7DHSX6SJPNYV748wBLsm3xM9VrjfvWMpS+cQUErOCt9yWT1hMn6w=="], + + "@better-auth/cli/better-auth": ["better-auth@1.4.21", "", { "dependencies": { "@better-auth/core": "1.4.21", "@better-auth/telemetry": "1.4.21", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-qdrIZS7xnGF2HPBV5wYNPWTkPojhauOOjz1+MhLvwFy+zXpgLofQmWsI5I9DY+ef845NKt93XcgpyAc4RPPT9A=="], + "@better-auth/cli/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "@better-auth/cli/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], - "@better-auth/cli/drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="], + "@better-auth/cli/drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="], "@better-auth/cli/yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="], @@ -6155,7 +6341,7 @@ "@better-auth/prisma-adapter/@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="], - "@datadog/datadog-api-client/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@datadog/datadog-api-client/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -6179,8 +6365,6 @@ "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "@eslint/eslintrc/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "@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=="], @@ -6195,14 +6379,12 @@ "@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=="], + "@infisical/sdk/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - - "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -6211,22 +6393,24 @@ "@langchain/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@langchain/langgraph/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "@langchain/langgraph/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], - "@langchain/langgraph-checkpoint/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "@langchain/langgraph-checkpoint/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "@langchain/langgraph-sdk/p-queue": ["p-queue@9.3.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang=="], "@langchain/langgraph-sdk/p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], - "@langchain/langgraph-sdk/uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], + "@langchain/langgraph-sdk/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], + + "@mastra/braintrust/braintrust": ["braintrust@2.2.2", "", { "dependencies": { "@ai-sdk/provider": "^1.1.3", "@next/env": "^14.2.3", "@types/nunjucks": "^3.2.6", "@vercel/functions": "^1.0.2", "ajv": "^8.17.1", "argparse": "^2.0.1", "boxen": "^8.0.1", "chalk": "^4.1.2", "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "cors": "^2.8.5", "dotenv": "^16.4.5", "esbuild": "^0.27.0", "eventsource-parser": "^1.1.2", "express": "^4.21.2", "graceful-fs": "^4.2.11", "http-errors": "^2.0.0", "minimatch": "^9.0.3", "mustache": "^4.2.0", "nunjucks": "^3.2.4", "pluralize": "^8.0.0", "simple-git": "^3.21.0", "source-map": "^0.7.4", "termi-link": "^1.0.1", "uuid": "^9.0.1", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "zod": "^3.25.34 || ^4.0" }, "bin": { "braintrust": "dist/cli.js" } }, "sha512-g8TPnfZb7X8ziJG3w2iYRBMiIbSTV6YW79rjhDyDAeVCwa4hq52ns4JzQeQTPRWusm7vE3gXAEgIxuBc9q18uQ=="], "@mastra/core/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], - "@mastra/core/hono": ["hono@4.12.22", "", {}, "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw=="], - "@mastra/core/p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], + "@mendable/firecrawl-js/axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], + "@mermaid-js/parser/@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@mintlify/cli/@inquirer/prompts": ["@inquirer/prompts@7.9.0", "", { "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", "@inquirer/editor": "^4.2.21", "@inquirer/expand": "^4.0.21", "@inquirer/input": "^4.2.5", "@inquirer/number": "^3.0.21", "@inquirer/password": "^4.0.21", "@inquirer/rawlist": "^4.1.9", "@inquirer/search": "^3.2.0", "@inquirer/select": "^4.4.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A=="], @@ -6289,6 +6473,8 @@ "@mintlify/link-rot/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="], + "@mintlify/models/axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], + "@mintlify/prebuild/chalk": ["chalk@5.3.0", "", {}, "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w=="], "@mintlify/prebuild/fs-extra": ["fs-extra@11.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw=="], @@ -6317,6 +6503,8 @@ "@mintlify/previewing/socket.io": ["socket.io@4.8.0", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.3.2", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA=="], + "@mintlify/previewing/tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], + "@mintlify/previewing/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="], "@mintlify/previewing/yargs": ["yargs@17.7.1", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw=="], @@ -6347,6 +6535,8 @@ "@mishieck/ink-titled-box/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], @@ -6537,7 +6727,7 @@ "@orpc/server/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "@orpc/shared/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "@orpc/shared/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -6545,7 +6735,7 @@ "@posthog/ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="], - "@posthog/ai/openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="], + "@posthog/ai/openai": ["openai@6.42.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg=="], "@posthog/ai/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -6633,23 +6823,25 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@react-grab/cli/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "@react-grab/cli/ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], - "@react-grab/cli/tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "@react-grab/cli/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], - "@sentry-internal/browser-utils/@sentry/core": ["@sentry/core@10.53.1", "", {}, "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA=="], + "@sentry-internal/browser-utils/@sentry/core": ["@sentry/core@10.56.0", "", {}, "sha512-L+u1dIz5SANrmST5jhIwETtt4apILgKrylv12X4hKJU0PvZl+NorjeV/ty3MwzpKQPg6b6q6qMOSLc1rLpy3iQ=="], - "@sentry-internal/feedback/@sentry/core": ["@sentry/core@10.53.1", "", {}, "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA=="], + "@sentry-internal/feedback/@sentry/core": ["@sentry/core@10.56.0", "", {}, "sha512-L+u1dIz5SANrmST5jhIwETtt4apILgKrylv12X4hKJU0PvZl+NorjeV/ty3MwzpKQPg6b6q6qMOSLc1rLpy3iQ=="], - "@sentry-internal/replay/@sentry/core": ["@sentry/core@10.53.1", "", {}, "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA=="], + "@sentry-internal/replay/@sentry/core": ["@sentry/core@10.56.0", "", {}, "sha512-L+u1dIz5SANrmST5jhIwETtt4apILgKrylv12X4hKJU0PvZl+NorjeV/ty3MwzpKQPg6b6q6qMOSLc1rLpy3iQ=="], - "@sentry-internal/replay-canvas/@sentry/core": ["@sentry/core@10.53.1", "", {}, "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA=="], + "@sentry-internal/replay-canvas/@sentry/core": ["@sentry/core@10.56.0", "", {}, "sha512-L+u1dIz5SANrmST5jhIwETtt4apILgKrylv12X4hKJU0PvZl+NorjeV/ty3MwzpKQPg6b6q6qMOSLc1rLpy3iQ=="], - "@sentry/browser/@sentry/core": ["@sentry/core@10.53.1", "", {}, "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA=="], + "@sentry/browser/@sentry/core": ["@sentry/core@10.56.0", "", {}, "sha512-L+u1dIz5SANrmST5jhIwETtt4apILgKrylv12X4hKJU0PvZl+NorjeV/ty3MwzpKQPg6b6q6qMOSLc1rLpy3iQ=="], + + "@sentry/bundler-plugin-core/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "@sentry/bundler-plugin-core/unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], "@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -6683,9 +6875,21 @@ "@sentry/opentelemetry/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@sentry/react/@sentry/core": ["@sentry/core@10.53.1", "", {}, "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA=="], + "@sentry/react/@sentry/core": ["@sentry/core@10.56.0", "", {}, "sha512-L+u1dIz5SANrmST5jhIwETtt4apILgKrylv12X4hKJU0PvZl+NorjeV/ty3MwzpKQPg6b6q6qMOSLc1rLpy3iQ=="], - "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="], + "@sentry/vite-plugin/unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], + + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@slack/logger/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@slack/socket-mode/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@slack/web-api/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@slack/web-api/@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "@smithy/abort-controller/@smithy/types": ["@smithy/types@3.7.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg=="], @@ -6819,7 +7023,7 @@ "@tailwindcss/vite/tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], - "@tinybirdco/sdk/@clack/prompts": ["@clack/prompts@1.4.0", "", { "dependencies": { "@clack/core": "1.3.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA=="], + "@tinybirdco/sdk/@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], "@tinybirdco/sdk/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -6839,6 +7043,8 @@ "@trigger.dev/core/@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.5", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ=="], + "@trigger.dev/core/eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "@trigger.dev/core/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], "@trigger.dev/core/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], @@ -6847,55 +7053,57 @@ "@trigger.dev/core/socket.io": ["socket.io@4.7.4", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.3.2", "engine.io": "~6.5.2", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw=="], + "@trigger.dev/schema-to-json/effect": ["effect@3.21.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-RqwU7WnJ6CqYhyjpOVJA5vh1Sgkn6eVECO6mnD0EjlbWcC2M3LJaPglXXr13Rdo/Y+B+wTEPzGRYFNL2xKxNeQ=="], + "@trigger.dev/sdk/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@types/body-parser/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/body-parser/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/buffer-from/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/buffer-from/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/cacheable-request/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/cacheable-request/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/chai-http/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/chai-http/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/connect/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/connect/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/cors/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/cors/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/es-aggregate-error/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/es-aggregate-error/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/express-serve-static-core/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/express-serve-static-core/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/keyv/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/keyv/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/mysql/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/mysql/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/node-fetch/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/node-fetch/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/pg/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/pg/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/react-syntax-highlighter/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react-syntax-highlighter/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - "@types/responselike/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/responselike/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/send/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/send/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/serve-static/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/serve-static/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/set-cookie-parser/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/set-cookie-parser/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/superagent/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/superagent/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/tedious/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/tedious/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/ws/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/ws/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "@types/yauzl/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/yauzl/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "@useautumn/sdk/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "@useautumn/ai-sdk/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@useautumn/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -6903,14 +7111,20 @@ "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "agent-install/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "aggregate-error/clean-stack": ["clean-stack@4.2.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg=="], + "ansi-align/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=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "artillery/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "artillery/csv-parse": ["csv-parse@4.16.3", "", {}, "sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg=="], + "artillery/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "artillery/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "artillery-plugin-ensure/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -6937,15 +7151,15 @@ "artillery-plugin-publish-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], - "artillery-plugin-publish-metrics/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "artillery-plugin-publish-metrics/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw=="], "artillery-plugin-publish-metrics/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], "artillery-plugin-publish-metrics/prom-client": ["prom-client@14.2.0", "", { "dependencies": { "tdigest": "^0.1.1" } }, "sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA=="], - "atmn/@tanstack/react-query": ["@tanstack/react-query@5.100.11", "", { "dependencies": { "@tanstack/query-core": "5.100.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg=="], + "atmn/@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="], - "atmn/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "atmn/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "atmn/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260511.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260511.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA=="], @@ -6957,16 +7171,22 @@ "atmn/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "atmn/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "atmn/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "autoevals/openai": ["openai@6.42.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg=="], + "autumn-js/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], - "autumn-js/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "autumn-js/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - "autumn-js/next": ["next@15.5.18", "", { "dependencies": { "@next/env": "15.5.18", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.18", "@next/swc-darwin-x64": "15.5.18", "@next/swc-linux-arm64-gnu": "15.5.18", "@next/swc-linux-arm64-musl": "15.5.18", "@next/swc-linux-x64-gnu": "15.5.18", "@next/swc-linux-x64-musl": "15.5.18", "@next/swc-win32-arm64-msvc": "15.5.18", "@next/swc-win32-x64-msvc": "15.5.18", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ=="], + "autumn-js/next": ["next@15.5.19", "", { "dependencies": { "@next/env": "15.5.19", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.19", "@next/swc-darwin-x64": "15.5.19", "@next/swc-linux-arm64-gnu": "15.5.19", "@next/swc-linux-arm64-musl": "15.5.19", "@next/swc-linux-x64-gnu": "15.5.19", "@next/swc-linux-x64-musl": "15.5.19", "@next/swc-win32-arm64-msvc": "15.5.19", "@next/swc-win32-x64-msvc": "15.5.19", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg=="], "autumn-js/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "autumn-js/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "autumn-js/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "ava/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], @@ -6993,8 +7213,6 @@ "better-call/set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], - "better-call/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -7003,7 +7221,15 @@ "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "bun-types/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "boxen/camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + + "braintrust/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "bullmq/ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + + "bullmq/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "bun-types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -7019,11 +7245,9 @@ "camelcase-keys/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], - "chat/remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="], + "checkout/@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="], - "checkout/@tanstack/react-query": ["@tanstack/react-query@5.100.11", "", { "dependencies": { "@tanstack/query-core": "5.100.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg=="], - - "checkout/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "checkout/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "checkout/@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -7033,12 +7257,16 @@ "checkout/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "checkout/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "clean-stack/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "cli-progress/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=="], + "cli-table3/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=="], "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], @@ -7081,14 +7309,16 @@ "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "engine.io/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "engine.io/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], - "engine.io/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "engine.io/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], - "engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "engine.io-client/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -7165,8 +7395,12 @@ "eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@2.1.0", "", {}, "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw=="], + "eventsource/eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "execa/figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "execa/pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -7193,18 +7427,20 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "front-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], "glob/foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -7215,6 +7451,8 @@ "gradient-string/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "headers-polyfill/set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], "hosted-git-info/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], @@ -7251,7 +7489,7 @@ "jake/async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], - "jest-worker/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "jest-worker/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], @@ -7279,6 +7517,8 @@ "micromark-extension-frontmatter/fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + "micromark-extension-mdxjs/micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "minimist-options/arrify": ["arrify@1.0.1", "", {}, "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA=="], @@ -7291,7 +7531,7 @@ "mocha/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "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/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "mocha/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -7301,7 +7541,7 @@ "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], - "msw/@inquirer/confirm": ["@inquirer/confirm@6.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw=="], + "msw/@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], "msw/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -7309,7 +7549,9 @@ "msw/tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], - "msw/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "msw/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + + "next/@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -7327,6 +7569,8 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "nunjucks/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "open-editor/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -7367,8 +7611,6 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], "pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], @@ -7387,19 +7629,11 @@ "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], - "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], - - "posthog-js/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="], - - "posthog-js/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], - - "posthog-js/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="], - "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - "protobufjs/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "protobufjs/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -7429,17 +7663,15 @@ "react-day-picker/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], - "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "react-devtools-core/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], "react-email/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], - "react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], - "react-email/log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], - "react-email/next": ["next@15.5.18", "", { "dependencies": { "@next/env": "15.5.18", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.18", "@next/swc-darwin-x64": "15.5.18", "@next/swc-linux-arm64-gnu": "15.5.18", "@next/swc-linux-arm64-musl": "15.5.18", "@next/swc-linux-x64-gnu": "15.5.18", "@next/swc-linux-x64-musl": "15.5.18", "@next/swc-win32-arm64-msvc": "15.5.18", "@next/swc-win32-x64-msvc": "15.5.18", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ=="], + "react-email/next": ["next@15.5.19", "", { "dependencies": { "@next/env": "15.5.19", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.19", "@next/swc-darwin-x64": "15.5.19", "@next/swc-linux-arm64-gnu": "15.5.19", "@next/swc-linux-arm64-musl": "15.5.19", "@next/swc-linux-x64-gnu": "15.5.19", "@next/swc-linux-x64-musl": "15.5.19", "@next/swc-win32-arm64-msvc": "15.5.19", "@next/swc-win32-x64-msvc": "15.5.19", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg=="], "react-email/ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], @@ -7467,8 +7699,6 @@ "rimraf/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "run-jxa/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -7481,7 +7711,7 @@ "sdk-test/@types/node": ["@types/node@20.19.41", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ=="], - "sdk-test/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "sdk-test/@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "sdk-test/@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -7491,6 +7721,8 @@ "sdk-test/react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "sdk-test/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -7513,9 +7745,9 @@ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "socket.io-adapter/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "socket.io-adapter/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], - "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -7525,6 +7757,8 @@ "streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], + "streamdown/remend": ["remend@1.0.1", "", {}, "sha512-152puVH0qMoRJQFnaMG+rVDdf01Jq/CaED+MBuXExurJgdbkLp0c3TIe4R12o28Klx8uyGsjvFNG05aFG69G9w=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -7535,6 +7769,8 @@ "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "supertap/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "supports-hyperlinks/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -7543,8 +7779,6 @@ "sync-content/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "sync-content/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "tempy/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], "tempy/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], @@ -7571,6 +7805,8 @@ "ts-to-zod/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "ts-to-zod/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ts-to-zod/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "tsc-alias/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], @@ -7579,6 +7815,8 @@ "tshy/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "tshy/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "tsup/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "tsutils/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -7615,17 +7853,17 @@ "xo/@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], - "xo/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@5.62.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/type-utils": "5.62.0", "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.0", "natural-compare-lite": "^1.4.0", "semver": "^7.3.7", "tsutils": "^3.21.0" }, "peerDependencies": { "@typescript-eslint/parser": "^5.0.0", "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0", "typescript": "*" }, "optionalPeers": ["typescript"], "bundled": true }, "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag=="], + "xo/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@5.62.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/type-utils": "5.62.0", "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.0", "natural-compare-lite": "^1.4.0", "semver": "^7.3.7", "tsutils": "^3.21.0" }, "peerDependencies": { "@typescript-eslint/parser": "^5.0.0", "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "bundled": true }, "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag=="], - "xo/@typescript-eslint/parser": ["@typescript-eslint/parser@5.62.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0", "typescript": "*" }, "optionalPeers": ["typescript"], "bundled": true }, "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA=="], + "xo/@typescript-eslint/parser": ["@typescript-eslint/parser@5.62.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "bundled": true }, "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA=="], "xo/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0" } }, "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w=="], - "xo/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@5.62.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "5.62.0", "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "tsutils": "^3.21.0" }, "peerDependencies": { "eslint": "*", "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew=="], + "xo/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@5.62.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "5.62.0", "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "tsutils": "^3.21.0" }, "peerDependencies": { "eslint": "*" } }, "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew=="], "xo/@typescript-eslint/types": ["@typescript-eslint/types@5.62.0", "", {}, "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ=="], - "xo/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", "semver": "^7.3.7", "tsutils": "^3.21.0" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA=="], + "xo/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", "semver": "^7.3.7", "tsutils": "^3.21.0" } }, "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA=="], "xo/@typescript-eslint/utils": ["@typescript-eslint/utils@5.62.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", "@typescript-eslint/typescript-estree": "5.62.0", "eslint-scope": "^5.1.1", "semver": "^7.3.7" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ=="], @@ -7635,7 +7873,7 @@ "xo/braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "xo/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "xo/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "xo/dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -7665,8 +7903,6 @@ "xo/graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], - "xo/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "xo/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "xo/is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -7695,12 +7931,10 @@ "xo/run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "xo/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "xo/semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], "xo/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "xo/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "xo/to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "xo/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -7717,10 +7951,6 @@ "zod-from-json-schema/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@ai-sdk/ui-utils-v5/@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - - "@ai-sdk/ui-utils-v5/@ai-sdk/provider-utils/secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], - "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@artilleryio/int-core/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -7729,11 +7959,13 @@ "@artilleryio/int-core/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - "@artilleryio/int-core/socket.io-client/engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="], + "@artilleryio/int-core/socket.io-client/engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], - "@asyncapi/parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@autumn/auth/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@autumn/mcp-server/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@autumn/leaf/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@autumn/logging/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@autumn/mcp/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -7757,6 +7989,8 @@ "@autumn/server/ink/@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + "@autumn/server/ink/@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + "@autumn/server/ink/cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "@autumn/server/ink/is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], @@ -7773,7 +8007,7 @@ "@autumn/server/ink/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - "@autumn/server/ink/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "@autumn/server/ink/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "@autumn/server/ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], @@ -7785,7 +8019,7 @@ "@autumn/website/eslint/@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - "@autumn/website/eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@autumn/website/eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], "@autumn/website/eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -7801,17 +8035,17 @@ "@autumn/website/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "@autumn/website/shiki/@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="], + "@autumn/website/shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "@autumn/website/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="], + "@autumn/website/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "@autumn/website/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="], + "@autumn/website/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "@autumn/website/shiki/@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="], + "@autumn/website/shiki/@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "@autumn/website/shiki/@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="], + "@autumn/website/shiki/@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], - "@autumn/website/shiki/@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="], + "@autumn/website/shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "@aws-sdk/client-sso-oidc/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@3.1.2", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "@smithy/types": "^3.3.0", "@smithy/util-hex-encoding": "^3.0.0", "@smithy/util-middleware": "^3.0.3", "@smithy/util-uri-escape": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-3BcPylEsYtD0esM4Hoyml/+s7WP2LFhcM3J2AGdcL2vx9O60TtfpDOL72gjb4lU8NeRPeKAwR77YNyyGvMbuEA=="], @@ -7843,14 +8077,16 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@better-auth/cli/@better-auth/core/better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + + "@better-auth/cli/better-auth/better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + "@datadog/datadog-api-client/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], "@dotenvx/dotenvx/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -7861,17 +8097,15 @@ "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "@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=="], + "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "@google/genai/p-retry/@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], - "@humanwhocodes/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "@humanwhocodes/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "@infisical/sdk/@aws-sdk/credential-providers/@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.600.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/client-sso-oidc": "3.600.0", "@aws-sdk/client-sts": "3.600.0", "@aws-sdk/core": "3.598.0", "@aws-sdk/credential-provider-node": "3.600.0", "@aws-sdk/middleware-host-header": "3.598.0", "@aws-sdk/middleware-logger": "3.598.0", "@aws-sdk/middleware-recursion-detection": "3.598.0", "@aws-sdk/middleware-user-agent": "3.598.0", "@aws-sdk/region-config-resolver": "3.598.0", "@aws-sdk/types": "3.598.0", "@aws-sdk/util-endpoints": "3.598.0", "@aws-sdk/util-user-agent-browser": "3.598.0", "@aws-sdk/util-user-agent-node": "3.598.0", "@smithy/config-resolver": "^3.0.2", "@smithy/core": "^2.2.1", "@smithy/fetch-http-handler": "^3.0.2", "@smithy/hash-node": "^3.0.1", "@smithy/invalid-dependency": "^3.0.1", "@smithy/middleware-content-length": "^3.0.1", "@smithy/middleware-endpoint": "^3.0.2", "@smithy/middleware-retry": "^3.0.4", "@smithy/middleware-serde": "^3.0.1", "@smithy/middleware-stack": "^3.0.1", "@smithy/node-config-provider": "^3.1.1", "@smithy/node-http-handler": "^3.0.1", "@smithy/protocol-http": "^4.0.1", "@smithy/smithy-client": "^3.1.2", "@smithy/types": "^3.1.0", "@smithy/url-parser": "^3.0.1", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", "@smithy/util-defaults-mode-browser": "^3.0.4", "@smithy/util-defaults-mode-node": "^3.0.4", "@smithy/util-endpoints": "^2.0.2", "@smithy/util-middleware": "^3.0.1", "@smithy/util-retry": "^3.0.1", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-8dYsnDLiD0rjujRiZZl0E57heUkHqMSFZHBi0YMs57SM8ODPxK3tahwDYZtS7bqanvFKZwGy+o9jIcij7jBOlA=="], @@ -7905,10 +8139,16 @@ "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@langchain/langgraph-sdk/p-queue/p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + "@mastra/braintrust/braintrust/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "@mastra/braintrust/braintrust/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@mastra/braintrust/braintrust/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "@mastra/braintrust/braintrust/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@mintlify/cli/ink/@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], "@mintlify/cli/ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -7925,8 +8165,6 @@ "@mintlify/cli/inquirer/run-async": ["run-async@3.0.0", "", {}, "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q=="], - "@mintlify/cli/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@mintlify/cli/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "@mintlify/cli/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -7939,8 +8177,6 @@ "@mintlify/common/hast-util-to-html/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], - "@mintlify/common/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@mintlify/common/mdast-util-gfm/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], "@mintlify/common/mdast-util-mdx-jsx/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], @@ -7975,8 +8211,6 @@ "@mintlify/link-rot/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="], - "@mintlify/prebuild/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@mintlify/prebuild/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@mintlify/prebuild/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], @@ -8051,9 +8285,7 @@ "@mintlify/previewing/ink/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "@mintlify/previewing/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "@mintlify/previewing/socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "@mintlify/previewing/socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "@mintlify/previewing/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -8063,12 +8295,8 @@ "@mintlify/previewing/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/scraping/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@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/validation/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@mishieck/ink-titled-box/ink/@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], "@mishieck/ink-titled-box/ink/cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], @@ -8085,7 +8313,7 @@ "@mishieck/ink-titled-box/ink/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - "@mishieck/ink-titled-box/ink/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "@mishieck/ink-titled-box/ink/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "@mishieck/ink-titled-box/ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], @@ -8317,6 +8545,16 @@ "@react-grab/cli/ora/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "@sentry/bundler-plugin-core/glob/foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "@sentry/bundler-plugin-core/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "@sentry/bundler-plugin-core/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], + "@sentry/node-core/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], "@sentry/node/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.211.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg=="], @@ -8325,7 +8563,15 @@ "@sentry/node/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], - "@sentry/node/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "@sentry/node/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + + "@sentry/vite-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], + + "@slack/logger/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@slack/socket-mode/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@slack/web-api/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@smithy/eventstream-codec/@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@3.0.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" } }, "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w=="], @@ -8347,7 +8593,7 @@ "@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], - "@stoplight/spectral-core/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "@stoplight/spectral-core/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "@tailwindcss/postcss/@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -8377,7 +8623,7 @@ "@tailwindcss/postcss/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], - "@tinybirdco/sdk/@clack/prompts/@clack/core": ["@clack/core@1.3.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA=="], + "@tinybirdco/sdk/@clack/prompts/@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], "@tinybirdco/sdk/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], @@ -8405,7 +8651,7 @@ "@trigger.dev/core/execa/strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], - "@trigger.dev/core/socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "@trigger.dev/core/socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "@trigger.dev/core/socket.io/engine.io": ["engine.io@6.5.5", "", { "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.4.1", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA=="], @@ -8453,6 +8699,12 @@ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "ansi-align/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "artillery-plugin-ensure/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "artillery-plugin-ensure/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -8471,18 +8723,24 @@ "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], @@ -8507,7 +8765,9 @@ "artillery-plugin-publish-metrics/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], - "artillery-plugin-publish-metrics/@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], + "artillery-plugin-publish-metrics/@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "artillery-plugin-publish-metrics/@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], "artillery/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -8515,7 +8775,9 @@ "artillery/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - "atmn/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.100.11", "", {}, "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw=="], + "artillery/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "atmn/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="], "atmn/@typescript/native-preview/@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SYrqVOlapDxDG7FzHBIJbfgaix+mXPkYzYGqwpz/TAhoPA7sgbfAoGLaqi3ut9N88C/OYNhEX4tjz/0PC9i1nw=="], @@ -8549,29 +8811,29 @@ "atmn/ink/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - "atmn/ink/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "atmn/ink/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "atmn/ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], "autumn-js/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "autumn-js/next/@next/env": ["@next/env@15.5.18", "", {}, "sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g=="], + "autumn-js/next/@next/env": ["@next/env@15.5.19", "", {}, "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw=="], - "autumn-js/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ=="], + "autumn-js/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg=="], - "autumn-js/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og=="], + "autumn-js/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA=="], - "autumn-js/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw=="], + "autumn-js/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA=="], - "autumn-js/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw=="], + "autumn-js/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw=="], - "autumn-js/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.18", "", { "os": "linux", "cpu": "x64" }, "sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A=="], + "autumn-js/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.19", "", { "os": "linux", "cpu": "x64" }, "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg=="], - "autumn-js/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.18", "", { "os": "linux", "cpu": "x64" }, "sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA=="], + "autumn-js/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.19", "", { "os": "linux", "cpu": "x64" }, "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q=="], - "autumn-js/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA=="], + "autumn-js/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q=="], - "autumn-js/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.18", "", { "os": "win32", "cpu": "x64" }, "sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg=="], + "autumn-js/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.19", "", { "os": "win32", "cpu": "x64" }, "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w=="], "autumn-js/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -8595,14 +8857,42 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "braintrust/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "braintrust/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "braintrust/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "braintrust/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "braintrust/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "braintrust/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "braintrust/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "braintrust/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "braintrust/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "braintrust/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "bullmq/ioredis/@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + "bun-types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "checkout/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.100.11", "", {}, "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw=="], + "checkout/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="], "checkout/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "cli-progress/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cli-progress/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "cli-progress/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "cli-table3/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cli-table3/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -8629,6 +8919,8 @@ "engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "eslint-config-next/eslint-plugin-react-hooks/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "eslint-config-next/eslint-plugin-react-hooks/zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], @@ -8651,15 +8943,15 @@ "eslint-plugin-es/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@1.3.0", "", {}, "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ=="], - "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], - "eslint-plugin-n/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "eslint-plugin-n/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], - "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -8667,7 +8959,7 @@ "eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "eslint/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "eslint/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "execa/figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], @@ -8713,7 +9005,7 @@ "favicons/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], - "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -8721,18 +9013,20 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "front-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "fs-minipass/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], - "got/decompress-response/mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="], "gradient-string/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "gradient-string/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "ink-confirm-input/ink-text-input/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "ink-confirm-input/ink-text-input/ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], @@ -8755,7 +9049,7 @@ "ink-scroll-list/ink/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - "ink-scroll-list/ink/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "ink-scroll-list/ink/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "ink-scroll-list/ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], @@ -8787,7 +9081,7 @@ "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - "matcher-collection/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "matcher-collection/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "meow/read-pkg-up/find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "^7.1.0", "path-exists": "^5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="], @@ -8797,17 +9091,21 @@ "mocha/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "mocha/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "mocha/glob/foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "mocha/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "mocha/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "msw/@inquirer/confirm/@inquirer/core": ["@inquirer/core@11.1.10", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A=="], + "mocha/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "msw/@inquirer/confirm/@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="], + "mocha/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - "msw/tough-cookie/tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], + "msw/@inquirer/confirm/@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], - "next-mdx-remote-client/serialize-error/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "msw/@inquirer/confirm/@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], + + "msw/tough-cookie/tldts": ["tldts@7.4.2", "", { "dependencies": { "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw=="], + + "next-mdx-remote-client/serialize-error/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -8831,8 +9129,6 @@ "open-editor/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "open-editor/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "open-editor/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], "open-editor/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -8873,20 +9169,6 @@ "pkg-conf/find-up/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], - - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="], - - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ=="], - - "posthog-js/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], - - "posthog-js/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - - "posthog-js/@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], - - "posthog-js/@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - "prebuild-install/tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], "prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], @@ -8911,37 +9193,29 @@ "puppeteer/cosmiconfig/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "puppeteer/cosmiconfig/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "puppeteer/puppeteer-core/chromium-bidi": ["chromium-bidi@0.6.2", "", { "dependencies": { "mitt": "3.0.1", "urlpattern-polyfill": "10.0.0", "zod": "3.23.8" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-4WVBa6ijmUTVr9cZD4eicQD8Mdy/HCX3bzEIYYpmk0glqYLoWH+LqQEvV9RpDRzoQSbY1KJHloYXbDMXMbDPhg=="], "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "react-email/glob/foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - - "react-email/glob/jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], - - "react-email/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "react-email/log-symbols/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "react-email/next/@next/env": ["@next/env@15.5.18", "", {}, "sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g=="], + "react-email/next/@next/env": ["@next/env@15.5.19", "", {}, "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw=="], - "react-email/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ=="], + "react-email/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg=="], - "react-email/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og=="], + "react-email/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA=="], - "react-email/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw=="], + "react-email/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA=="], - "react-email/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw=="], + "react-email/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw=="], - "react-email/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.18", "", { "os": "linux", "cpu": "x64" }, "sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A=="], + "react-email/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.19", "", { "os": "linux", "cpu": "x64" }, "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg=="], - "react-email/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.18", "", { "os": "linux", "cpu": "x64" }, "sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA=="], + "react-email/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.19", "", { "os": "linux", "cpu": "x64" }, "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q=="], - "react-email/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA=="], + "react-email/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q=="], - "react-email/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.18", "", { "os": "win32", "cpu": "x64" }, "sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg=="], + "react-email/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.19", "", { "os": "win32", "cpu": "x64" }, "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w=="], "react-email/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -8959,18 +9233,12 @@ "read-pkg/normalize-package-data/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "resolve-import/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "rimraf/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "run-jxa/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "run-jxa/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "run-jxa/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "run-jxa/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], "run-jxa/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -9023,8 +9291,6 @@ "shadcn/cosmiconfig/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "shadcn/cosmiconfig/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "shadcn/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "shadcn/open/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], @@ -9041,6 +9307,8 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "supertap/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "trigger.dev/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="], "trigger.dev/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.203.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A=="], @@ -9101,8 +9369,6 @@ "xo/@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "xo/@eslint/eslintrc/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "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=="], @@ -9133,8 +9399,6 @@ "xo/eslint/globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], - "xo/eslint/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "xo/eslint/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "xo/eslint/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -9157,7 +9421,7 @@ "@artilleryio/int-core/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "@artilleryio/int-core/socket.io-client/engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "@artilleryio/int-core/socket.io-client/engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "@artilleryio/int-core/socket.io-client/engine.io-client/xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], @@ -9189,6 +9453,10 @@ "@aws-sdk/signature-v4/@smithy/signature-v4/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@1.1.0", "", { "dependencies": { "@smithy/is-array-buffer": "^1.1.0", "tslib": "^2.5.0" } }, "sha512-9m6NXE0ww+ra5HKHCHig20T+FAwxBAm7DIdwc/767uGWbRcY720ybgPacQNB96JMOI7xVr/CDa3oMzKmW4a+kw=="], + "@better-auth/cli/@better-auth/core/better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + + "@better-auth/cli/better-auth/better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "@dotenvx/dotenvx/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -9235,6 +9503,12 @@ "@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@mastra/braintrust/braintrust/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@mastra/braintrust/braintrust/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "@mastra/braintrust/braintrust/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + "@mintlify/cli/ink/@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "@mintlify/cli/ink/react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], @@ -9347,6 +9621,12 @@ "@react-grab/cli/ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "@sentry/bundler-plugin-core/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + + "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@sentry/node/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@smithy/middleware-endpoint/@smithy/core/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="], @@ -9391,12 +9671,14 @@ "@trigger.dev/core/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "@trigger.dev/core/socket.io/engine.io/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@trigger.dev/core/socket.io/engine.io/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "@trigger.dev/core/socket.io/engine.io/cookie": ["cookie@0.4.2", "", {}, "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="], "@trigger.dev/core/socket.io/engine.io/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "artillery-plugin-ensure/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "artillery-plugin-ensure/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -9419,14 +9701,20 @@ "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="], "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="], "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag=="], + "artillery-plugin-publish-metrics/@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="], + "artillery/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "artillery/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -9455,8 +9743,6 @@ "atmn/eslint-plugin-react-hooks/eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "atmn/eslint-plugin-react-hooks/eslint/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "atmn/eslint-plugin-react-hooks/eslint/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "atmn/eslint-plugin-react-hooks/eslint/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -9469,6 +9755,18 @@ "ava/cli-truncate/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "braintrust/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "braintrust/express/body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "braintrust/express/body-parser/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "braintrust/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "braintrust/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "cli-progress/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "cli-table3/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "concurrently/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -9495,8 +9793,6 @@ "find-cache-dir/pkg-dir/find-up/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "gradient-string/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "ink-confirm-input/ink-text-input/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -9521,7 +9817,7 @@ "ink-confirm-input/ink-text-input/ink/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - "ink-confirm-input/ink-text-input/ink/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "ink-confirm-input/ink-text-input/ink/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "ink-confirm-input/ink-text-input/ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], @@ -9547,15 +9843,19 @@ "meow/read-pkg-up/read-pkg/normalize-package-data": ["normalize-package-data@3.0.3", "", { "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", "semver": "^7.3.4", "validate-npm-package-license": "^3.0.1" } }, "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA=="], + "mocha/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "mocha/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "mocha/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "msw/@inquirer/confirm/@inquirer/core/@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="], + "msw/@inquirer/confirm/@inquirer/core/@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], - "msw/@inquirer/confirm/@inquirer/core/@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="], + "msw/@inquirer/confirm/@inquirer/core/@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], "msw/@inquirer/confirm/@inquirer/core/mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], - "msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="], + "msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="], "ngrok/got/cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], @@ -9579,30 +9879,14 @@ "pkg-conf/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], - - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], - - "posthog-js/@opentelemetry/sdk-logs/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - - "posthog-js/@opentelemetry/sdk-logs/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "prebuild-install/tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "public-ip/got/cacheable-request/keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "public-ip/got/cacheable-request/mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="], - "puppeteer/cosmiconfig/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "puppeteer/puppeteer-core/chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], - "react-email/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], - "react-email/next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "react-email/ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -9615,8 +9899,6 @@ "sdk-test/next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - "shadcn/cosmiconfig/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "shadcn/ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "shadcn/ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], @@ -9635,9 +9917,7 @@ "xo/@eslint/eslintrc/globals/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - "xo/@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "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/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "xo/eslint/@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -9651,9 +9931,7 @@ "xo/eslint/globals/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - "xo/eslint/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "xo/eslint/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/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "xo/eslint/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -9671,9 +9949,13 @@ "@infisical/sdk/@aws-sdk/credential-providers/@aws-sdk/client-sts/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="], + "@mastra/braintrust/braintrust/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "@mastra/braintrust/braintrust/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@mintlify/cli/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@mintlify/common/sucrase/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "@mintlify/common/sucrase/glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "@mintlify/previewing/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -9681,10 +9963,16 @@ "@prisma/config/c12/giget/nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], - "@prisma/config/c12/giget/nypm/tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "@prisma/config/c12/giget/nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "@react-grab/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "@sentry/bundler-plugin-core/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@sentry/bundler-plugin-core/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@smithy/middleware-endpoint/@smithy/core/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], "@smithy/smithy-client/@smithy/core/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], @@ -9707,9 +9995,7 @@ "atmn/eslint-plugin-react-hooks/eslint/globals/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - "atmn/eslint-plugin-react-hooks/eslint/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "atmn/eslint-plugin-react-hooks/eslint/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "atmn/eslint-plugin-react-hooks/eslint/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "atmn/eslint-plugin-react-hooks/eslint/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -9725,6 +10011,10 @@ "meow/read-pkg-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "mocha/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "mocha/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "ora/log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], @@ -9733,10 +10023,6 @@ "pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "react-email/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "read-pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -9755,6 +10041,8 @@ "xo/@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "xo/eslint/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "xo/eslint/file-entry-cache/flat-cache/keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "xo/eslint/file-entry-cache/flat-cache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], @@ -9781,6 +10069,8 @@ "@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@sentry/bundler-plugin-core/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "atmn/eslint-plugin-react-hooks/eslint/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "atmn/eslint-plugin-react-hooks/eslint/file-entry-cache/flat-cache/keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -9795,13 +10085,15 @@ "meow/read-pkg-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "mocha/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "ora/log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "trigger.dev/c12/giget/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "xo/eslint/file-entry-cache/flat-cache/rimraf/glob": ["glob@7.1.6", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA=="], + "xo/eslint/file-entry-cache/flat-cache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "atmn/eslint-plugin-react-hooks/eslint/file-entry-cache/flat-cache/rimraf/glob": ["glob@7.1.6", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA=="], + "atmn/eslint-plugin-react-hooks/eslint/file-entry-cache/flat-cache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "meow/read-pkg-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], } diff --git a/docker/Dockerfile b/docker/Dockerfile index f1e8f4511..7e9bec93a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,36 +1,62 @@ -FROM oven/bun:1.3.10 +# syntax=docker/dockerfile:1 + +# Shared runtime image for server + workers + cron + leaf. +# Each FlightControl service runs the same image with a different command: +# server -> bun start (cwd /app/server) +# workers -> bun src/workers.ts (cwd /app/server) +# cron -> bun src/cron.ts (cwd /app/server) +# leaf -> bun leaf (cwd /app/server; serves chat + MCP routes) +FROM oven/bun:1.3.10 AS deps WORKDIR /app -# Copy root package files -COPY package.json . -COPY bun.lock . +# Copy the committed lockfile + every workspace manifest. Installing against the +# real bun.lock (not a turbo-pruned one) keeps resolution byte-for-byte identical +# to local/CI: zod v3 stays hoisted for the server, autumn-js keeps its nested +# zod v4. --frozen-lockfile needs every workspace manifest present, so list them +# all (add a line here when a workspace is added). Source is copied later, so this +# layer caches until a package.json or the lockfile changes. +COPY package.json bun.lock bunfig.toml ./ +COPY server/package.json server/ +COPY shared/package.json shared/ +COPY vite/package.json vite/ +COPY scripts/package.json scripts/ +COPY apps/leaf/package.json apps/leaf/ +COPY apps/checkout/package.json apps/checkout/ +COPY apps/docs/package.json apps/docs/ +COPY apps/sdk-test/package.json apps/sdk-test/ +COPY apps/website/package.json apps/website/ +COPY packages/atmn/package.json packages/atmn/ +COPY packages/atmn-tests/package.json packages/atmn-tests/ +COPY packages/auth/package.json packages/auth/ +COPY packages/autumn-js/package.json packages/autumn-js/ +COPY packages/ksuid/package.json packages/ksuid/ +COPY packages/logging/package.json packages/logging/ +COPY packages/mcp/package.json packages/mcp/ +COPY packages/openapi/package.json packages/openapi/ +COPY packages/sdk/package.json packages/sdk/ +COPY packages/stripe-sync/package.json packages/stripe-sync/ -# Copy workspace package.json files -COPY server/package.json ./server/package.json -COPY shared/package.json ./shared/package.json -COPY vite/package.json ./vite/package.json -COPY scripts/package.json ./scripts/package.json -COPY apps/checkout/package.json ./apps/checkout/package.json -COPY apps/mcp-server/package.json ./apps/mcp-server/package.json -COPY packages/autumn-js/package.json ./packages/autumn-js/package.json -COPY packages/atmn/package.json ./packages/atmn/package.json -COPY packages/atmn-tests/package.json ./packages/atmn-tests/package.json -COPY packages/mcp/package.json ./packages/mcp/package.json -COPY packages/openapi/package.json ./packages/openapi/package.json -COPY packages/ksuid/package.json ./packages/ksuid/package.json -COPY packages/stripe-sync/package.json ./packages/stripe-sync/package.json -COPY packages/sdk/package.json ./packages/sdk/package.json -COPY apps/sdk-test/package.json ./apps/sdk-test/package.json -COPY apps/docs/package.json ./apps/docs/package.json -COPY apps/website/package.json ./apps/website/package.json +# bunfig.toml preloads ./scripts/preload-env.ts on every bun run; stub it so the +# install step doesn't fail before the real source is copied. +RUN mkdir -p scripts && touch scripts/preload-env.ts -# Install dependencies -RUN bun install --ignore-scripts +# Install only the workspaces the runtime services need (server hosts workers + +# cron), plus their transitive workspace deps. --frozen-lockfile guarantees no +# re-resolution; --filter skips the frontend-heavy workspaces. +RUN --mount=type=cache,target=/root/.bun/install/cache \ + bun install --frozen-lockfile --ignore-scripts \ + --filter @autumn/server \ + --filter @autumn/leaf -# Copy rest of the code +FROM oven/bun:1.3.10 +WORKDIR /app +ENV NODE_ENV=production + +# node_modules (+ manifests) from the cached deps layer, then the source on top. +# node_modules is .dockerignore'd, so COPY . . never clobbers the install. +COPY --from=deps /app ./ COPY . . -ENV NODE_ENV=production EXPOSE 8080 WORKDIR /app/server diff --git a/docker/dev-services.compose.yml b/docker/dev-services.compose.yml index f5cf64191..226dec407 100644 --- a/docker/dev-services.compose.yml +++ b/docker/dev-services.compose.yml @@ -29,6 +29,23 @@ services: volumes: - autumn-dev-dragonfly:/data + ngrok: + image: ngrok/ngrok:latest + profiles: + - ngrok + environment: + NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN:-} + command: + - http + - host.docker.internal:8080 + - --url=${NGROK_DOMAIN} + - --pooling-enabled + - --log=stdout + ports: + - "4040:4040" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: autumn-dev-postgres-18: autumn-dev-redis-stack: diff --git a/docker/dev.dockerfile b/docker/dev.dockerfile deleted file mode 100644 index 4fec00979..000000000 --- a/docker/dev.dockerfile +++ /dev/null @@ -1,45 +0,0 @@ -# Multi-stage Dockerfile for Autumn development -FROM oven/bun:latest AS base - -WORKDIR /app - -# Skip Puppeteer Chromium download to speed up install -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true -ENV PUPPETEER_SKIP_DOWNLOAD=true - -COPY package.json ./ -COPY bun.lock ./ -COPY shared/package*.json ./shared/ -COPY server/package*.json ./server/ -COPY vite/package*.json ./vite/ - -RUN bun install - -# Stage 1: /localtunnel -FROM base AS localtunnel -WORKDIR /app -COPY localtunnel-start.sh ./ -CMD ["sh", "localtunnel-start.sh"] - -# Stage 2: /vite -FROM base AS vite -COPY shared/ ./shared/ -WORKDIR /app/vite -COPY vite/ ./ -EXPOSE 3000 -CMD ["bun", "dev"] - -# Stage 3: /server -FROM base AS server -COPY shared/ ./shared/ -COPY server/ ./server/ -WORKDIR /app/server -EXPOSE 8080 -CMD ["bun", "dev"] - -# Stage 4: Workers -FROM base AS workers -COPY shared/ ./shared/ -COPY server/ ./server/ -WORKDIR /app/server -CMD ["bun", "workers:dev"] \ No newline at end of file diff --git a/firelens.conf b/firelens.conf index 4ac836c9d..9035154d7 100644 --- a/firelens.conf +++ b/firelens.conf @@ -16,6 +16,12 @@ Condition Key_value_matches container_name .*-cron-.* Add type cron +[FILTER] + Name modify + Match * + Condition Key_value_matches container_name .*-leaf-.* + Add type leaf + [FILTER] Name modify Match * diff --git a/knip.json b/knip.json index 037fd6aa9..914fd849e 100644 --- a/knip.json +++ b/knip.json @@ -78,6 +78,10 @@ "project": ["src/**/*.{ts,tsx}"], "ignoreDependencies": ["shadcn", "tailwindcss", "tw-animate-css"] }, + "apps/leaf": { + "entry": ["tests/**/*.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/ksuid": { "project": ["src/**/*.ts"] }, diff --git a/others/python-sdk/.speakeasy/code-samples.overlay.yaml b/others/python-sdk/.speakeasy/code-samples.overlay.yaml index e56237595..b040c0111 100644 --- a/others/python-sdk/.speakeasy/code-samples.overlay.yaml +++ b/others/python-sdk/.speakeasy/code-samples.overlay.yaml @@ -773,6 +773,63 @@ actions: "interval": "month", }, create_in_stripe=True, archived=False) + # Handle response + print(res) + - target: $["paths"]["/v1/platform.get_revenuecat_keys"]["post"] + update: + x-codeSamples: + - lang: python + label: Python (SDK) + source: |- + from autumn_sdk import Autumn + + + with Autumn( + x_api_version="2.3.0", + secret_key="", + ) as autumn: + + res = autumn.platform.get_revenue_cat_keys(organization_slug="acme", env="test") + + # Handle response + print(res) + - target: $["paths"]["/v1/platform.link_revenuecat"]["post"] + update: + x-codeSamples: + - lang: python + label: Python (SDK) + source: |- + from autumn_sdk import Autumn + + + with Autumn( + x_api_version="2.3.0", + secret_key="", + ) as autumn: + + res = autumn.platform.link_revenue_cat(organization_slug="acme", env="test", project_name="acme-mobile", redirect_url="https://dashboard.useautumn.com/dev?tab=revenuecat") + + # Handle response + print(res) + - target: $["paths"]["/v1/platform.sync_revenuecat"]["post"] + update: + x-codeSamples: + - lang: python + label: Python (SDK) + source: |- + from autumn_sdk import Autumn + + + with Autumn( + x_api_version="2.3.0", + secret_key="", + ) as autumn: + + res = autumn.platform.sync_revenue_cat(organization_slug="acme", env="test", product_ids=[ + "pro", + "premium", + ]) + # Handle response print(res) - target: $["paths"]["/v1/referrals.create_code"]["post"] diff --git a/others/python-sdk/.speakeasy/gen.lock b/others/python-sdk/.speakeasy/gen.lock index b46267f42..eee59c8d7 100644 --- a/others/python-sdk/.speakeasy/gen.lock +++ b/others/python-sdk/.speakeasy/gen.lock @@ -1,19 +1,20 @@ lockVersion: 2.0.0 id: 05940b80-1ef8-40f4-9878-822fb2792070 management: - docChecksum: e59e2727a19a8da7d72e098250662df0 + docChecksum: 69c7a38357ec2752bfddc3d3fe3c8217 docVersion: 2.3.0 speakeasyVersion: 1.762.0 generationVersion: 2.882.0 releaseVersion: 0.4.18 configChecksum: 2263d20254e354a1792248274002f650 persistentEdits: - generation_id: 8f470814-b251-45fc-81da-9aeae1e53fd6 - pristine_commit_hash: 4545f0867e5974ed781c39f7cd40499b847df4ad - pristine_tree_hash: 0ecaf916b2aa009b294d42e1b386ae63816281c2 + generation_id: eeb45171-4135-499a-b86f-e278f1f1d8ab + pristine_commit_hash: 8339a7c4802bb87e0ebf10bdc7cab95b507d4599 + pristine_tree_hash: 7a4a651f3fd98b3d736fe7bb6dbc87d904b8af0c features: python: additionalDependencies: 1.0.0 + additionalProperties: 1.0.1 constsAndDefaults: 1.0.7 core: 6.0.21 defaultEnabledRetries: 0.2.0 @@ -67,6 +68,10 @@ trackedFiles: id: b597cfc651ae last_write_checksum: sha1:641c0d27fe8e8242429da6d8083fe10ab70b570c pristine_git_object: 2fd7b5fb0f426ec942cb43aa30d3a85287ef724a + docs/models/apikey.md: + id: 3cd1b4235d4f + last_write_checksum: sha1:5c30cc7199e1f3886bcea8052365d549277e1795 + pristine_git_object: 673e261bea89e033c21752bddec54413cae7d856 docs/models/attachaction.md: id: 984e5ad5e280 last_write_checksum: sha1:162c4a0771f000b3835c06765eafdd843fb8d2a7 @@ -177,8 +182,8 @@ trackedFiles: pristine_git_object: 8263c9b452d2d70eb43f9971c2a6138d03c4fafb docs/models/attachinvoicemode.md: id: b01f94714872 - last_write_checksum: sha1:7cd024db5ec907c0d481245dbc7ac89c4f6f3148 - pristine_git_object: 5d7416d60f95d5c4c61f875c61abc909fd861647 + last_write_checksum: sha1:814c71d93174d1bd1c9ca4e126ba280d1a9e9d93 + pristine_git_object: 93fc965cb8fa922bde2c63f20be87fa1c52e3e14 docs/models/attachitembillingmethod.md: id: f7e7873920ea last_write_checksum: sha1:520f62cbb42d1697debb2433a36b0ae269b2e603 @@ -441,8 +446,8 @@ trackedFiles: pristine_git_object: ff3ddb6e28c7e7fd6916f35142804b42bf997505 docs/models/billingupdateinvoicemode.md: id: fef42e5dba71 - last_write_checksum: sha1:8aa45c3fbab57a2b86f4e634b59f8996b68a0233 - pristine_git_object: ccc987c410b54cdfd945b0a4c54020614b9d3e65 + last_write_checksum: sha1:4a7588b35fa5c5a96ec25b120a4ecb0fe7544fcc + pristine_git_object: ce5340b0b297dc8f8f6431c2d9ef4115d17b6da9 docs/models/billingupdateitembillingmethod.md: id: 7c8a6bad4ef8 last_write_checksum: sha1:fb68b533f0f80c80842a5ff3a88fc4cde6994f08 @@ -639,6 +644,14 @@ trackedFiles: id: 41de438d57cd last_write_checksum: sha1:ab7c54bfe0c657851a59fedd447c99bb3acdb4c2 pristine_git_object: 7848c33fdbffc71c09fadd8ad859214182f00e0a + docs/models/checkproduct1.md: + id: 22c131b2d914 + last_write_checksum: sha1:a156ce217c8853244160cd4dc347ef660a4b4688 + pristine_git_object: 8d8fe115ad5a73bcd5b21e22700ebc537105d36f + docs/models/checkproduct2.md: + id: f002dbaf8c20 + last_write_checksum: sha1:3140655b5efa528c4d839550d3d17a04de6f3f43 + pristine_git_object: 340e33a4d0afb0f7433a7b6f30d0329bd9f4015c docs/models/checkresponse.md: id: b988b0f4b781 last_write_checksum: sha1:9522d4ecea7631ce3ae3d80b341a068ccb2dcca4 @@ -757,8 +770,12 @@ trackedFiles: pristine_git_object: 0a980279dbe3f35c22847e8dcd6ee62bbb43e106 docs/models/createentitypurchase.md: id: 79988cc65fd5 - last_write_checksum: sha1:2ea9c6788ba26ba4a8a4f6a3071a4069189d4eeb - pristine_git_object: 486d379d49b9915698f42727dd09a88131dd3f3c + last_write_checksum: sha1:f977fa9ce66b960404ed2e700f3346d5104774d8 + pristine_git_object: 403a9d253f2a04d92c264c2e6db77807a5ce92f6 + docs/models/createentitypurchasescope.md: + id: 0097892c4eed + last_write_checksum: sha1:9274d3b9173e12f4f44305b131e2e61121915440 + pristine_git_object: 2c00b8c624a2209b7b48d52cd2623138e7bcda4e docs/models/createentityresponse.md: id: 4f255b8a83de last_write_checksum: sha1:0689385e6eec70149e6a789af97aa61987478782 @@ -777,8 +794,12 @@ trackedFiles: pristine_git_object: b3778ff16df6323f7493fd94455681c833b66091 docs/models/createentitysubscription.md: id: 8aa96fa32fbf - last_write_checksum: sha1:8ec950859a22ebd2187914d27bd442f3c0452116 - pristine_git_object: 9586cfc0faabfe8e90fad4a79098f124ac3649ac + last_write_checksum: sha1:ed141a178e25c7d53bfeab3ae7f75d8d585be176 + pristine_git_object: 101215fa2cb5c620c3ee4042907bd4cb4d7ac33e + docs/models/createentitysubscriptionscope.md: + id: 7d797da32452 + last_write_checksum: sha1:83e3031a7c7fdc2b42b7315a7f0935a0c01423fd + pristine_git_object: f27c9f60b1daa37cf26c2fe7ffbe64e7df5c40cb docs/models/createentitythresholdtyperequestbody.md: id: ed52dc34947b last_write_checksum: sha1:5f1ab9970b493c4941a0a36a819fd5c88f2fe7a9 @@ -1035,6 +1056,10 @@ trackedFiles: id: b746d21856be last_write_checksum: sha1:ef75b823c030983dfafcf82298a1ab67e8482682 pristine_git_object: cef67b1f14cedbdbac9b0af23e05bd4fc0856294 + docs/models/createscheduleattachdiscount.md: + id: c46ced86d565 + last_write_checksum: sha1:7125edc16218dcdf02afea2aecaae044367e8f96 + pristine_git_object: 115eb2fd82f0358d49f960ddca8e8f651fc8b33f docs/models/createschedulebaseprice2.md: id: 085a5cb811d8 last_write_checksum: sha1:9f79c1efa249af5fabf138941328be472984ea59 @@ -1069,8 +1094,8 @@ trackedFiles: pristine_git_object: e4e05d7d0028e2fcc7b966a4189cdab19594580a docs/models/createscheduleinvoicemode.md: id: 465bb2d9aab1 - last_write_checksum: sha1:c2000694d3810a81b5f78c421fb2538158050de2 - pristine_git_object: d06a9a5e99f1cea5b898110f135dacc74f284e79 + last_write_checksum: sha1:a4551c4c9b1ba66409a910d491152397457adcaa + pristine_git_object: a50fa9ca91fb9eafd8eca65dfa453f3a56ffa89b docs/models/createscheduleitempriceinterval2.md: id: 068bd5764bce last_write_checksum: sha1:498275d15b787de04c157bb6dcf6a2cd399033f9 @@ -1085,8 +1110,8 @@ trackedFiles: pristine_git_object: 99a759e41bbd512fd12a044916b8fc02e60b0adb docs/models/createscheduleparams.md: id: 20f486e2b183 - last_write_checksum: sha1:d2e0ad724b2752f8f7101c24e4b909c92d50bb35 - pristine_git_object: 3a9b5798a5848bfb4cbf75c92a3234c301cb5d97 + last_write_checksum: sha1:76df4c1427c0487d24d303708011cfadb9be8ffb + pristine_git_object: 7237fb6f9fd5fcff4244db28ecf2f077858df728 docs/models/createscheduleplan2.md: id: a42f3ea41a0a last_write_checksum: sha1:a89e01782168c74bc54f3be72e0b8f1e71cfcf24 @@ -1545,8 +1570,8 @@ trackedFiles: pristine_git_object: 99c68888cd8b00b21d6dc2ee36e25d7c85bafb76 docs/models/getcustomerpurchase.md: id: dfc2dae14e5a - last_write_checksum: sha1:e7b1db20da144422a1bec4bce64dd8441f2611f3 - pristine_git_object: 392fd6750d99caf847539903b3e2a33f07a04ae9 + last_write_checksum: sha1:b4667aabdbf6c18dc2909f25b9cdf11203d09dd3 + pristine_git_object: da0c6e3e611cd095b22332d100703b824252fba0 docs/models/getcustomerpurchaselimit1.md: id: 3982347562fb last_write_checksum: sha1:cbf9a6c0097feb98a12d9bb3e8481acf016d80eb @@ -1559,6 +1584,10 @@ trackedFiles: id: bf8fb7e9287a last_write_checksum: sha1:70f84883dc4562fa59565a47f86c7ec7adde866e pristine_git_object: 3a2c51a95c477524bfa81581380590ea79e2c5e0 + docs/models/getcustomerpurchasescope.md: + id: a7f1a983219a + last_write_checksum: sha1:da1afe73818ac9c05521a92d6daa4e126ba6e9ab + pristine_git_object: aa0201c2e2c82c444928b425a96a3638802e950a docs/models/getcustomerreferral.md: id: f8e268de02f3 last_write_checksum: sha1:1eeb2fa974ff878651dcf340888ec2282edf6a21 @@ -1593,8 +1622,12 @@ trackedFiles: pristine_git_object: a3c98cf8be006038028cf9cfcd27b0f0d9c4eec4 docs/models/getcustomersubscription.md: id: f35631fd7369 - last_write_checksum: sha1:3e19bdddd52616c0e2ec8dcef489410bb5189d15 - pristine_git_object: 88d543ef3d6fb67b3046a667d0dd1b8c7d1d3c48 + last_write_checksum: sha1:acc91ed70d254106f0bf2d1c738da2b59f73fc64 + pristine_git_object: afc464bd93d534a3e338bb67330ab45e3b3095bd + docs/models/getcustomersubscriptionscope.md: + id: 5c37541c74be + last_write_checksum: sha1:3c235b1eb697d160d3ab59ad83bccdb7df801d37 + pristine_git_object: 80a6942a7605797bc6b6e4034fdad39fe4b78039 docs/models/getcustomerthresholdtype.md: id: 268aa4c50a1d last_write_checksum: sha1:1a6dc5c8ba05fbda230d8a54a465fcdbe206e4a3 @@ -1657,8 +1690,12 @@ trackedFiles: pristine_git_object: 92ec35902d94de56a0d513201d6eb1d7b5f31f64 docs/models/getentitypurchase.md: id: ce7162950983 - last_write_checksum: sha1:40d1e429c716d0c4ca6d035a2472cb166a0858f4 - pristine_git_object: 88e4c2669e74e7b963ee5d2ee509d0d3fa868eef + last_write_checksum: sha1:0f6fd0ae4a19843a62ab85a2b9d81dc6494f95bc + pristine_git_object: 1f28c63137368c26553c6543cc68985fb4d99092 + docs/models/getentitypurchasescope.md: + id: fb80a6be18d1 + last_write_checksum: sha1:3b4039f36220d28669feb8916cc0384a56857ba1 + pristine_git_object: 8b44696c136afd5dc60605409b94006b6c79a8c3 docs/models/getentityresponse.md: id: 2a260e19ff31 last_write_checksum: sha1:1787709bf0c5aba7c7e6d62011a4439f348208d2 @@ -1673,8 +1710,12 @@ trackedFiles: pristine_git_object: f823deba8a676305bfe817193613486b58c6fce3 docs/models/getentitysubscription.md: id: 79bea398e954 - last_write_checksum: sha1:8713b786fca38066c8a961fae76344a314db8d10 - pristine_git_object: f07e2baae2a663c3d3e59b936f707e65bc5722d2 + last_write_checksum: sha1:9a5e42bb79aa8c9482bb791fdbc90d3d7859e8e6 + pristine_git_object: 9e2263e1fbd4ce68083b1b0a68470d561e1f0fa9 + docs/models/getentitysubscriptionscope.md: + id: 8a894cc4d04f + last_write_checksum: sha1:137aa554048235be6e44ad001e5cd0fa17592f8c + pristine_git_object: da965bd8cc52be9bf9b62938177d47f1eb59f83a docs/models/getentitythresholdtype.md: id: dc124a35cc1d last_write_checksum: sha1:70fc505396f5b94440a72a5d9f78aeabe6855390 @@ -1867,6 +1908,26 @@ trackedFiles: id: afbdf26f0f4b last_write_checksum: sha1:6d5dfd04c5d95c235f56eb23e31f497c9ed91247 pristine_git_object: 7390b496108568e19aba42d36c5c3b508e3d42b3 + docs/models/getrevenuecatkeysapp.md: + id: b47525a2f9de + last_write_checksum: sha1:1de286cbb4c475583313a05f5d89c8cdc90fcbf1 + pristine_git_object: 18eb7cb3f4c2077bbe2e19015f6098703a65ce03 + docs/models/getrevenuecatkeysenv.md: + id: a9081dfb1e62 + last_write_checksum: sha1:f44bbe9fd99bac368b4e679a7a05c4d22f030259 + pristine_git_object: 5c9236f2a8fb8a3c6f762a47e8b2c40a3f7c39cc + docs/models/getrevenuecatkeysglobals.md: + id: 3e0c18c79f36 + last_write_checksum: sha1:a264613009362faa9a1497f94403cf9fe1a9f869 + pristine_git_object: 412e603c39750e0d782e772d72d7172779298506 + docs/models/getrevenuecatkeysparams.md: + id: 76cd38034f68 + last_write_checksum: sha1:99ff618181876cae820a5f42b6e06beb255c248c + pristine_git_object: f5c96fdacda480ae30ebc28d71826d5dffe80cef + docs/models/getrevenuecatkeysresponse.md: + id: 4b23c9d5ea78 + last_write_checksum: sha1:4eb6f4db2d614bbb53dfb512b73030035dbb6d86 + pristine_git_object: be7a493d4f7d1670084a8882a2ab4b60a8b367ad docs/models/includedusage1.md: id: 7ceb62e48016 last_write_checksum: sha1:9a7a940ec67dc041a2fb2064f8e5b433c9cb12ed @@ -1891,6 +1952,22 @@ trackedFiles: id: 40dd7473ab87 last_write_checksum: sha1:76b0d2926283b9d9cb79c92fc0c1baec458f06af pristine_git_object: cfe1e33316af7bc0ca8cca5ecb59e72e5f3182bd + docs/models/linkrevenuecatenv.md: + id: 57a0ac8d952a + last_write_checksum: sha1:6a2280c9c13ebd82da37f3398be0d4427950bf3c + pristine_git_object: 419027171edab8fd89e8bcddb02e374d8b263649 + docs/models/linkrevenuecatglobals.md: + id: 3a3c92d9666d + last_write_checksum: sha1:bd4c5dae75935cfffe3496713fcdcbad66a84fba + pristine_git_object: dfab4e4be248fa1f702de0f92f898dd41de56c09 + docs/models/linkrevenuecatparams.md: + id: fdd3864d636f + last_write_checksum: sha1:aa4605a7153b9e78f325afc477a091b17bae9b98 + pristine_git_object: 6e209832568861dae3b88067bc70762132db5595 + docs/models/linkrevenuecatresponse.md: + id: a598227583fb + last_write_checksum: sha1:8481a4f7b9efb12da31da74469889eafb03cedb5 + pristine_git_object: ea11c719bceb2bd254286e7f8f0c1ba5161d15b8 docs/models/listcustomersautotopup.md: id: 43e50adc0195 last_write_checksum: sha1:36e4e209d3743d6ff7911104da4f54ebd484f923 @@ -1961,8 +2038,8 @@ trackedFiles: pristine_git_object: 27007645d995e6fc194480b9088bb2f28972829f docs/models/listcustomerspurchase.md: id: b7fa1c1d2bae - last_write_checksum: sha1:a450767d5e82f94f068d28de4010954f0dd5bcb2 - pristine_git_object: e2c10dcf58bccd8db5af1f91d21633f4aa026879 + last_write_checksum: sha1:2f27d0e7f8c80567c773e599ee27d365a310acfa + pristine_git_object: 4767ec313e53870c46aeaf7a8ef7d936f1babdfc docs/models/listcustomerspurchaselimit1.md: id: 8334a6fb4ac0 last_write_checksum: sha1:45350933c3243391a029de3574a1d939fa88b4ef @@ -1975,6 +2052,10 @@ trackedFiles: id: 7973f4f5e2d1 last_write_checksum: sha1:23bd75ed61801f82e0c063ae5a8f3e06c1ad1a6c pristine_git_object: b5a71cec323a5f010ec5393952655622d9957bf6 + docs/models/listcustomerspurchasescope.md: + id: 0b03ccbc8b6a + last_write_checksum: sha1:de9f4789a12fc797acdb6cf114f5d85e980db35f + pristine_git_object: 82258e3bb480c49d34f959e956aec748a294e79d docs/models/listcustomersresponse.md: id: 8bcc0ece3648 last_write_checksum: sha1:bb231bcaa0609393f4b2c8f6d0d88057cb452d1c @@ -1997,8 +2078,12 @@ trackedFiles: pristine_git_object: 868e2afd45082d13eecdf46e9c5849a0cd0522a8 docs/models/listcustomerssubscription.md: id: 563487abda41 - last_write_checksum: sha1:fb3b140e7a41d14e1561e82043698326a31be834 - pristine_git_object: f2257cb7235a82b97ba6a773a19a81345252e112 + last_write_checksum: sha1:739584898883de30a9ae4a0abafcb9a266b26c45 + pristine_git_object: 3872931debb3199f4581b6e9538a5c51a7568093 + docs/models/listcustomerssubscriptionscope.md: + id: 93f4ea94806c + last_write_checksum: sha1:9c5217de5bf8536bdad5bbdeea0fd1671e71c269 + pristine_git_object: c27a081c052a57a2852bc17c871fd30096b08046 docs/models/listcustomerssubscriptionstatus.md: id: f24b4a03e2ed last_write_checksum: sha1:157530dabd0d28e5fd4a40b12d9a7b71a6addeea @@ -2077,8 +2162,12 @@ trackedFiles: pristine_git_object: ae1dba57381f5dcbe29227e11dc92a6abdbb1f99 docs/models/listentitiespurchase.md: id: 14e60687626b - last_write_checksum: sha1:d0b0d1b2b591b981c2c9c4f2d10a0994eb55c744 - pristine_git_object: 6cc60dd796c7645d56e0b31bc85d79040261e519 + last_write_checksum: sha1:ea60115b50730c92c1b4a8491f71198fe8aa05ed + pristine_git_object: 8c89da222ed455a1b4f09cb349280b3f891dd0c1 + docs/models/listentitiespurchasescope.md: + id: e5787801ac4e + last_write_checksum: sha1:dd1eb5950891ec1bb91165aa22c42978c0eac173 + pristine_git_object: cfaa91c14d56ab50672a36de86dc6dc33fba2610 docs/models/listentitiesresponse.md: id: 795662eb1108 last_write_checksum: sha1:898c1d9cb5e52991df0477c7ba8a73796d3a1844 @@ -2093,8 +2182,12 @@ trackedFiles: pristine_git_object: 1b7889f7ef46a1b94fe5ecfaaf329fb2d1ccbf12 docs/models/listentitiessubscription.md: id: 9826f64a5928 - last_write_checksum: sha1:2fd28368c033a1deb64b001833ac9858bd4bb6c0 - pristine_git_object: f76d7eb47128603ae43f623d4967312a42c6a1dc + last_write_checksum: sha1:b5aeb05c0f7f00c7e22a52b53f4c01b9015c8252 + pristine_git_object: de22db0c78e3eb32a6cf19fb8374785dd8eaee89 + docs/models/listentitiessubscriptionscope.md: + id: fed709d7cab6 + last_write_checksum: sha1:91a0a20495e07d2c186e43bbed9017e25fac5271 + pristine_git_object: 065628275c718b32faea973b0297269ee6fb46dc docs/models/listentitiessubscriptionstatus.md: id: a00e8376e9ad last_write_checksum: sha1:f31d59f17b04094ecba83a43af7219413f5bf979 @@ -2341,8 +2434,8 @@ trackedFiles: pristine_git_object: 4c4123fdbe2fd21dc4c0c6a92e8f1b2bb9352640 docs/models/multiattachinvoicemode.md: id: ee3e6021f815 - last_write_checksum: sha1:98745fe9a1792f42f39986e88a2a1cb29777e0d9 - pristine_git_object: ffc930cdd73dafe972deb77b4b8090cdee001cdb + last_write_checksum: sha1:38e4e9d0457d51b245f9821ce5a50a2b2d4b1f56 + pristine_git_object: 12d57225a5115a270e774700bf8dc85eacfa1ab7 docs/models/multiattachitempriceinterval.md: id: 0030b6692fe1 last_write_checksum: sha1:8ab49b031c75684ab2980f52e04ea8cc77978984 @@ -2545,12 +2638,12 @@ trackedFiles: pristine_git_object: 5add1a3cab3d01077452a4df8d9037c6279da319 docs/models/preview1.md: id: 203e34d3c393 - last_write_checksum: sha1:b9b1ce18639c5e83d9eb6bd83e1d99bd683291cd - pristine_git_object: ac258d817302c6d7f841b006ff7a7931cfe13df7 + last_write_checksum: sha1:116b6aa31a514f220d270164fdc3572eacb16be8 + pristine_git_object: d82654857e15b247088aa29b1edc997bfbaa2df5 docs/models/preview2.md: id: 96d6fae57a72 - last_write_checksum: sha1:4839487a437486dbc9567dec57a136234230ddc9 - pristine_git_object: 9ad954e94d0ec60a5def86f162fe804fad115518 + last_write_checksum: sha1:9654ce4f91d0c8ff6409117c1e6a12f6f6789802 + pristine_git_object: 5080785d6528a0cd52d1f86d217efe31cbbbfa3c docs/models/previewattachadditembillingmethod.md: id: 341766b0f910 last_write_checksum: sha1:a8357c2f1f1f0e394f9ca1aabf6c19c360496d57 @@ -2669,8 +2762,8 @@ trackedFiles: pristine_git_object: 349244232c15839b67bec01f899a4696175a1cc9 docs/models/previewattachinvoicemode.md: id: 78a526159f9b - last_write_checksum: sha1:83d57ed29d6c74b8f7c0e9a379700a98046fc5a8 - pristine_git_object: 52153f9fd9a587fd6e60b8ee15bbd9e661d7bb3c + last_write_checksum: sha1:21d1bb22892d8e66dfd9cd64df53f7d5d5656177 + pristine_git_object: a6600afea69f65e0b31902f9d502ba94f74def9d docs/models/previewattachitembillingmethod.md: id: ea775c1dac5a last_write_checksum: sha1:317915101671aa0ef8926f50e7e3e2dfea0efe0b @@ -2877,8 +2970,8 @@ trackedFiles: pristine_git_object: 0eabc09a8013ecf8ef5571178b35b8b3b1b8ce5a docs/models/previewmultiattachinvoicemode.md: id: 54ae12af9cfe - last_write_checksum: sha1:bc0d0e512c3643cfb05d0ee952fc4f10a06e02f8 - pristine_git_object: 516159a7040694c7888368e96a319b35ebd29887 + last_write_checksum: sha1:1c37c4faf158a1c00c953429767bc57bb52dba03 + pristine_git_object: 25b5f17138f0bd08923230f4867c2c3807bd3a31 docs/models/previewmultiattachitempriceinterval.md: id: 8ca44d192cbe last_write_checksum: sha1:92e48e53a5b39b07b98d248b8bf7cda63053c64b @@ -3125,8 +3218,8 @@ trackedFiles: pristine_git_object: 8d455493b186d8088852a906a3778b8bb3a4b017 docs/models/previewupdateinvoicemode.md: id: 171401d06f36 - last_write_checksum: sha1:b83b7ac6d353171470a200d0bbdc349eef937f72 - pristine_git_object: 7210d48c7d789b0e78753c7280072daccf611894 + last_write_checksum: sha1:6f40ac7e5e60ab7c6a04b7309e54b28a3cfab786 + pristine_git_object: a82f6930a63f7992adcada680b9b55e858cf91e6 docs/models/previewupdateitembillingmethod.md: id: 62c90eb495b5 last_write_checksum: sha1:cce3efe7d54f5e01b2ab21eefc6259495c8e38eb @@ -3279,14 +3372,6 @@ trackedFiles: id: 446cf8386114 last_write_checksum: sha1:c917a956f1af010a60b2e7d43bad09cf54e44274 pristine_git_object: c42de1ea48789d1cd09dcce226e3673f29b2a747 - docs/models/product1.md: - id: 880ca8ae9886 - last_write_checksum: sha1:d6c959243c6b293682d7faed288627aca8b42712 - pristine_git_object: 555d0ec5c226b413aebb505ac316c7a14cf1fc4b - docs/models/product2.md: - id: 6262b044d234 - last_write_checksum: sha1:d650f24a1fee7d7b93f1f3d1f0c3a9b727b5f111 - pristine_git_object: c1b81dedf250c04788acef9878b406b03b3747cc docs/models/productdisplay1.md: id: b5bdefcde7af last_write_checksum: sha1:4cbda567685f39d23b4a83d5a9bc20c4bf2fa21b @@ -3321,8 +3406,12 @@ trackedFiles: pristine_git_object: 6b0ee07d823098c850632ebaae8f35fa23980d12 docs/models/purchase.md: id: f872769b6939 - last_write_checksum: sha1:b250603c69b08d953b6f2dde179eec8606d27ef9 - pristine_git_object: 133bec63c1dce6f39d3954909e188dae71f3e2af + last_write_checksum: sha1:654ae481b07b0e621ee37e574f173472310a1045 + pristine_git_object: de4225346a44c7e75465101ab4ba63b8dd3d10b2 + docs/models/purchasescope.md: + id: 71b7e124badc + last_write_checksum: sha1:a938b35cfb32ff5d5f91b2ae13c9b853dc5f8a26 + pristine_git_object: db6b7b133f45c29190143e8a1ba0680cecdb8816 docs/models/range.md: id: 0cae0c76762e last_write_checksum: sha1:ae76473539105f50e728c1657bf5d2af3699da0d @@ -3363,6 +3452,10 @@ trackedFiles: id: a15f5440d48c last_write_checksum: sha1:792ae2d550cfbb56888ee2fe6342a0666c83d219 pristine_git_object: 49346d4858b332cd3d0e59f46c4af0116346bf14 + docs/models/result.md: + id: b850437752c3 + last_write_checksum: sha1:3ee32bdd6689dc5dda87df145ffe124a1e78c80a + pristine_git_object: 1718a5b0274ab83f20f2fd21d269934722c64f01 docs/models/revenuecat.md: id: 5418b6373a80 last_write_checksum: sha1:3cfb5781a5762c564d5b48ef1af6cba5db229435 @@ -3567,14 +3660,54 @@ trackedFiles: id: a0fe36809906 last_write_checksum: sha1:0506cb5fd620fb73affa03da445160a245e94fd6 pristine_git_object: caa4f8813f21dd5e60c7f1fd5e7591340247fb1f + docs/models/storepush.md: + id: 0bff2a8f0cc7 + last_write_checksum: sha1:220f7b38e22cb56d9ac3c5b9f2adfd2da41c2842 + pristine_git_object: 2a58923b830a788b0746bea3e55670ab79c1fb6e docs/models/stripe.md: id: ef8fa4c7fedd last_write_checksum: sha1:304cbcf780ff0569d9ea00886e26043a8ad2cf88 pristine_git_object: 568c7230d9e95d08cfce7cc003f012bfed13595e docs/models/subscription.md: id: 4a200793e0f4 - last_write_checksum: sha1:e990f1010a79616659020a150df34a8c7f78b506 - pristine_git_object: 5bb9f22b2f50cb4c2ba4d8172fdc21b7842cd607 + last_write_checksum: sha1:28d52f9f784ee2b2783069d7ce722547918c49a6 + pristine_git_object: 693186e33f17fb524de0d0e70e90e5477aa8a695 + docs/models/subscriptionscope.md: + id: 87bc56fd272b + last_write_checksum: sha1:d5bd8497d81e3928c11257ad6db7bd0df6ac2b91 + pristine_git_object: d2281ec892b2bb9f1210643cf0efcf4166106876 + docs/models/syncrevenuecatapp.md: + id: 73d2264f26a6 + last_write_checksum: sha1:64526bb1b58191cb623c4f4f696eff4c80848a1c + pristine_git_object: eafb5e618296dc86ee5b3fda114a3686da4ef8e1 + docs/models/syncrevenuecatenv.md: + id: 012dc6089488 + last_write_checksum: sha1:f81a04a3c0f08c817f0da7dc2401a3fd7a9a94e9 + pristine_git_object: 9b2eedb9eb3836bd27951ad04a006d88bca67db7 + docs/models/syncrevenuecatglobals.md: + id: 5c889c9a0be8 + last_write_checksum: sha1:6b865a42d969203431455ffed358fb72f586ee7c + pristine_git_object: ddab5b6ab7d0f08bb6618eadfa9744603f75e0a5 + docs/models/syncrevenuecatparams.md: + id: 9ab677e7cf8f + last_write_checksum: sha1:8d1b3cde4fc5b3a03019107eb0f65620e53a8285 + pristine_git_object: 5a65e517e6beef0c6afc7adfbcd450c979f4804a + docs/models/syncrevenuecatprice.md: + id: 9a5d5482c4e0 + last_write_checksum: sha1:98d655ac8f6bfde40101ea7069c9c29a25bd27c8 + pristine_git_object: f1f2e2af312104537761410fe1da470cc67466d0 + docs/models/syncrevenuecatproduct.md: + id: 1f316b101f9a + last_write_checksum: sha1:4cd1e623d4f8789444f7c6b03835ced8bf00421c + pristine_git_object: def6ce10cef765494237dacc1b5add17c84a2be1 + docs/models/syncrevenuecatresponse.md: + id: 7a818b200e7d + last_write_checksum: sha1:9e9b2f8c540bd84a81597d1be89b1b74d9a05719 + pristine_git_object: ec12b07addf1937d6b6b95352233907ada3de1ce + docs/models/syncrevenuecatstatus.md: + id: a4921afbf845 + last_write_checksum: sha1:5aaa82e010e6dc5f35a20e11a644b7a2e6afd252 + pristine_git_object: d56a56f2bd0f2d6f9a847acbcc03c41079ab1d41 docs/models/total.md: id: f4060c3b4657 last_write_checksum: sha1:08c2c14481fcae1bcc1c550d6e2c95f14bb1efb0 @@ -3725,8 +3858,8 @@ trackedFiles: pristine_git_object: 142dca345d9ae6392e2bd53d30a4545f20f71967 docs/models/updatecustomerpurchase.md: id: 392cb675471e - last_write_checksum: sha1:00c1dc351b93935b60328fd25590f3e2e788aecb - pristine_git_object: 6870656edf32967bf223d43aec5397c272e9cb41 + last_write_checksum: sha1:8398970612f1a0b5212d5ba7184ca5645edd7beb + pristine_git_object: 60c28a4851cbd7bda36975825621bb139d979174 docs/models/updatecustomerpurchaselimitrequest.md: id: e0b5bae8abfe last_write_checksum: sha1:9b519a82f89e6a2d841840521779ff3d8a0fdb50 @@ -3743,6 +3876,10 @@ trackedFiles: id: c178ad7131af last_write_checksum: sha1:519063a6a6bef71026650c099718473281d92da1 pristine_git_object: efad52f1fcb255634e318299187b32db488cd23c + docs/models/updatecustomerpurchasescope.md: + id: 0765dd16fb96 + last_write_checksum: sha1:1e91aab4b8e7e193b9208320a8ad644d31fd6ec0 + pristine_git_object: 0d0879fce5ad0596bf0b5a1287063196ef1ce61b docs/models/updatecustomerresponse.md: id: b8e414267f76 last_write_checksum: sha1:35a2718229e67559d36dddf749e01799b267719b @@ -3769,8 +3906,12 @@ trackedFiles: pristine_git_object: eb0bb2c3c885beb88326e71ccfad623ded14f7c0 docs/models/updatecustomersubscription.md: id: 23620d1508c4 - last_write_checksum: sha1:9c40c57f10465741cb0f291015c5ab5a289c962b - pristine_git_object: 114aba943de596f1b88f17934c479354d739abd4 + last_write_checksum: sha1:1de0bc0d48aaaeacd34aef4bcfc03b9501fb6a4c + pristine_git_object: 77d3d1e1f9c86f681aaa357a64c9f308df92ea3e + docs/models/updatecustomersubscriptionscope.md: + id: c7284689a518 + last_write_checksum: sha1:dede1801055a9785e15d097eabaf08edf9ff8ba8 + pristine_git_object: 1ee296469a0b16923239c4f97c0ceaa5f2ff1cd6 docs/models/updatecustomerthresholdtyperequestbody.md: id: abdb64acf98d last_write_checksum: sha1:07986cbe52d29a0b5c3c01a244b319a43d4945bd @@ -3849,8 +3990,12 @@ trackedFiles: pristine_git_object: d839828b3a478b89212b9f1f3db21242f07a6004 docs/models/updateentitypurchase.md: id: e6e0bb58543d - last_write_checksum: sha1:84225261550c0b35763cfac09c60a06278c6a5ec - pristine_git_object: 24ec00cb8b40dda3592b162912dddf4f9e9e5c03 + last_write_checksum: sha1:a39e898f9f8f333cd1d508661d37562f87b919a7 + pristine_git_object: e362edc11d9f2b1038051629c55393a133273816 + docs/models/updateentitypurchasescope.md: + id: 2712d814aa7f + last_write_checksum: sha1:ecf575eba2a70fe7f77051c73cecccb1a078c24f + pristine_git_object: d0a39a505e28b28f41b1a565120f6f13599899bd docs/models/updateentityresponse.md: id: a549a65da9e8 last_write_checksum: sha1:9818a8c82a6df1aa01074cd87771db71968fbd02 @@ -3869,8 +4014,12 @@ trackedFiles: pristine_git_object: 6a6259482772250caa0ab22ff5f9e0a9d93d9cc6 docs/models/updateentitysubscription.md: id: 6b6242a7771b - last_write_checksum: sha1:ce6f7693abaed603bc7c0c41572bf95a337ca456 - pristine_git_object: c41d09e86e84d272182a57180616fca862dc3033 + last_write_checksum: sha1:17b9a7b035d81897ff9256034b4757f1ee41a2ce + pristine_git_object: 1cb1e4f8252d5fd9ad6b738dc4cf4e983a58fd04 + docs/models/updateentitysubscriptionscope.md: + id: cc09c30871e0 + last_write_checksum: sha1:74373678de14c51ac3b2dafb279a372026b18ba1 + pristine_git_object: 601d043ea05ae69fe2d9085aec2f1d289abafad3 docs/models/updateentitythresholdtyperequestbody.md: id: 9db8438c9a7c last_write_checksum: sha1:acadb05a7f10d9794d8521609857174852d2b8fa @@ -4149,8 +4298,8 @@ trackedFiles: pristine_git_object: a90be00054691592cd72127dfcdf499c3dc38fb3 docs/sdks/billing/README.md: id: dc915331dd9d - last_write_checksum: sha1:5d8fb24ebc60773e8dbed2794d1ec047749f13bd - pristine_git_object: a8e93d3d0daff0d045432d569ffbb00f7414457d + last_write_checksum: sha1:29e7a2b0f6832a37610d1dca768fb4410be689b2 + pristine_git_object: 8c737f2aa2ab2b6723088f7a35b4841859b6a399 docs/sdks/customers/README.md: id: 9332759cffc2 last_write_checksum: sha1:3ba96b62f6a62480122a03a7f8c27c119eefade6 @@ -4171,6 +4320,10 @@ trackedFiles: id: 2d8c741fff57 last_write_checksum: sha1:a956c9b35832ad1b4b988220bbf133aa87c73301 pristine_git_object: 25a47ac0fdb8bc31110b3d96d2656bf744969f37 + docs/sdks/platform/README.md: + id: b66219e9cd4d + last_write_checksum: sha1:e18d4e05e801ff5b52414fc51e98e10f33cfbb04 + pristine_git_object: 68bfa16724239e249cfa4263dd4e029e148d9af8 docs/sdks/referrals/README.md: id: 50b71f597f20 last_write_checksum: sha1:de99a40759f0d5c8850f2f59613d20d646161c53 @@ -4225,8 +4378,8 @@ trackedFiles: pristine_git_object: 4db96641966f6337a1a1613ea731050ed25a1fb9 src/autumn_sdk/billing.py: id: e6cffdbf2221 - last_write_checksum: sha1:cad46c4bca26de53802a61a41a82277ddfc732fe - pristine_git_object: 715116867f3ddec54a536bee69e673fd0c0c0881 + last_write_checksum: sha1:3fe80d1abfe1157c4aea2218790844af906e774c + pristine_git_object: 1ff2109b4a08417a9138ed2e89adf3556cf253e8 src/autumn_sdk/customers.py: id: 5c5a0a07a433 last_write_checksum: sha1:bbdd432fc314a94a6ac5bd6335c8cf9474e02b66 @@ -4269,16 +4422,16 @@ trackedFiles: pristine_git_object: 89560b566073785535643e694c112bedbd3db13d src/autumn_sdk/models/__init__.py: id: bcf3802243ff - last_write_checksum: sha1:92533685e939fb502afebedd05da85692029e5c5 - pristine_git_object: e8feabc95f366a3197c1572a233ee0f8daf2f38b + last_write_checksum: sha1:0300fd72a06e0e2bb6fdf06654e88e670fd575bb + pristine_git_object: 923db4da1df1e544bfa79c2fc5b55c90a1a3fb5e src/autumn_sdk/models/aggregateeventsop.py: id: 01321099f2a5 last_write_checksum: sha1:bbaf78080f665b38e531d2427c787e40ca0635a7 pristine_git_object: 3a4476749ea87675039e23243ed3865d3bdf57ca src/autumn_sdk/models/attachop.py: id: ebb59e06476c - last_write_checksum: sha1:1042a36bd58ee6baf9dbde276b5372541f32d414 - pristine_git_object: 9fb1b21c640571f3900697599a8311220f0dc8d5 + last_write_checksum: sha1:72a13011c760cc0a83634bcad244dc7982bde3e6 + pristine_git_object: 5d591a0d1f939a606ba7639031b4ac7ed6ef1787 src/autumn_sdk/models/balance.py: id: a6354d7c4b97 last_write_checksum: sha1:54a4422123d262666370d2e1238d990ef1dba324 @@ -4289,20 +4442,20 @@ trackedFiles: pristine_git_object: 4fc065eaa77c761dbe2461c74650a44421522d9b src/autumn_sdk/models/billingupdateop.py: id: a2f17c75cfd3 - last_write_checksum: sha1:486c326c6fa595b324f95d42f1fbfbb4104cce95 - pristine_git_object: dc5897507cf0d6d90590cacf99826158d0aed0d9 + last_write_checksum: sha1:4b04b09a26d52fb5c963b0d385484ecde0f928be + pristine_git_object: ac6c4e6ec0e3ab0c7990440e6d6f04fbef840bdb src/autumn_sdk/models/checkop.py: id: 31c2f84723c6 - last_write_checksum: sha1:7cb0cbbf11f480372aa2c5a3466b07f78e301226 - pristine_git_object: b165ecfba6d2d2d1ccf9a21029d6fa87db67b470 + last_write_checksum: sha1:8711ca8b987fad934aea167a38b5d614836bcdb9 + pristine_git_object: 6c98274a990207b1690198e94541c51531e01c4e src/autumn_sdk/models/createbalanceop.py: id: 27daf4da75bf last_write_checksum: sha1:f034893074f11952de2b174c8f2991232e512858 pristine_git_object: 42d3636f3548ca45bcb5059f38f4a3d6762948f6 src/autumn_sdk/models/createentityop.py: id: bf9521c0cfec - last_write_checksum: sha1:6a026ae9529f5ba00688fa306914ba058c9e9c05 - pristine_git_object: 55fbc8631313633e7bdc82a707a11ab1dad04e1f + last_write_checksum: sha1:721f83159168f5d68668a47881867cf35482973b + pristine_git_object: 1ad23c8de773c688f1c6a152131ea67f40ff7d3a src/autumn_sdk/models/createfeatureop.py: id: 68487033fbe5 last_write_checksum: sha1:c68f6e79a3dc3d3436d607fae75e4c4a958b7141 @@ -4317,12 +4470,12 @@ trackedFiles: pristine_git_object: fea4d1bb3875848e66502705e4cb818233ec3f5a src/autumn_sdk/models/createscheduleop.py: id: afb0cf1cf7f2 - last_write_checksum: sha1:435695ce987ee9e5393bb64ba84f479007f7cfd3 - pristine_git_object: 97d115471f43fcbbc6c27f64112c0dd76dea3252 + last_write_checksum: sha1:4046f367525814636a58b5ebe52b3b7356047107 + pristine_git_object: 201b77cbeb42ce0b466bc4134b107bc3b9306aed src/autumn_sdk/models/customer.py: id: 8ed0174f7272 - last_write_checksum: sha1:b34d9296ba774e5a622b84a48ce38cdab983953c - pristine_git_object: d91dbc5368f357b01107510c1b5f13c7fe371ee4 + last_write_checksum: sha1:e21377dcf67b7e242f80719b1296abf831efb3f8 + pristine_git_object: 47c3d131de0aa846cf1859d53e207db2debff0d3 src/autumn_sdk/models/customerdata.py: id: 9d88118f2123 last_write_checksum: sha1:3342d09cf62cc51d935fecf1edfac126bcc203de @@ -4357,12 +4510,12 @@ trackedFiles: pristine_git_object: f10712da219176f9298c3426dd7b1372ae8777dd src/autumn_sdk/models/getcustomerop.py: id: 266e08dde55d - last_write_checksum: sha1:99d1a3e00f783dbeb7e90362549917fc6e892140 - pristine_git_object: 2770ceced0327c6b2c38fa3fd91662bb5da786f9 + last_write_checksum: sha1:e8a423a0a9103f8ac3c95980039b6f3c8f036040 + pristine_git_object: a4ec8bc47a4f820b93465cea1e8bd25f6d714c68 src/autumn_sdk/models/getentityop.py: id: 6a624594b41f - last_write_checksum: sha1:3b71deedab3c21399d5d6afd4372dd31322afcc8 - pristine_git_object: 2b8a05c98adc64b7f24d1078ec27d190a0138f2d + last_write_checksum: sha1:30eb1ff20357f6dafd502ea182576d0a7929f3e3 + pristine_git_object: 1dee2063987a25d34eccb143f91d95d603305f10 src/autumn_sdk/models/getfeatureop.py: id: 72b158789497 last_write_checksum: sha1:dccbc8de6a2d4cc07a4045585f12d8649b9f4c0a @@ -4375,6 +4528,10 @@ trackedFiles: id: 590fb77ac88d last_write_checksum: sha1:4b545b24018986ba9d93107df7840f39fb1f861c pristine_git_object: e619e84014109d0ac73df596b4926a8f6dc92ee3 + src/autumn_sdk/models/getrevenuecatkeysop.py: + id: 015155862a71 + last_write_checksum: sha1:6b3f88a48b721093562ec59db3f6dae2540fa139 + pristine_git_object: a51f5d26227a36d16cce914b32e92363eb5a95ff src/autumn_sdk/models/internal/__init__.py: id: 2906fe7f2cde last_write_checksum: sha1:1905b58b74ecc52346d8f5c24ded2b6d6e1dad4a @@ -4383,14 +4540,18 @@ trackedFiles: id: 4e33eb99f463 last_write_checksum: sha1:b12bb60e74b8678b0fd0543223c09cc3d77dcc8b pristine_git_object: 2614675c993545d5df1e516d043649e50281c2a3 + src/autumn_sdk/models/linkrevenuecatop.py: + id: 8dd3e355d8d5 + last_write_checksum: sha1:f5d06c40f8031e1791bd515fdad831f539a6ecaf + pristine_git_object: 2a117515b5cac185ba0b7eb21d3b2b4d8260d194 src/autumn_sdk/models/listcustomersop.py: id: d7074740b8b0 - last_write_checksum: sha1:a5d8ecb8aa876fbc13e45895238fdd4b8ef00262 - pristine_git_object: efb7ae861ed3c85269dea269465c198119fe37fe + last_write_checksum: sha1:64e98dae9bc3719c50a81e306ce270ad59d2e216 + pristine_git_object: 732fd3a8749565a2886b2ece22654a19adca12b3 src/autumn_sdk/models/listentitiesop.py: id: 918a05430967 - last_write_checksum: sha1:449f3fb97195ee88ead6093f6f2b2e05798d5d7b - pristine_git_object: b2cdd998afdeb5dfdc83210df15f9df4fa5e9ab2 + last_write_checksum: sha1:a79107d8810f0dab4073abf172bf45e981a2414e + pristine_git_object: dfbd606cf76f9ee1d54f276711d7bbe5f5958bd3 src/autumn_sdk/models/listeventsop.py: id: 751b0200d91d last_write_checksum: sha1:a57b8d56c96c341e8572b4395c12209a62ff6b74 @@ -4405,8 +4566,8 @@ trackedFiles: pristine_git_object: cc085894937b7e149381b51729063f03d39e4eb5 src/autumn_sdk/models/multiattachop.py: id: dfdf7952c870 - last_write_checksum: sha1:58d91eee19f9d021419ef6d98e15f65a435fdc8f - pristine_git_object: 98621cdbe47a5109f68dd5c1dce3127b1aa2545d + last_write_checksum: sha1:1f2b8d06e418f2f4c859d9fb45c11749abefb11e + pristine_git_object: ece0d519eef5f4260fe03a8f46442aeaeabbe603 src/autumn_sdk/models/opencustomerportalop.py: id: 004cc9a6466f last_write_checksum: sha1:e32037dcfea1c4bd953f749f74775b3d7d1d83c3 @@ -4417,16 +4578,16 @@ trackedFiles: pristine_git_object: 99032117da3cc10b694f41fd0bc7a0c1f2bd7973 src/autumn_sdk/models/previewattachop.py: id: 2b361be4bfa8 - last_write_checksum: sha1:5093a6f3669c25ec437cc19b3c2ea3e49bac4664 - pristine_git_object: 9daad94ae45ae8076da0c89dd3e6bb578e38083a + last_write_checksum: sha1:7e70b858a262848520eae497c1531e24dfa4b415 + pristine_git_object: b43973c40ef8c6ef1e0fa653e2bb43b9a8d42762 src/autumn_sdk/models/previewmultiattachop.py: id: 963ffcd646a4 - last_write_checksum: sha1:b8ff436f7435ce227a5426ae9fe5059e08cbef2b - pristine_git_object: 3a0561ab842ec6d09dda0e9bae06e6059c92e75a + last_write_checksum: sha1:6616d0af51ae3cc3db52de12e4045a9a4815f58a + pristine_git_object: 3af175b0bfafff76b5dc3a68eab50d9c1a1df296 src/autumn_sdk/models/previewupdateop.py: id: 081d5f08508d - last_write_checksum: sha1:765e69f7f7f703c8e8e92bfa784221add4b6cba7 - pristine_git_object: a2336686fd12de7950079aa5c104ee29e5abe5f6 + last_write_checksum: sha1:5571478e10944c67207a30e257bcb9772474fb02 + pristine_git_object: dd5c8717db573b74909443623775c24c8cf53b04 src/autumn_sdk/models/redeemreferralcodeop.py: id: 0abd7bfae718 last_write_checksum: sha1:b1a584450f1f79e796dd755a9305dc30a07ed601 @@ -4443,6 +4604,10 @@ trackedFiles: id: 603339ee67e3 last_write_checksum: sha1:46900f03adbb063677a4190d461c0460c470618e pristine_git_object: 417d43b99b409c1d4e003c593097ac05ec5da795 + src/autumn_sdk/models/syncrevenuecatop.py: + id: faddfbfd1214 + last_write_checksum: sha1:a2b90222b09b4ca95bc78d3573f5b17d4d50208b + pristine_git_object: 7d12a3d78b0ab1e19f8d66fe3ed367066c145fc2 src/autumn_sdk/models/trackop.py: id: 2a744315e781 last_write_checksum: sha1:216a03a195bb90ad24e42f62294a3de606b29142 @@ -4453,12 +4618,12 @@ trackedFiles: pristine_git_object: c0bbec7e538787d31d5eaebafa5c9033b6df9da3 src/autumn_sdk/models/updatecustomerop.py: id: 28b9d5b59bae - last_write_checksum: sha1:76c2569065030ec3f17314b057cec06d300aecd6 - pristine_git_object: 63e06deec671e219be422630460b483fc31e16bd + last_write_checksum: sha1:e3a4cbc83a84322df6b5261c28d24554eb853733 + pristine_git_object: 0ab9bc3334f761b89f498ebab11570a023209987 src/autumn_sdk/models/updateentityop.py: id: a49305af1e2e - last_write_checksum: sha1:448d0b049bedfd94fba404a9053da4f959200dcf - pristine_git_object: 80fe86883274d4bcd47b2f4f66d9a8286c98cdbe + last_write_checksum: sha1:8814635533bb9ecd0e8b0f5adf9f38189cb32beb + pristine_git_object: 5a828ceff98e07fd7e7f692414cb2ea6e4c4c5ea src/autumn_sdk/models/updatefeatureop.py: id: 2fdfed4aa2f2 last_write_checksum: sha1:31b590aab5fd341dbf9d3ae8ec97f01fc6fe5090 @@ -4471,6 +4636,10 @@ trackedFiles: id: cf1ebabb687c last_write_checksum: sha1:8cca1565af6b67ab6947b016d0ab8f7a431fa824 pristine_git_object: 4c299d37ebbc9a9185bb496926a5a326334dcbe7 + src/autumn_sdk/platform.py: + id: aee79240c441 + last_write_checksum: sha1:2e80cdba550e5487a2ac16c063e24caf3f2b265f + pristine_git_object: 9a76e9815155fa2a01056e20ea2d7ec640718cd9 src/autumn_sdk/py.typed: id: 9b75cee1c007 last_write_checksum: sha1:8efc425ffe830805ffcc0f3055871bdcdc542c60 @@ -4485,8 +4654,8 @@ trackedFiles: pristine_git_object: c52c86dd77e753356560ebcf0ee10f8dc46de593 src/autumn_sdk/sdk.py: id: 9e733b372628 - last_write_checksum: sha1:a51e0822250581aeecd610abcb8207849cd3c73c - pristine_git_object: 19d77062eea9e5010e454d71cb64f137f7001314 + last_write_checksum: sha1:da2019a4fd1bc539f99076623e758d53baec25b7 + pristine_git_object: 8feede30ce5471864788d9ce7e6779ab9be5157c src/autumn_sdk/sdkconfiguration.py: id: e65df2e44fc0 last_write_checksum: sha1:233b710dff940202f00e389e0c8fa6a33f6ae7b4 @@ -5164,4 +5333,34 @@ examples: responses: "202": application/json: {"success": true} + linkRevenueCat: + speakeasy-default-link-revenue-cat: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"organization_slug": "acme", "env": "test", "project_name": "acme-mobile", "redirect_url": "https://dashboard.useautumn.com/dev?tab=revenuecat"} + responses: + "200": + application/json: {"oauth_url": "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write"} + syncRevenueCat: + speakeasy-default-sync-revenue-cat: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"organization_slug": "acme", "env": "test", "product_ids": ["pro", "premium"]} + responses: + "200": + application/json: {"results": [{"plan_id": "pro", "status": "synced", "store_identifier": "autumn.sandbox.org_123.pro", "apps": [{"app_id": "app_test", "app_type": "test_store", "product": "created", "store_push": "skipped", "price": "set"}]}]} + getRevenueCatKeys: + speakeasy-default-get-revenue-cat-keys: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"organization_slug": "acme", "env": "test"} + responses: + "200": + application/json: {"apps": [{"app_id": "app1a2b3c4d", "app_type": "test_store", "name": "Acme (Test Store)", "api_keys": [{"id": "apikey12345", "key": "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ", "environment": "production", "app_id": "app1a2b3c4"}]}], "oauth_access_token": "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ"} examplesVersion: 1.0.2 diff --git a/others/python-sdk/README.md b/others/python-sdk/README.md index c382d6ce5..737b6c390 100644 --- a/others/python-sdk/README.md +++ b/others/python-sdk/README.md @@ -296,6 +296,12 @@ Use this to permanently remove a feature. Note: features that are used in produc * [update](docs/sdks/plans/README.md#update) - Update a plan * [delete](docs/sdks/plans/README.md#delete) - Delete a plan +### [Platform](docs/sdks/platform/README.md) + +* [link_revenue_cat](docs/sdks/platform/README.md#link_revenue_cat) - Generate a RevenueCat OAuth URL for linking a project to an organization. +* [sync_revenue_cat](docs/sdks/platform/README.md#sync_revenue_cat) - Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth. +* [get_revenue_cat_keys](docs/sdks/platform/README.md#get_revenue_cat_keys) - Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app. + ### [Referrals](docs/sdks/referrals/README.md) * [create_code](docs/sdks/referrals/README.md#create_code) - Create or fetch a referral code for a customer in a referral program. diff --git a/others/python-sdk/src/autumn_sdk/billing.py b/others/python-sdk/src/autumn_sdk/billing.py index 715116867..1ff2109b4 100644 --- a/others/python-sdk/src/autumn_sdk/billing.py +++ b/others/python-sdk/src/autumn_sdk/billing.py @@ -427,6 +427,12 @@ class Billing(BaseSDK): models.CreateScheduleInvoiceModeTypedDict, ] ] = None, + discounts: Optional[ + Union[ + List[models.CreateScheduleAttachDiscount], + List[models.CreateScheduleAttachDiscountTypedDict], + ] + ] = None, success_url: Optional[str] = None, checkout_session_params: Optional[Dict[str, Any]] = None, redirect_mode: Optional[models.CreateScheduleRedirectMode] = "if_required", @@ -445,6 +451,7 @@ class Billing(BaseSDK): :param phases: Ordered phase definitions for the schedule. :param entity_id: Optional entity ID for an entity-scoped schedule. :param invoice_mode: Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. + :param discounts: List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. :param success_url: URL to redirect to after successful checkout. :param checkout_session_params: Additional parameters to pass into the creation of the Stripe checkout session. :param redirect_mode: Controls when to return a checkout URL for the immediate phase. 'always' forces a confirmation or checkout flow, 'if_required' only redirects when needed, and 'never' disables redirects. @@ -471,6 +478,9 @@ class Billing(BaseSDK): invoice_mode=utils.get_pydantic_model( invoice_mode, Optional[models.CreateScheduleInvoiceMode] ), + discounts=utils.get_pydantic_model( + discounts, Optional[List[models.CreateScheduleAttachDiscount]] + ), success_url=success_url, checkout_session_params=checkout_session_params, redirect_mode=redirect_mode, @@ -550,6 +560,12 @@ class Billing(BaseSDK): models.CreateScheduleInvoiceModeTypedDict, ] ] = None, + discounts: Optional[ + Union[ + List[models.CreateScheduleAttachDiscount], + List[models.CreateScheduleAttachDiscountTypedDict], + ] + ] = None, success_url: Optional[str] = None, checkout_session_params: Optional[Dict[str, Any]] = None, redirect_mode: Optional[models.CreateScheduleRedirectMode] = "if_required", @@ -568,6 +584,7 @@ class Billing(BaseSDK): :param phases: Ordered phase definitions for the schedule. :param entity_id: Optional entity ID for an entity-scoped schedule. :param invoice_mode: Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. + :param discounts: List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. :param success_url: URL to redirect to after successful checkout. :param checkout_session_params: Additional parameters to pass into the creation of the Stripe checkout session. :param redirect_mode: Controls when to return a checkout URL for the immediate phase. 'always' forces a confirmation or checkout flow, 'if_required' only redirects when needed, and 'never' disables redirects. @@ -594,6 +611,9 @@ class Billing(BaseSDK): invoice_mode=utils.get_pydantic_model( invoice_mode, Optional[models.CreateScheduleInvoiceMode] ), + discounts=utils.get_pydantic_model( + discounts, Optional[List[models.CreateScheduleAttachDiscount]] + ), success_url=success_url, checkout_session_params=checkout_session_params, redirect_mode=redirect_mode, diff --git a/others/python-sdk/src/autumn_sdk/models/__init__.py b/others/python-sdk/src/autumn_sdk/models/__init__.py index e8feabc95..923db4da1 100644 --- a/others/python-sdk/src/autumn_sdk/models/__init__.py +++ b/others/python-sdk/src/autumn_sdk/models/__init__.py @@ -256,6 +256,10 @@ if TYPE_CHECKING: CheckOnIncrease2, CheckParams, CheckParamsTypedDict, + CheckProduct1, + CheckProduct1TypedDict, + CheckProduct2, + CheckProduct2TypedDict, CheckResponse, CheckResponseBody1, CheckResponseBody1TypedDict, @@ -292,10 +296,6 @@ if TYPE_CHECKING: Preview1TypedDict, Preview2, Preview2TypedDict, - Product1, - Product1TypedDict, - Product2, - Product2TypedDict, ProductDisplay1, ProductDisplay1TypedDict, ProductDisplay2, @@ -353,6 +353,7 @@ if TYPE_CHECKING: CreateEntityParamsTypedDict, CreateEntityProcessorType, CreateEntityPurchase, + CreateEntityPurchaseScope, CreateEntityPurchaseTypedDict, CreateEntityResponse, CreateEntityResponseTypedDict, @@ -362,6 +363,7 @@ if TYPE_CHECKING: CreateEntitySpendLimitResponseTypedDict, CreateEntityStatus, CreateEntitySubscription, + CreateEntitySubscriptionScope, CreateEntitySubscriptionTypedDict, CreateEntityThresholdTypeRequestBody, CreateEntityThresholdTypeResponse, @@ -475,6 +477,8 @@ if TYPE_CHECKING: ) from .createscheduleop import ( BillingBehavior, + CreateScheduleAttachDiscount, + CreateScheduleAttachDiscountTypedDict, CreateScheduleBasePrice2, CreateScheduleBasePrice2TypedDict, CreateScheduleBillingMethod2, @@ -573,6 +577,7 @@ if TYPE_CHECKING: Processors, ProcessorsTypedDict, Purchase, + PurchaseScope, PurchaseTypedDict, Referral, ReferralCustomer, @@ -585,6 +590,7 @@ if TYPE_CHECKING: Stripe, StripeTypedDict, Subscription, + SubscriptionScope, SubscriptionTypedDict, TrialsUsed, TrialsUsedTypedDict, @@ -711,6 +717,7 @@ if TYPE_CHECKING: GetCustomerPurchaseLimit2TypedDict, GetCustomerPurchaseLimitUnion, GetCustomerPurchaseLimitUnionTypedDict, + GetCustomerPurchaseScope, GetCustomerPurchaseTypedDict, GetCustomerReferral, GetCustomerReferralTypedDict, @@ -727,6 +734,7 @@ if TYPE_CHECKING: GetCustomerStripe, GetCustomerStripeTypedDict, GetCustomerSubscription, + GetCustomerSubscriptionScope, GetCustomerSubscriptionTypedDict, GetCustomerThresholdType, GetCustomerTrialsUsed, @@ -758,6 +766,7 @@ if TYPE_CHECKING: GetEntityParamsTypedDict, GetEntityProcessorType, GetEntityPurchase, + GetEntityPurchaseScope, GetEntityPurchaseTypedDict, GetEntityResponse, GetEntityResponseTypedDict, @@ -765,6 +774,7 @@ if TYPE_CHECKING: GetEntitySpendLimitTypedDict, GetEntityStatus, GetEntitySubscription, + GetEntitySubscriptionScope, GetEntitySubscriptionTypedDict, GetEntityThresholdType, GetEntityType, @@ -852,6 +862,28 @@ if TYPE_CHECKING: GetPlanTierBehavior, GetPlanType, ) + from .getrevenuecatkeysop import ( + APIKey, + APIKeyTypedDict, + GetRevenueCatKeysApp, + GetRevenueCatKeysAppTypedDict, + GetRevenueCatKeysEnv, + GetRevenueCatKeysGlobals, + GetRevenueCatKeysGlobalsTypedDict, + GetRevenueCatKeysParams, + GetRevenueCatKeysParamsTypedDict, + GetRevenueCatKeysResponse, + GetRevenueCatKeysResponseTypedDict, + ) + from .linkrevenuecatop import ( + LinkRevenueCatEnv, + LinkRevenueCatGlobals, + LinkRevenueCatGlobalsTypedDict, + LinkRevenueCatParams, + LinkRevenueCatParamsTypedDict, + LinkRevenueCatResponse, + LinkRevenueCatResponseTypedDict, + ) from .listcustomersop import ( ListCustomersAutoTopup, ListCustomersAutoTopupTypedDict, @@ -890,6 +922,7 @@ if TYPE_CHECKING: ListCustomersPurchaseLimit2TypedDict, ListCustomersPurchaseLimitUnion, ListCustomersPurchaseLimitUnionTypedDict, + ListCustomersPurchaseScope, ListCustomersPurchaseTypedDict, ListCustomersResponse, ListCustomersResponseTypedDict, @@ -901,6 +934,7 @@ if TYPE_CHECKING: ListCustomersStripe, ListCustomersStripeTypedDict, ListCustomersSubscription, + ListCustomersSubscriptionScope, ListCustomersSubscriptionStatus, ListCustomersSubscriptionTypedDict, ListCustomersThresholdType, @@ -937,6 +971,7 @@ if TYPE_CHECKING: ListEntitiesProcessor, ListEntitiesProcessorType, ListEntitiesPurchase, + ListEntitiesPurchaseScope, ListEntitiesPurchaseTypedDict, ListEntitiesResponse, ListEntitiesResponseTypedDict, @@ -944,6 +979,7 @@ if TYPE_CHECKING: ListEntitiesSpendLimitTypedDict, ListEntitiesStatus, ListEntitiesSubscription, + ListEntitiesSubscriptionScope, ListEntitiesSubscriptionStatus, ListEntitiesSubscriptionTypedDict, ListEntitiesThresholdType, @@ -1547,6 +1583,23 @@ if TYPE_CHECKING: SetupPaymentResponse, SetupPaymentResponseTypedDict, ) + from .syncrevenuecatop import ( + Result, + ResultTypedDict, + StorePush, + SyncRevenueCatApp, + SyncRevenueCatAppTypedDict, + SyncRevenueCatEnv, + SyncRevenueCatGlobals, + SyncRevenueCatGlobalsTypedDict, + SyncRevenueCatParams, + SyncRevenueCatParamsTypedDict, + SyncRevenueCatPrice, + SyncRevenueCatProduct, + SyncRevenueCatResponse, + SyncRevenueCatResponseTypedDict, + SyncRevenueCatStatus, + ) from .trackop import ( Deduction1, Deduction1TypedDict, @@ -1628,6 +1681,7 @@ if TYPE_CHECKING: UpdateCustomerPurchaseLimitResponse2TypedDict, UpdateCustomerPurchaseLimitUnion, UpdateCustomerPurchaseLimitUnionTypedDict, + UpdateCustomerPurchaseScope, UpdateCustomerPurchaseTypedDict, UpdateCustomerResponse, UpdateCustomerResponseTypedDict, @@ -1641,6 +1695,7 @@ if TYPE_CHECKING: UpdateCustomerStripe, UpdateCustomerStripeTypedDict, UpdateCustomerSubscription, + UpdateCustomerSubscriptionScope, UpdateCustomerSubscriptionTypedDict, UpdateCustomerThresholdTypeRequestBody, UpdateCustomerThresholdTypeResponse, @@ -1678,6 +1733,7 @@ if TYPE_CHECKING: UpdateEntityParamsTypedDict, UpdateEntityProcessorType, UpdateEntityPurchase, + UpdateEntityPurchaseScope, UpdateEntityPurchaseTypedDict, UpdateEntityResponse, UpdateEntityResponseTypedDict, @@ -1687,6 +1743,7 @@ if TYPE_CHECKING: UpdateEntitySpendLimitResponseTypedDict, UpdateEntityStatus, UpdateEntitySubscription, + UpdateEntitySubscriptionScope, UpdateEntitySubscriptionTypedDict, UpdateEntityThresholdTypeRequestBody, UpdateEntityThresholdTypeResponse, @@ -1793,6 +1850,8 @@ if TYPE_CHECKING: from . import internal __all__ = [ + "APIKey", + "APIKeyTypedDict", "AggregateEventsCustomRange", "AggregateEventsCustomRangeTypedDict", "AggregateEventsFeatureID", @@ -2026,6 +2085,10 @@ __all__ = [ "CheckOnIncrease2", "CheckParams", "CheckParamsTypedDict", + "CheckProduct1", + "CheckProduct1TypedDict", + "CheckProduct2", + "CheckProduct2TypedDict", "CheckResponse", "CheckResponseBody1", "CheckResponseBody1TypedDict", @@ -2077,6 +2140,7 @@ __all__ = [ "CreateEntityParamsTypedDict", "CreateEntityProcessorType", "CreateEntityPurchase", + "CreateEntityPurchaseScope", "CreateEntityPurchaseTypedDict", "CreateEntityResponse", "CreateEntityResponseTypedDict", @@ -2086,6 +2150,7 @@ __all__ = [ "CreateEntitySpendLimitResponseTypedDict", "CreateEntityStatus", "CreateEntitySubscription", + "CreateEntitySubscriptionScope", "CreateEntitySubscriptionTypedDict", "CreateEntityThresholdTypeRequestBody", "CreateEntityThresholdTypeResponse", @@ -2188,6 +2253,8 @@ __all__ = [ "CreateReferralCodeParamsTypedDict", "CreateReferralCodeResponse", "CreateReferralCodeResponseTypedDict", + "CreateScheduleAttachDiscount", + "CreateScheduleAttachDiscountTypedDict", "CreateScheduleBasePrice2", "CreateScheduleBasePrice2TypedDict", "CreateScheduleBillingMethod2", @@ -2410,6 +2477,7 @@ __all__ = [ "GetCustomerPurchaseLimit2TypedDict", "GetCustomerPurchaseLimitUnion", "GetCustomerPurchaseLimitUnionTypedDict", + "GetCustomerPurchaseScope", "GetCustomerPurchaseTypedDict", "GetCustomerReferral", "GetCustomerReferralTypedDict", @@ -2426,6 +2494,7 @@ __all__ = [ "GetCustomerStripe", "GetCustomerStripeTypedDict", "GetCustomerSubscription", + "GetCustomerSubscriptionScope", "GetCustomerSubscriptionTypedDict", "GetCustomerThresholdType", "GetCustomerTrialsUsed", @@ -2455,6 +2524,7 @@ __all__ = [ "GetEntityParamsTypedDict", "GetEntityProcessorType", "GetEntityPurchase", + "GetEntityPurchaseScope", "GetEntityPurchaseTypedDict", "GetEntityResponse", "GetEntityResponseTypedDict", @@ -2462,6 +2532,7 @@ __all__ = [ "GetEntitySpendLimitTypedDict", "GetEntityStatus", "GetEntitySubscription", + "GetEntitySubscriptionScope", "GetEntitySubscriptionTypedDict", "GetEntityThresholdType", "GetEntityType", @@ -2542,6 +2613,15 @@ __all__ = [ "GetPlanStatus", "GetPlanTierBehavior", "GetPlanType", + "GetRevenueCatKeysApp", + "GetRevenueCatKeysAppTypedDict", + "GetRevenueCatKeysEnv", + "GetRevenueCatKeysGlobals", + "GetRevenueCatKeysGlobalsTypedDict", + "GetRevenueCatKeysParams", + "GetRevenueCatKeysParamsTypedDict", + "GetRevenueCatKeysResponse", + "GetRevenueCatKeysResponseTypedDict", "IncludedUsage1", "IncludedUsage1TypedDict", "IncludedUsage2", @@ -2551,6 +2631,13 @@ __all__ = [ "InvoiceTypedDict", "Item", "ItemTypedDict", + "LinkRevenueCatEnv", + "LinkRevenueCatGlobals", + "LinkRevenueCatGlobalsTypedDict", + "LinkRevenueCatParams", + "LinkRevenueCatParamsTypedDict", + "LinkRevenueCatResponse", + "LinkRevenueCatResponseTypedDict", "ListCustomersAutoTopup", "ListCustomersAutoTopupTypedDict", "ListCustomersBillingControls", @@ -2588,6 +2675,7 @@ __all__ = [ "ListCustomersPurchaseLimit2TypedDict", "ListCustomersPurchaseLimitUnion", "ListCustomersPurchaseLimitUnionTypedDict", + "ListCustomersPurchaseScope", "ListCustomersPurchaseTypedDict", "ListCustomersResponse", "ListCustomersResponseTypedDict", @@ -2599,6 +2687,7 @@ __all__ = [ "ListCustomersStripe", "ListCustomersStripeTypedDict", "ListCustomersSubscription", + "ListCustomersSubscriptionScope", "ListCustomersSubscriptionStatus", "ListCustomersSubscriptionTypedDict", "ListCustomersThresholdType", @@ -2633,6 +2722,7 @@ __all__ = [ "ListEntitiesProcessor", "ListEntitiesProcessorType", "ListEntitiesPurchase", + "ListEntitiesPurchaseScope", "ListEntitiesPurchaseTypedDict", "ListEntitiesResponse", "ListEntitiesResponseTypedDict", @@ -2640,6 +2730,7 @@ __all__ = [ "ListEntitiesSpendLimitTypedDict", "ListEntitiesStatus", "ListEntitiesSubscription", + "ListEntitiesSubscriptionScope", "ListEntitiesSubscriptionStatus", "ListEntitiesSubscriptionTypedDict", "ListEntitiesThresholdType", @@ -3131,10 +3222,6 @@ __all__ = [ "ProcessorType", "Processors", "ProcessorsTypedDict", - "Product1", - "Product1TypedDict", - "Product2", - "Product2TypedDict", "ProductDisplay1", "ProductDisplay1TypedDict", "ProductDisplay2", @@ -3148,6 +3235,7 @@ __all__ = [ "Properties2", "Properties2TypedDict", "Purchase", + "PurchaseScope", "PurchaseTypedDict", "Range", "RedeemReferralCodeGlobals", @@ -3168,6 +3256,8 @@ __all__ = [ "ReferralTypedDict", "RequestBody", "RequestBodyTypedDict", + "Result", + "ResultTypedDict", "Revenuecat", "RevenuecatTypedDict", "Rewards", @@ -3248,10 +3338,24 @@ __all__ = [ "SetupPaymentRemoveItemInterval", "SetupPaymentResponse", "SetupPaymentResponseTypedDict", + "StorePush", "Stripe", "StripeTypedDict", "Subscription", + "SubscriptionScope", "SubscriptionTypedDict", + "SyncRevenueCatApp", + "SyncRevenueCatAppTypedDict", + "SyncRevenueCatEnv", + "SyncRevenueCatGlobals", + "SyncRevenueCatGlobalsTypedDict", + "SyncRevenueCatParams", + "SyncRevenueCatParamsTypedDict", + "SyncRevenueCatPrice", + "SyncRevenueCatProduct", + "SyncRevenueCatResponse", + "SyncRevenueCatResponseTypedDict", + "SyncRevenueCatStatus", "Total", "TotalTypedDict", "TrackGlobals", @@ -3328,6 +3432,7 @@ __all__ = [ "UpdateCustomerPurchaseLimitResponse2TypedDict", "UpdateCustomerPurchaseLimitUnion", "UpdateCustomerPurchaseLimitUnionTypedDict", + "UpdateCustomerPurchaseScope", "UpdateCustomerPurchaseTypedDict", "UpdateCustomerResponse", "UpdateCustomerResponseTypedDict", @@ -3341,6 +3446,7 @@ __all__ = [ "UpdateCustomerStripe", "UpdateCustomerStripeTypedDict", "UpdateCustomerSubscription", + "UpdateCustomerSubscriptionScope", "UpdateCustomerSubscriptionTypedDict", "UpdateCustomerThresholdTypeRequestBody", "UpdateCustomerThresholdTypeResponse", @@ -3376,6 +3482,7 @@ __all__ = [ "UpdateEntityParamsTypedDict", "UpdateEntityProcessorType", "UpdateEntityPurchase", + "UpdateEntityPurchaseScope", "UpdateEntityPurchaseTypedDict", "UpdateEntityResponse", "UpdateEntityResponseTypedDict", @@ -3385,6 +3492,7 @@ __all__ = [ "UpdateEntitySpendLimitResponseTypedDict", "UpdateEntityStatus", "UpdateEntitySubscription", + "UpdateEntitySubscriptionScope", "UpdateEntitySubscriptionTypedDict", "UpdateEntityThresholdTypeRequestBody", "UpdateEntityThresholdTypeResponse", @@ -3732,6 +3840,10 @@ _dynamic_imports: dict[str, str] = { "CheckOnIncrease2": ".checkop", "CheckParams": ".checkop", "CheckParamsTypedDict": ".checkop", + "CheckProduct1": ".checkop", + "CheckProduct1TypedDict": ".checkop", + "CheckProduct2": ".checkop", + "CheckProduct2TypedDict": ".checkop", "CheckResponse": ".checkop", "CheckResponseBody1": ".checkop", "CheckResponseBody1TypedDict": ".checkop", @@ -3768,10 +3880,6 @@ _dynamic_imports: dict[str, str] = { "Preview1TypedDict": ".checkop", "Preview2": ".checkop", "Preview2TypedDict": ".checkop", - "Product1": ".checkop", - "Product1TypedDict": ".checkop", - "Product2": ".checkop", - "Product2TypedDict": ".checkop", "ProductDisplay1": ".checkop", "ProductDisplay1TypedDict": ".checkop", "ProductDisplay2": ".checkop", @@ -3825,6 +3933,7 @@ _dynamic_imports: dict[str, str] = { "CreateEntityParamsTypedDict": ".createentityop", "CreateEntityProcessorType": ".createentityop", "CreateEntityPurchase": ".createentityop", + "CreateEntityPurchaseScope": ".createentityop", "CreateEntityPurchaseTypedDict": ".createentityop", "CreateEntityResponse": ".createentityop", "CreateEntityResponseTypedDict": ".createentityop", @@ -3834,6 +3943,7 @@ _dynamic_imports: dict[str, str] = { "CreateEntitySpendLimitResponseTypedDict": ".createentityop", "CreateEntityStatus": ".createentityop", "CreateEntitySubscription": ".createentityop", + "CreateEntitySubscriptionScope": ".createentityop", "CreateEntitySubscriptionTypedDict": ".createentityop", "CreateEntityThresholdTypeRequestBody": ".createentityop", "CreateEntityThresholdTypeResponse": ".createentityop", @@ -3939,6 +4049,8 @@ _dynamic_imports: dict[str, str] = { "CreateReferralCodeResponse": ".createreferralcodeop", "CreateReferralCodeResponseTypedDict": ".createreferralcodeop", "BillingBehavior": ".createscheduleop", + "CreateScheduleAttachDiscount": ".createscheduleop", + "CreateScheduleAttachDiscountTypedDict": ".createscheduleop", "CreateScheduleBasePrice2": ".createscheduleop", "CreateScheduleBasePrice2TypedDict": ".createscheduleop", "CreateScheduleBillingMethod2": ".createscheduleop", @@ -4035,6 +4147,7 @@ _dynamic_imports: dict[str, str] = { "Processors": ".customer", "ProcessorsTypedDict": ".customer", "Purchase": ".customer", + "PurchaseScope": ".customer", "PurchaseTypedDict": ".customer", "Referral": ".customer", "ReferralCustomer": ".customer", @@ -4047,6 +4160,7 @@ _dynamic_imports: dict[str, str] = { "Stripe": ".customer", "StripeTypedDict": ".customer", "Subscription": ".customer", + "SubscriptionScope": ".customer", "SubscriptionTypedDict": ".customer", "TrialsUsed": ".customer", "TrialsUsedTypedDict": ".customer", @@ -4157,6 +4271,7 @@ _dynamic_imports: dict[str, str] = { "GetCustomerPurchaseLimit2TypedDict": ".getcustomerop", "GetCustomerPurchaseLimitUnion": ".getcustomerop", "GetCustomerPurchaseLimitUnionTypedDict": ".getcustomerop", + "GetCustomerPurchaseScope": ".getcustomerop", "GetCustomerPurchaseTypedDict": ".getcustomerop", "GetCustomerReferral": ".getcustomerop", "GetCustomerReferralTypedDict": ".getcustomerop", @@ -4173,6 +4288,7 @@ _dynamic_imports: dict[str, str] = { "GetCustomerStripe": ".getcustomerop", "GetCustomerStripeTypedDict": ".getcustomerop", "GetCustomerSubscription": ".getcustomerop", + "GetCustomerSubscriptionScope": ".getcustomerop", "GetCustomerSubscriptionTypedDict": ".getcustomerop", "GetCustomerThresholdType": ".getcustomerop", "GetCustomerTrialsUsed": ".getcustomerop", @@ -4202,6 +4318,7 @@ _dynamic_imports: dict[str, str] = { "GetEntityParamsTypedDict": ".getentityop", "GetEntityProcessorType": ".getentityop", "GetEntityPurchase": ".getentityop", + "GetEntityPurchaseScope": ".getentityop", "GetEntityPurchaseTypedDict": ".getentityop", "GetEntityResponse": ".getentityop", "GetEntityResponseTypedDict": ".getentityop", @@ -4209,6 +4326,7 @@ _dynamic_imports: dict[str, str] = { "GetEntitySpendLimitTypedDict": ".getentityop", "GetEntityStatus": ".getentityop", "GetEntitySubscription": ".getentityop", + "GetEntitySubscriptionScope": ".getentityop", "GetEntitySubscriptionTypedDict": ".getentityop", "GetEntityThresholdType": ".getentityop", "GetEntityType": ".getentityop", @@ -4289,6 +4407,24 @@ _dynamic_imports: dict[str, str] = { "GetPlanStatus": ".getplanop", "GetPlanTierBehavior": ".getplanop", "GetPlanType": ".getplanop", + "APIKey": ".getrevenuecatkeysop", + "APIKeyTypedDict": ".getrevenuecatkeysop", + "GetRevenueCatKeysApp": ".getrevenuecatkeysop", + "GetRevenueCatKeysAppTypedDict": ".getrevenuecatkeysop", + "GetRevenueCatKeysEnv": ".getrevenuecatkeysop", + "GetRevenueCatKeysGlobals": ".getrevenuecatkeysop", + "GetRevenueCatKeysGlobalsTypedDict": ".getrevenuecatkeysop", + "GetRevenueCatKeysParams": ".getrevenuecatkeysop", + "GetRevenueCatKeysParamsTypedDict": ".getrevenuecatkeysop", + "GetRevenueCatKeysResponse": ".getrevenuecatkeysop", + "GetRevenueCatKeysResponseTypedDict": ".getrevenuecatkeysop", + "LinkRevenueCatEnv": ".linkrevenuecatop", + "LinkRevenueCatGlobals": ".linkrevenuecatop", + "LinkRevenueCatGlobalsTypedDict": ".linkrevenuecatop", + "LinkRevenueCatParams": ".linkrevenuecatop", + "LinkRevenueCatParamsTypedDict": ".linkrevenuecatop", + "LinkRevenueCatResponse": ".linkrevenuecatop", + "LinkRevenueCatResponseTypedDict": ".linkrevenuecatop", "ListCustomersAutoTopup": ".listcustomersop", "ListCustomersAutoTopupTypedDict": ".listcustomersop", "ListCustomersBillingControls": ".listcustomersop", @@ -4326,6 +4462,7 @@ _dynamic_imports: dict[str, str] = { "ListCustomersPurchaseLimit2TypedDict": ".listcustomersop", "ListCustomersPurchaseLimitUnion": ".listcustomersop", "ListCustomersPurchaseLimitUnionTypedDict": ".listcustomersop", + "ListCustomersPurchaseScope": ".listcustomersop", "ListCustomersPurchaseTypedDict": ".listcustomersop", "ListCustomersResponse": ".listcustomersop", "ListCustomersResponseTypedDict": ".listcustomersop", @@ -4337,6 +4474,7 @@ _dynamic_imports: dict[str, str] = { "ListCustomersStripe": ".listcustomersop", "ListCustomersStripeTypedDict": ".listcustomersop", "ListCustomersSubscription": ".listcustomersop", + "ListCustomersSubscriptionScope": ".listcustomersop", "ListCustomersSubscriptionStatus": ".listcustomersop", "ListCustomersSubscriptionTypedDict": ".listcustomersop", "ListCustomersThresholdType": ".listcustomersop", @@ -4371,6 +4509,7 @@ _dynamic_imports: dict[str, str] = { "ListEntitiesProcessor": ".listentitiesop", "ListEntitiesProcessorType": ".listentitiesop", "ListEntitiesPurchase": ".listentitiesop", + "ListEntitiesPurchaseScope": ".listentitiesop", "ListEntitiesPurchaseTypedDict": ".listentitiesop", "ListEntitiesResponse": ".listentitiesop", "ListEntitiesResponseTypedDict": ".listentitiesop", @@ -4378,6 +4517,7 @@ _dynamic_imports: dict[str, str] = { "ListEntitiesSpendLimitTypedDict": ".listentitiesop", "ListEntitiesStatus": ".listentitiesop", "ListEntitiesSubscription": ".listentitiesop", + "ListEntitiesSubscriptionScope": ".listentitiesop", "ListEntitiesSubscriptionStatus": ".listentitiesop", "ListEntitiesSubscriptionTypedDict": ".listentitiesop", "ListEntitiesThresholdType": ".listentitiesop", @@ -4957,6 +5097,21 @@ _dynamic_imports: dict[str, str] = { "SetupPaymentRemoveItemInterval": ".setuppaymentop", "SetupPaymentResponse": ".setuppaymentop", "SetupPaymentResponseTypedDict": ".setuppaymentop", + "Result": ".syncrevenuecatop", + "ResultTypedDict": ".syncrevenuecatop", + "StorePush": ".syncrevenuecatop", + "SyncRevenueCatApp": ".syncrevenuecatop", + "SyncRevenueCatAppTypedDict": ".syncrevenuecatop", + "SyncRevenueCatEnv": ".syncrevenuecatop", + "SyncRevenueCatGlobals": ".syncrevenuecatop", + "SyncRevenueCatGlobalsTypedDict": ".syncrevenuecatop", + "SyncRevenueCatParams": ".syncrevenuecatop", + "SyncRevenueCatParamsTypedDict": ".syncrevenuecatop", + "SyncRevenueCatPrice": ".syncrevenuecatop", + "SyncRevenueCatProduct": ".syncrevenuecatop", + "SyncRevenueCatResponse": ".syncrevenuecatop", + "SyncRevenueCatResponseTypedDict": ".syncrevenuecatop", + "SyncRevenueCatStatus": ".syncrevenuecatop", "Deduction1": ".trackop", "Deduction1TypedDict": ".trackop", "Deduction2": ".trackop", @@ -5033,6 +5188,7 @@ _dynamic_imports: dict[str, str] = { "UpdateCustomerPurchaseLimitResponse2TypedDict": ".updatecustomerop", "UpdateCustomerPurchaseLimitUnion": ".updatecustomerop", "UpdateCustomerPurchaseLimitUnionTypedDict": ".updatecustomerop", + "UpdateCustomerPurchaseScope": ".updatecustomerop", "UpdateCustomerPurchaseTypedDict": ".updatecustomerop", "UpdateCustomerResponse": ".updatecustomerop", "UpdateCustomerResponseTypedDict": ".updatecustomerop", @@ -5046,6 +5202,7 @@ _dynamic_imports: dict[str, str] = { "UpdateCustomerStripe": ".updatecustomerop", "UpdateCustomerStripeTypedDict": ".updatecustomerop", "UpdateCustomerSubscription": ".updatecustomerop", + "UpdateCustomerSubscriptionScope": ".updatecustomerop", "UpdateCustomerSubscriptionTypedDict": ".updatecustomerop", "UpdateCustomerThresholdTypeRequestBody": ".updatecustomerop", "UpdateCustomerThresholdTypeResponse": ".updatecustomerop", @@ -5081,6 +5238,7 @@ _dynamic_imports: dict[str, str] = { "UpdateEntityParamsTypedDict": ".updateentityop", "UpdateEntityProcessorType": ".updateentityop", "UpdateEntityPurchase": ".updateentityop", + "UpdateEntityPurchaseScope": ".updateentityop", "UpdateEntityPurchaseTypedDict": ".updateentityop", "UpdateEntityResponse": ".updateentityop", "UpdateEntityResponseTypedDict": ".updateentityop", @@ -5090,6 +5248,7 @@ _dynamic_imports: dict[str, str] = { "UpdateEntitySpendLimitResponseTypedDict": ".updateentityop", "UpdateEntityStatus": ".updateentityop", "UpdateEntitySubscription": ".updateentityop", + "UpdateEntitySubscriptionScope": ".updateentityop", "UpdateEntitySubscriptionTypedDict": ".updateentityop", "UpdateEntityThresholdTypeRequestBody": ".updateentityop", "UpdateEntityThresholdTypeResponse": ".updateentityop", diff --git a/others/python-sdk/src/autumn_sdk/models/attachop.py b/others/python-sdk/src/autumn_sdk/models/attachop.py index 9fb1b21c6..5d591a0d1 100644 --- a/others/python-sdk/src/autumn_sdk/models/attachop.py +++ b/others/python-sdk/src/autumn_sdk/models/attachop.py @@ -990,6 +990,10 @@ class AttachInvoiceModeTypedDict(TypedDict): r"""If true, enables the plan immediately even though the invoice is not paid yet.""" finalize: NotRequired[bool] r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: NotRequired[str] + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + net_terms_days: NotRequired[int] + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" class AttachInvoiceMode(BaseModel): @@ -1004,9 +1008,22 @@ class AttachInvoiceMode(BaseModel): finalize: Optional[bool] = True r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: Optional[str] = None + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + + net_terms_days: Optional[int] = None + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["enable_plan_immediately", "finalize"]) + optional_fields = set( + [ + "enable_plan_immediately", + "finalize", + "invoice_template_id", + "net_terms_days", + ] + ) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/billingupdateop.py b/others/python-sdk/src/autumn_sdk/models/billingupdateop.py index dc5897507..ac6c4e6ec 100644 --- a/others/python-sdk/src/autumn_sdk/models/billingupdateop.py +++ b/others/python-sdk/src/autumn_sdk/models/billingupdateop.py @@ -994,6 +994,10 @@ class BillingUpdateInvoiceModeTypedDict(TypedDict): r"""If true, enables the plan immediately even though the invoice is not paid yet.""" finalize: NotRequired[bool] r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: NotRequired[str] + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + net_terms_days: NotRequired[int] + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" class BillingUpdateInvoiceMode(BaseModel): @@ -1008,9 +1012,22 @@ class BillingUpdateInvoiceMode(BaseModel): finalize: Optional[bool] = True r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: Optional[str] = None + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + + net_terms_days: Optional[int] = None + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["enable_plan_immediately", "finalize"]) + optional_fields = set( + [ + "enable_plan_immediately", + "finalize", + "invoice_template_id", + "net_terms_days", + ] + ) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/checkop.py b/others/python-sdk/src/autumn_sdk/models/checkop.py index b165ecfba..6c98274a9 100644 --- a/others/python-sdk/src/autumn_sdk/models/checkop.py +++ b/others/python-sdk/src/autumn_sdk/models/checkop.py @@ -890,7 +890,7 @@ class Properties2(BaseModel): return m -class Product2TypedDict(TypedDict): +class CheckProduct2TypedDict(TypedDict): id: str r"""The ID of the product you set when creating the product""" name: str @@ -920,7 +920,7 @@ class Product2TypedDict(TypedDict): properties: NotRequired[Properties2TypedDict] -class Product2(BaseModel): +class CheckProduct2(BaseModel): id: str r"""The ID of the product you set when creating the product""" @@ -1001,7 +1001,7 @@ class Preview2TypedDict(TypedDict): r"""The ID of the feature that was checked.""" feature_name: str r"""The display name of the feature.""" - products: List[Product2TypedDict] + products: List[CheckProduct2TypedDict] r"""Products that would grant access to this feature. Use to display upgrade options.""" @@ -1023,7 +1023,7 @@ class Preview2(BaseModel): feature_name: str r"""The display name of the feature.""" - products: List[Product2] + products: List[CheckProduct2] r"""Products that would grant access to this feature. Use to display upgrade options.""" @@ -1832,7 +1832,7 @@ class Properties1(BaseModel): return m -class Product1TypedDict(TypedDict): +class CheckProduct1TypedDict(TypedDict): id: str r"""The ID of the product you set when creating the product""" name: str @@ -1862,7 +1862,7 @@ class Product1TypedDict(TypedDict): properties: NotRequired[Properties1TypedDict] -class Product1(BaseModel): +class CheckProduct1(BaseModel): id: str r"""The ID of the product you set when creating the product""" @@ -1943,7 +1943,7 @@ class Preview1TypedDict(TypedDict): r"""The ID of the feature that was checked.""" feature_name: str r"""The display name of the feature.""" - products: List[Product1TypedDict] + products: List[CheckProduct1TypedDict] r"""Products that would grant access to this feature. Use to display upgrade options.""" @@ -1965,7 +1965,7 @@ class Preview1(BaseModel): feature_name: str r"""The display name of the feature.""" - products: List[Product1] + products: List[CheckProduct1] r"""Products that would grant access to this feature. Use to display upgrade options.""" diff --git a/others/python-sdk/src/autumn_sdk/models/createentityop.py b/others/python-sdk/src/autumn_sdk/models/createentityop.py index 55fbc8631..1ad23c8de 100644 --- a/others/python-sdk/src/autumn_sdk/models/createentityop.py +++ b/others/python-sdk/src/autumn_sdk/models/createentityop.py @@ -289,6 +289,16 @@ CreateEntityStatus = Union[ r"""Current status of the subscription.""" +CreateEntitySubscriptionScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this subscription is attached at the customer level or entity level.""" + + class CreateEntitySubscriptionTypedDict(TypedDict): id: str r"""The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.""" @@ -317,6 +327,8 @@ class CreateEntitySubscriptionTypedDict(TypedDict): quantity: float r"""Number of units of this subscription (for per-seat plans).""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[CreateEntitySubscriptionScope] + r"""Whether this subscription is attached at the customer level or entity level.""" class CreateEntitySubscription(BaseModel): @@ -361,9 +373,12 @@ class CreateEntitySubscription(BaseModel): plan: Optional[Plan] = None + scope: Optional[CreateEntitySubscriptionScope] = None + r"""Whether this subscription is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set( [ "canceled_at", @@ -395,6 +410,16 @@ class CreateEntitySubscription(BaseModel): return m +CreateEntityPurchaseScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this purchase is attached at the customer level or entity level.""" + + class CreateEntityPurchaseTypedDict(TypedDict): plan_id: str r"""The unique identifier of the purchased plan.""" @@ -405,6 +430,8 @@ class CreateEntityPurchaseTypedDict(TypedDict): quantity: float r"""Number of units purchased.""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[CreateEntityPurchaseScope] + r"""Whether this purchase is attached at the customer level or entity level.""" class CreateEntityPurchase(BaseModel): @@ -422,9 +449,12 @@ class CreateEntityPurchase(BaseModel): plan: Optional[Plan] = None + scope: Optional[CreateEntityPurchaseScope] = None + r"""Whether this purchase is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set(["expires_at"]) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/createscheduleop.py b/others/python-sdk/src/autumn_sdk/models/createscheduleop.py index 97d115471..201b77cbe 100644 --- a/others/python-sdk/src/autumn_sdk/models/createscheduleop.py +++ b/others/python-sdk/src/autumn_sdk/models/createscheduleop.py @@ -54,6 +54,10 @@ class CreateScheduleInvoiceModeTypedDict(TypedDict): r"""If true, enables the plan immediately even though the invoice is not paid yet.""" finalize: NotRequired[bool] r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: NotRequired[str] + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + net_terms_days: NotRequired[int] + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" class CreateScheduleInvoiceMode(BaseModel): @@ -68,9 +72,57 @@ class CreateScheduleInvoiceMode(BaseModel): finalize: Optional[bool] = True r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: Optional[str] = None + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + + net_terms_days: Optional[int] = None + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["enable_plan_immediately", "finalize"]) + optional_fields = set( + [ + "enable_plan_immediately", + "finalize", + "invoice_template_id", + "net_terms_days", + ] + ) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m + + +class CreateScheduleAttachDiscountTypedDict(TypedDict): + r"""A discount to apply. Can be either a reward ID or a promotion code.""" + + reward_id: NotRequired[str] + r"""The ID of the reward to apply as a discount.""" + promotion_code: NotRequired[str] + r"""The promotion code to apply as a discount.""" + + +class CreateScheduleAttachDiscount(BaseModel): + r"""A discount to apply. Can be either a reward ID or a promotion code.""" + + reward_id: Optional[str] = None + r"""The ID of the reward to apply as a discount.""" + + promotion_code: Optional[str] = None + r"""The promotion code to apply as a discount.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["reward_id", "promotion_code"]) serialized = handler(self) m = {} @@ -640,6 +692,8 @@ class CreateScheduleParamsTypedDict(TypedDict): r"""Optional entity ID for an entity-scoped schedule.""" invoice_mode: NotRequired[CreateScheduleInvoiceModeTypedDict] r"""Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase.""" + discounts: NotRequired[List[CreateScheduleAttachDiscountTypedDict]] + r"""List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code.""" success_url: NotRequired[str] r"""URL to redirect to after successful checkout.""" checkout_session_params: NotRequired[Dict[str, Any]] @@ -667,6 +721,9 @@ class CreateScheduleParams(BaseModel): invoice_mode: Optional[CreateScheduleInvoiceMode] = None r"""Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase.""" + discounts: Optional[List[CreateScheduleAttachDiscount]] = None + r"""List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code.""" + success_url: Optional[str] = None r"""URL to redirect to after successful checkout.""" @@ -694,6 +751,7 @@ class CreateScheduleParams(BaseModel): [ "entity_id", "invoice_mode", + "discounts", "success_url", "checkout_session_params", "redirect_mode", diff --git a/others/python-sdk/src/autumn_sdk/models/customer.py b/others/python-sdk/src/autumn_sdk/models/customer.py index d91dbc536..47c3d131d 100644 --- a/others/python-sdk/src/autumn_sdk/models/customer.py +++ b/others/python-sdk/src/autumn_sdk/models/customer.py @@ -375,6 +375,16 @@ CustomerStatus = Union[ r"""Current status of the subscription.""" +SubscriptionScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this subscription is attached at the customer level or entity level.""" + + class SubscriptionTypedDict(TypedDict): id: str r"""The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.""" @@ -403,6 +413,8 @@ class SubscriptionTypedDict(TypedDict): quantity: float r"""Number of units of this subscription (for per-seat plans).""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[SubscriptionScope] + r"""Whether this subscription is attached at the customer level or entity level.""" class Subscription(BaseModel): @@ -447,9 +459,12 @@ class Subscription(BaseModel): plan: Optional[Plan] = None + scope: Optional[SubscriptionScope] = None + r"""Whether this subscription is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set( [ "canceled_at", @@ -481,6 +496,16 @@ class Subscription(BaseModel): return m +PurchaseScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this purchase is attached at the customer level or entity level.""" + + class PurchaseTypedDict(TypedDict): plan_id: str r"""The unique identifier of the purchased plan.""" @@ -491,6 +516,8 @@ class PurchaseTypedDict(TypedDict): quantity: float r"""Number of units purchased.""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[PurchaseScope] + r"""Whether this purchase is attached at the customer level or entity level.""" class Purchase(BaseModel): @@ -508,9 +535,12 @@ class Purchase(BaseModel): plan: Optional[Plan] = None + scope: Optional[PurchaseScope] = None + r"""Whether this purchase is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set(["expires_at"]) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/getcustomerop.py b/others/python-sdk/src/autumn_sdk/models/getcustomerop.py index 2770ceced..a4ec8bc47 100644 --- a/others/python-sdk/src/autumn_sdk/models/getcustomerop.py +++ b/others/python-sdk/src/autumn_sdk/models/getcustomerop.py @@ -438,6 +438,16 @@ GetCustomerStatus = Union[ r"""Current status of the subscription.""" +GetCustomerSubscriptionScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this subscription is attached at the customer level or entity level.""" + + class GetCustomerSubscriptionTypedDict(TypedDict): id: str r"""The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.""" @@ -466,6 +476,8 @@ class GetCustomerSubscriptionTypedDict(TypedDict): quantity: float r"""Number of units of this subscription (for per-seat plans).""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[GetCustomerSubscriptionScope] + r"""Whether this subscription is attached at the customer level or entity level.""" class GetCustomerSubscription(BaseModel): @@ -510,9 +522,12 @@ class GetCustomerSubscription(BaseModel): plan: Optional[Plan] = None + scope: Optional[GetCustomerSubscriptionScope] = None + r"""Whether this subscription is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set( [ "canceled_at", @@ -544,6 +559,16 @@ class GetCustomerSubscription(BaseModel): return m +GetCustomerPurchaseScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this purchase is attached at the customer level or entity level.""" + + class GetCustomerPurchaseTypedDict(TypedDict): plan_id: str r"""The unique identifier of the purchased plan.""" @@ -554,6 +579,8 @@ class GetCustomerPurchaseTypedDict(TypedDict): quantity: float r"""Number of units purchased.""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[GetCustomerPurchaseScope] + r"""Whether this purchase is attached at the customer level or entity level.""" class GetCustomerPurchase(BaseModel): @@ -571,9 +598,12 @@ class GetCustomerPurchase(BaseModel): plan: Optional[Plan] = None + scope: Optional[GetCustomerPurchaseScope] = None + r"""Whether this purchase is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set(["expires_at"]) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/getentityop.py b/others/python-sdk/src/autumn_sdk/models/getentityop.py index 2b8a05c98..1dee20639 100644 --- a/others/python-sdk/src/autumn_sdk/models/getentityop.py +++ b/others/python-sdk/src/autumn_sdk/models/getentityop.py @@ -97,6 +97,16 @@ GetEntityStatus = Union[ r"""Current status of the subscription.""" +GetEntitySubscriptionScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this subscription is attached at the customer level or entity level.""" + + class GetEntitySubscriptionTypedDict(TypedDict): id: str r"""The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.""" @@ -125,6 +135,8 @@ class GetEntitySubscriptionTypedDict(TypedDict): quantity: float r"""Number of units of this subscription (for per-seat plans).""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[GetEntitySubscriptionScope] + r"""Whether this subscription is attached at the customer level or entity level.""" class GetEntitySubscription(BaseModel): @@ -169,9 +181,12 @@ class GetEntitySubscription(BaseModel): plan: Optional[Plan] = None + scope: Optional[GetEntitySubscriptionScope] = None + r"""Whether this subscription is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set( [ "canceled_at", @@ -203,6 +218,16 @@ class GetEntitySubscription(BaseModel): return m +GetEntityPurchaseScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this purchase is attached at the customer level or entity level.""" + + class GetEntityPurchaseTypedDict(TypedDict): plan_id: str r"""The unique identifier of the purchased plan.""" @@ -213,6 +238,8 @@ class GetEntityPurchaseTypedDict(TypedDict): quantity: float r"""Number of units purchased.""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[GetEntityPurchaseScope] + r"""Whether this purchase is attached at the customer level or entity level.""" class GetEntityPurchase(BaseModel): @@ -230,9 +257,12 @@ class GetEntityPurchase(BaseModel): plan: Optional[Plan] = None + scope: Optional[GetEntityPurchaseScope] = None + r"""Whether this purchase is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set(["expires_at"]) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/getrevenuecatkeysop.py b/others/python-sdk/src/autumn_sdk/models/getrevenuecatkeysop.py new file mode 100644 index 000000000..a51f5d262 --- /dev/null +++ b/others/python-sdk/src/autumn_sdk/models/getrevenuecatkeysop.py @@ -0,0 +1,179 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from autumn_sdk.types import ( + BaseModel, + Nullable, + OptionalNullable, + UNSET, + UNSET_SENTINEL, +) +from autumn_sdk.utils import FieldMetadata, HeaderMetadata +import pydantic +from pydantic import ConfigDict, model_serializer +from typing import Any, Dict, List, Literal, Optional +from typing_extensions import Annotated, NotRequired, TypedDict + + +class GetRevenueCatKeysGlobalsTypedDict(TypedDict): + x_api_version: NotRequired[str] + + +class GetRevenueCatKeysGlobals(BaseModel): + x_api_version: Annotated[ + Optional[str], + pydantic.Field(alias="x-api-version"), + FieldMetadata(header=HeaderMetadata(style="simple", explode=False)), + ] = "2.3.0" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["x-api-version"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m + + +GetRevenueCatKeysEnv = Literal[ + "test", + "sandbox", + "live", +] +r"""\"test\" and \"sandbox\" both target the sandbox environment""" + + +class GetRevenueCatKeysParamsTypedDict(TypedDict): + organization_slug: str + env: GetRevenueCatKeysEnv + r"""\"test\" and \"sandbox\" both target the sandbox environment""" + + +class GetRevenueCatKeysParams(BaseModel): + organization_slug: str + + env: GetRevenueCatKeysEnv + r"""\"test\" and \"sandbox\" both target the sandbox environment""" + + +class APIKeyTypedDict(TypedDict): + id: str + key: str + r"""The public SDK API key value""" + environment: NotRequired[Nullable[str]] + r"""e.g. \"production\" / \"sandbox\" """ + app_id: NotRequired[Nullable[str]] + created_at: NotRequired[float] + + +class APIKey(BaseModel): + model_config = ConfigDict( + populate_by_name=True, arbitrary_types_allowed=True, extra="allow" + ) + __pydantic_extra__: Dict[str, Any] = pydantic.Field(init=False) + + id: str + + key: str + r"""The public SDK API key value""" + + environment: OptionalNullable[str] = UNSET + r"""e.g. \"production\" / \"sandbox\" """ + + app_id: OptionalNullable[str] = UNSET + + created_at: Optional[float] = None + + @property + def additional_properties(self): + return self.__pydantic_extra__ + + @additional_properties.setter + def additional_properties(self, value): + self.__pydantic_extra__ = value # pyright: ignore[reportIncompatibleVariableOverride] + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["environment", "app_id", "created_at"]) + nullable_fields = set(["environment", "app_id"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + serialized.pop(k, serialized.pop(n, None)) + is_nullable_and_explicitly_set = ( + k in nullable_fields + and (self.__pydantic_fields_set__.intersection({n})) # pylint: disable=no-member + ) + + if val != UNSET_SENTINEL: + if ( + val is not None + or k not in optional_fields + or is_nullable_and_explicitly_set + ): + m[k] = val + for k, v in serialized.items(): + m[k] = v + + return m + + +class GetRevenueCatKeysAppTypedDict(TypedDict): + app_id: str + app_type: str + r"""RevenueCat store type, e.g. test_store / app_store / play_store""" + name: str + api_keys: List[APIKeyTypedDict] + + +class GetRevenueCatKeysApp(BaseModel): + app_id: str + + app_type: str + r"""RevenueCat store type, e.g. test_store / app_store / play_store""" + + name: str + + api_keys: List[APIKey] + + +class GetRevenueCatKeysResponseTypedDict(TypedDict): + r"""OK""" + + apps: List[GetRevenueCatKeysAppTypedDict] + oauth_access_token: Nullable[str] + r"""Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token.""" + + +class GetRevenueCatKeysResponse(BaseModel): + r"""OK""" + + apps: List[GetRevenueCatKeysApp] + + oauth_access_token: Nullable[str] + r"""Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + m[k] = val + + return m diff --git a/others/python-sdk/src/autumn_sdk/models/linkrevenuecatop.py b/others/python-sdk/src/autumn_sdk/models/linkrevenuecatop.py new file mode 100644 index 000000000..2a117515b --- /dev/null +++ b/others/python-sdk/src/autumn_sdk/models/linkrevenuecatop.py @@ -0,0 +1,72 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from autumn_sdk.types import BaseModel, UNSET_SENTINEL +from autumn_sdk.utils import FieldMetadata, HeaderMetadata +import pydantic +from pydantic import model_serializer +from typing import Literal, Optional +from typing_extensions import Annotated, NotRequired, TypedDict + + +class LinkRevenueCatGlobalsTypedDict(TypedDict): + x_api_version: NotRequired[str] + + +class LinkRevenueCatGlobals(BaseModel): + x_api_version: Annotated[ + Optional[str], + pydantic.Field(alias="x-api-version"), + FieldMetadata(header=HeaderMetadata(style="simple", explode=False)), + ] = "2.3.0" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["x-api-version"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m + + +LinkRevenueCatEnv = Literal[ + "test", + "live", +] + + +class LinkRevenueCatParamsTypedDict(TypedDict): + organization_slug: str + env: LinkRevenueCatEnv + project_name: str + redirect_url: str + + +class LinkRevenueCatParams(BaseModel): + organization_slug: str + + env: LinkRevenueCatEnv + + project_name: str + + redirect_url: str + + +class LinkRevenueCatResponseTypedDict(TypedDict): + r"""OK""" + + oauth_url: str + + +class LinkRevenueCatResponse(BaseModel): + r"""OK""" + + oauth_url: str diff --git a/others/python-sdk/src/autumn_sdk/models/listcustomersop.py b/others/python-sdk/src/autumn_sdk/models/listcustomersop.py index efb7ae861..732fd3a87 100644 --- a/others/python-sdk/src/autumn_sdk/models/listcustomersop.py +++ b/others/python-sdk/src/autumn_sdk/models/listcustomersop.py @@ -507,6 +507,16 @@ ListCustomersStatus = Union[ r"""Current status of the subscription.""" +ListCustomersSubscriptionScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this subscription is attached at the customer level or entity level.""" + + class ListCustomersSubscriptionTypedDict(TypedDict): id: str r"""The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.""" @@ -535,6 +545,8 @@ class ListCustomersSubscriptionTypedDict(TypedDict): quantity: float r"""Number of units of this subscription (for per-seat plans).""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[ListCustomersSubscriptionScope] + r"""Whether this subscription is attached at the customer level or entity level.""" class ListCustomersSubscription(BaseModel): @@ -579,9 +591,12 @@ class ListCustomersSubscription(BaseModel): plan: Optional[Plan] = None + scope: Optional[ListCustomersSubscriptionScope] = None + r"""Whether this subscription is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set( [ "canceled_at", @@ -613,6 +628,16 @@ class ListCustomersSubscription(BaseModel): return m +ListCustomersPurchaseScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this purchase is attached at the customer level or entity level.""" + + class ListCustomersPurchaseTypedDict(TypedDict): plan_id: str r"""The unique identifier of the purchased plan.""" @@ -623,6 +648,8 @@ class ListCustomersPurchaseTypedDict(TypedDict): quantity: float r"""Number of units purchased.""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[ListCustomersPurchaseScope] + r"""Whether this purchase is attached at the customer level or entity level.""" class ListCustomersPurchase(BaseModel): @@ -640,9 +667,12 @@ class ListCustomersPurchase(BaseModel): plan: Optional[Plan] = None + scope: Optional[ListCustomersPurchaseScope] = None + r"""Whether this purchase is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set(["expires_at"]) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/listentitiesop.py b/others/python-sdk/src/autumn_sdk/models/listentitiesop.py index b2cdd998a..dfbd606cf 100644 --- a/others/python-sdk/src/autumn_sdk/models/listentitiesop.py +++ b/others/python-sdk/src/autumn_sdk/models/listentitiesop.py @@ -173,6 +173,16 @@ ListEntitiesStatus = Union[ r"""Current status of the subscription.""" +ListEntitiesSubscriptionScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this subscription is attached at the customer level or entity level.""" + + class ListEntitiesSubscriptionTypedDict(TypedDict): id: str r"""The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.""" @@ -201,6 +211,8 @@ class ListEntitiesSubscriptionTypedDict(TypedDict): quantity: float r"""Number of units of this subscription (for per-seat plans).""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[ListEntitiesSubscriptionScope] + r"""Whether this subscription is attached at the customer level or entity level.""" class ListEntitiesSubscription(BaseModel): @@ -245,9 +257,12 @@ class ListEntitiesSubscription(BaseModel): plan: Optional[Plan] = None + scope: Optional[ListEntitiesSubscriptionScope] = None + r"""Whether this subscription is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set( [ "canceled_at", @@ -279,6 +294,16 @@ class ListEntitiesSubscription(BaseModel): return m +ListEntitiesPurchaseScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this purchase is attached at the customer level or entity level.""" + + class ListEntitiesPurchaseTypedDict(TypedDict): plan_id: str r"""The unique identifier of the purchased plan.""" @@ -289,6 +314,8 @@ class ListEntitiesPurchaseTypedDict(TypedDict): quantity: float r"""Number of units purchased.""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[ListEntitiesPurchaseScope] + r"""Whether this purchase is attached at the customer level or entity level.""" class ListEntitiesPurchase(BaseModel): @@ -306,9 +333,12 @@ class ListEntitiesPurchase(BaseModel): plan: Optional[Plan] = None + scope: Optional[ListEntitiesPurchaseScope] = None + r"""Whether this purchase is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set(["expires_at"]) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/multiattachop.py b/others/python-sdk/src/autumn_sdk/models/multiattachop.py index 98621cdbe..ece0d519e 100644 --- a/others/python-sdk/src/autumn_sdk/models/multiattachop.py +++ b/others/python-sdk/src/autumn_sdk/models/multiattachop.py @@ -630,6 +630,10 @@ class MultiAttachInvoiceModeTypedDict(TypedDict): r"""If true, enables the plan immediately even though the invoice is not paid yet.""" finalize: NotRequired[bool] r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: NotRequired[str] + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + net_terms_days: NotRequired[int] + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" class MultiAttachInvoiceMode(BaseModel): @@ -644,9 +648,22 @@ class MultiAttachInvoiceMode(BaseModel): finalize: Optional[bool] = True r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: Optional[str] = None + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + + net_terms_days: Optional[int] = None + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["enable_plan_immediately", "finalize"]) + optional_fields = set( + [ + "enable_plan_immediately", + "finalize", + "invoice_template_id", + "net_terms_days", + ] + ) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/previewattachop.py b/others/python-sdk/src/autumn_sdk/models/previewattachop.py index 9daad94ae..b43973c40 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewattachop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewattachop.py @@ -995,6 +995,10 @@ class PreviewAttachInvoiceModeTypedDict(TypedDict): r"""If true, enables the plan immediately even though the invoice is not paid yet.""" finalize: NotRequired[bool] r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: NotRequired[str] + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + net_terms_days: NotRequired[int] + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" class PreviewAttachInvoiceMode(BaseModel): @@ -1009,9 +1013,22 @@ class PreviewAttachInvoiceMode(BaseModel): finalize: Optional[bool] = True r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: Optional[str] = None + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + + net_terms_days: Optional[int] = None + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["enable_plan_immediately", "finalize"]) + optional_fields = set( + [ + "enable_plan_immediately", + "finalize", + "invoice_template_id", + "net_terms_days", + ] + ) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/previewmultiattachop.py b/others/python-sdk/src/autumn_sdk/models/previewmultiattachop.py index 3a0561ab8..3af175b0b 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewmultiattachop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewmultiattachop.py @@ -635,6 +635,10 @@ class PreviewMultiAttachInvoiceModeTypedDict(TypedDict): r"""If true, enables the plan immediately even though the invoice is not paid yet.""" finalize: NotRequired[bool] r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: NotRequired[str] + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + net_terms_days: NotRequired[int] + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" class PreviewMultiAttachInvoiceMode(BaseModel): @@ -649,9 +653,22 @@ class PreviewMultiAttachInvoiceMode(BaseModel): finalize: Optional[bool] = True r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: Optional[str] = None + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + + net_terms_days: Optional[int] = None + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["enable_plan_immediately", "finalize"]) + optional_fields = set( + [ + "enable_plan_immediately", + "finalize", + "invoice_template_id", + "net_terms_days", + ] + ) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/previewupdateop.py b/others/python-sdk/src/autumn_sdk/models/previewupdateop.py index a2336686f..dd5c8717d 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewupdateop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewupdateop.py @@ -995,6 +995,10 @@ class PreviewUpdateInvoiceModeTypedDict(TypedDict): r"""If true, enables the plan immediately even though the invoice is not paid yet.""" finalize: NotRequired[bool] r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: NotRequired[str] + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + net_terms_days: NotRequired[int] + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" class PreviewUpdateInvoiceMode(BaseModel): @@ -1009,9 +1013,22 @@ class PreviewUpdateInvoiceMode(BaseModel): finalize: Optional[bool] = True r"""If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.""" + invoice_template_id: Optional[str] = None + r"""ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.""" + + net_terms_days: Optional[int] = None + r"""Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["enable_plan_immediately", "finalize"]) + optional_fields = set( + [ + "enable_plan_immediately", + "finalize", + "invoice_template_id", + "net_terms_days", + ] + ) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/syncrevenuecatop.py b/others/python-sdk/src/autumn_sdk/models/syncrevenuecatop.py new file mode 100644 index 000000000..7d12a3d78 --- /dev/null +++ b/others/python-sdk/src/autumn_sdk/models/syncrevenuecatop.py @@ -0,0 +1,206 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from __future__ import annotations +from autumn_sdk.types import BaseModel, UNSET_SENTINEL, UnrecognizedStr +from autumn_sdk.utils import FieldMetadata, HeaderMetadata +import pydantic +from pydantic import model_serializer +from typing import List, Literal, Optional, Union +from typing_extensions import Annotated, NotRequired, TypedDict + + +class SyncRevenueCatGlobalsTypedDict(TypedDict): + x_api_version: NotRequired[str] + + +class SyncRevenueCatGlobals(BaseModel): + x_api_version: Annotated[ + Optional[str], + pydantic.Field(alias="x-api-version"), + FieldMetadata(header=HeaderMetadata(style="simple", explode=False)), + ] = "2.3.0" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["x-api-version"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m + + +SyncRevenueCatEnv = Literal[ + "test", + "sandbox", + "live", +] +r"""\"test\" and \"sandbox\" both target the sandbox environment""" + + +class SyncRevenueCatParamsTypedDict(TypedDict): + organization_slug: str + env: SyncRevenueCatEnv + r"""\"test\" and \"sandbox\" both target the sandbox environment""" + product_ids: NotRequired[List[str]] + r"""Plans to push. Omit to sync every plan in the org/env.""" + + +class SyncRevenueCatParams(BaseModel): + organization_slug: str + + env: SyncRevenueCatEnv + r"""\"test\" and \"sandbox\" both target the sandbox environment""" + + product_ids: Optional[List[str]] = None + r"""Plans to push. Omit to sync every plan in the org/env.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["product_ids"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m + + +SyncRevenueCatStatus = Union[ + Literal[ + "synced", + "skipped", + "error", + ], + UnrecognizedStr, +] + + +SyncRevenueCatProduct = Union[ + Literal[ + "created", + "updated", + "exists", + ], + UnrecognizedStr, +] + + +StorePush = Union[ + Literal[ + "pushed", + "failed", + "skipped", + ], + UnrecognizedStr, +] + + +SyncRevenueCatPrice = Union[ + Literal[ + "set", + "skipped", + "failed", + ], + UnrecognizedStr, +] + + +class SyncRevenueCatAppTypedDict(TypedDict): + app_id: str + app_type: str + product: SyncRevenueCatProduct + store_push: NotRequired[StorePush] + price: NotRequired[SyncRevenueCatPrice] + message: NotRequired[str] + + +class SyncRevenueCatApp(BaseModel): + app_id: str + + app_type: str + + product: SyncRevenueCatProduct + + store_push: Optional[StorePush] = None + + price: Optional[SyncRevenueCatPrice] = None + + message: Optional[str] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["store_push", "price", "message"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m + + +class ResultTypedDict(TypedDict): + plan_id: str + status: SyncRevenueCatStatus + store_identifier: NotRequired[str] + apps: NotRequired[List[SyncRevenueCatAppTypedDict]] + message: NotRequired[str] + + +class Result(BaseModel): + plan_id: str + + status: SyncRevenueCatStatus + + store_identifier: Optional[str] = None + + apps: Optional[List[SyncRevenueCatApp]] = None + + message: Optional[str] = None + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["store_identifier", "apps", "message"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m + + +class SyncRevenueCatResponseTypedDict(TypedDict): + r"""OK""" + + results: List[ResultTypedDict] + + +class SyncRevenueCatResponse(BaseModel): + r"""OK""" + + results: List[Result] diff --git a/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py b/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py index 63e06deec..0ab9bc333 100644 --- a/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py +++ b/others/python-sdk/src/autumn_sdk/models/updatecustomerop.py @@ -800,6 +800,16 @@ UpdateCustomerStatus = Union[ r"""Current status of the subscription.""" +UpdateCustomerSubscriptionScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this subscription is attached at the customer level or entity level.""" + + class UpdateCustomerSubscriptionTypedDict(TypedDict): id: str r"""The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.""" @@ -828,6 +838,8 @@ class UpdateCustomerSubscriptionTypedDict(TypedDict): quantity: float r"""Number of units of this subscription (for per-seat plans).""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[UpdateCustomerSubscriptionScope] + r"""Whether this subscription is attached at the customer level or entity level.""" class UpdateCustomerSubscription(BaseModel): @@ -872,9 +884,12 @@ class UpdateCustomerSubscription(BaseModel): plan: Optional[Plan] = None + scope: Optional[UpdateCustomerSubscriptionScope] = None + r"""Whether this subscription is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set( [ "canceled_at", @@ -906,6 +921,16 @@ class UpdateCustomerSubscription(BaseModel): return m +UpdateCustomerPurchaseScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this purchase is attached at the customer level or entity level.""" + + class UpdateCustomerPurchaseTypedDict(TypedDict): plan_id: str r"""The unique identifier of the purchased plan.""" @@ -916,6 +941,8 @@ class UpdateCustomerPurchaseTypedDict(TypedDict): quantity: float r"""Number of units purchased.""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[UpdateCustomerPurchaseScope] + r"""Whether this purchase is attached at the customer level or entity level.""" class UpdateCustomerPurchase(BaseModel): @@ -933,9 +960,12 @@ class UpdateCustomerPurchase(BaseModel): plan: Optional[Plan] = None + scope: Optional[UpdateCustomerPurchaseScope] = None + r"""Whether this purchase is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set(["expires_at"]) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/models/updateentityop.py b/others/python-sdk/src/autumn_sdk/models/updateentityop.py index 80fe86883..5a828ceff 100644 --- a/others/python-sdk/src/autumn_sdk/models/updateentityop.py +++ b/others/python-sdk/src/autumn_sdk/models/updateentityop.py @@ -264,6 +264,16 @@ UpdateEntityStatus = Union[ r"""Current status of the subscription.""" +UpdateEntitySubscriptionScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this subscription is attached at the customer level or entity level.""" + + class UpdateEntitySubscriptionTypedDict(TypedDict): id: str r"""The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.""" @@ -292,6 +302,8 @@ class UpdateEntitySubscriptionTypedDict(TypedDict): quantity: float r"""Number of units of this subscription (for per-seat plans).""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[UpdateEntitySubscriptionScope] + r"""Whether this subscription is attached at the customer level or entity level.""" class UpdateEntitySubscription(BaseModel): @@ -336,9 +348,12 @@ class UpdateEntitySubscription(BaseModel): plan: Optional[Plan] = None + scope: Optional[UpdateEntitySubscriptionScope] = None + r"""Whether this subscription is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set( [ "canceled_at", @@ -370,6 +385,16 @@ class UpdateEntitySubscription(BaseModel): return m +UpdateEntityPurchaseScope = Union[ + Literal[ + "customer", + "entity", + ], + UnrecognizedStr, +] +r"""Whether this purchase is attached at the customer level or entity level.""" + + class UpdateEntityPurchaseTypedDict(TypedDict): plan_id: str r"""The unique identifier of the purchased plan.""" @@ -380,6 +405,8 @@ class UpdateEntityPurchaseTypedDict(TypedDict): quantity: float r"""Number of units purchased.""" plan: NotRequired[PlanTypedDict] + scope: NotRequired[UpdateEntityPurchaseScope] + r"""Whether this purchase is attached at the customer level or entity level.""" class UpdateEntityPurchase(BaseModel): @@ -397,9 +424,12 @@ class UpdateEntityPurchase(BaseModel): plan: Optional[Plan] = None + scope: Optional[UpdateEntityPurchaseScope] = None + r"""Whether this purchase is attached at the customer level or entity level.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["plan"]) + optional_fields = set(["plan", "scope"]) nullable_fields = set(["expires_at"]) serialized = handler(self) m = {} diff --git a/others/python-sdk/src/autumn_sdk/platform.py b/others/python-sdk/src/autumn_sdk/platform.py new file mode 100644 index 000000000..9a76e9815 --- /dev/null +++ b/others/python-sdk/src/autumn_sdk/platform.py @@ -0,0 +1,586 @@ +"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + +from .basesdk import BaseSDK +from autumn_sdk import errors, models, utils +from autumn_sdk._hooks import HookContext +from autumn_sdk.types import OptionalNullable, UNSET +from autumn_sdk.utils.unmarshal_json_response import unmarshal_json_response +from typing import List, Mapping, Optional + + +class Platform(BaseSDK): + def link_revenue_cat( + self, + *, + organization_slug: str, + env: models.LinkRevenueCatEnv, + project_name: str, + redirect_url: str, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.LinkRevenueCatResponse: + r"""Generate a RevenueCat OAuth URL for linking a project to an organization. + + :param organization_slug: + :param env: + :param project_name: + :param redirect_url: + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.LinkRevenueCatParams( + organization_slug=organization_slug, + env=env, + project_name=project_name, + redirect_url=redirect_url, + ) + + req = self._build_request( + method="POST", + path="/v1/platform.link_revenuecat", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + _globals=models.LinkRevenueCatGlobals( + x_api_version=self.sdk_configuration.globals.x_api_version, + ), + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.LinkRevenueCatParams + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="linkRevenueCat", + oauth2_scopes=None, + security_source=self.sdk_configuration.security, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.LinkRevenueCatResponse, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + + raise errors.AutumnDefaultError("Unexpected response received", http_res) + + async def link_revenue_cat_async( + self, + *, + organization_slug: str, + env: models.LinkRevenueCatEnv, + project_name: str, + redirect_url: str, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.LinkRevenueCatResponse: + r"""Generate a RevenueCat OAuth URL for linking a project to an organization. + + :param organization_slug: + :param env: + :param project_name: + :param redirect_url: + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.LinkRevenueCatParams( + organization_slug=organization_slug, + env=env, + project_name=project_name, + redirect_url=redirect_url, + ) + + req = self._build_request_async( + method="POST", + path="/v1/platform.link_revenuecat", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + _globals=models.LinkRevenueCatGlobals( + x_api_version=self.sdk_configuration.globals.x_api_version, + ), + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.LinkRevenueCatParams + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="linkRevenueCat", + oauth2_scopes=None, + security_source=self.sdk_configuration.security, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.LinkRevenueCatResponse, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + + raise errors.AutumnDefaultError("Unexpected response received", http_res) + + def sync_revenue_cat( + self, + *, + organization_slug: str, + env: models.SyncRevenueCatEnv, + product_ids: Optional[List[str]] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.SyncRevenueCatResponse: + r"""Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth. + + :param organization_slug: + :param env: \"test\" and \"sandbox\" both target the sandbox environment + :param product_ids: Plans to push. Omit to sync every plan in the org/env. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.SyncRevenueCatParams( + organization_slug=organization_slug, + env=env, + product_ids=product_ids, + ) + + req = self._build_request( + method="POST", + path="/v1/platform.sync_revenuecat", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + _globals=models.SyncRevenueCatGlobals( + x_api_version=self.sdk_configuration.globals.x_api_version, + ), + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.SyncRevenueCatParams + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="syncRevenueCat", + oauth2_scopes=None, + security_source=self.sdk_configuration.security, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.SyncRevenueCatResponse, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + + raise errors.AutumnDefaultError("Unexpected response received", http_res) + + async def sync_revenue_cat_async( + self, + *, + organization_slug: str, + env: models.SyncRevenueCatEnv, + product_ids: Optional[List[str]] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.SyncRevenueCatResponse: + r"""Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth. + + :param organization_slug: + :param env: \"test\" and \"sandbox\" both target the sandbox environment + :param product_ids: Plans to push. Omit to sync every plan in the org/env. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.SyncRevenueCatParams( + organization_slug=organization_slug, + env=env, + product_ids=product_ids, + ) + + req = self._build_request_async( + method="POST", + path="/v1/platform.sync_revenuecat", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + _globals=models.SyncRevenueCatGlobals( + x_api_version=self.sdk_configuration.globals.x_api_version, + ), + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.SyncRevenueCatParams + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="syncRevenueCat", + oauth2_scopes=None, + security_source=self.sdk_configuration.security, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.SyncRevenueCatResponse, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + + raise errors.AutumnDefaultError("Unexpected response received", http_res) + + def get_revenue_cat_keys( + self, + *, + organization_slug: str, + env: models.GetRevenueCatKeysEnv, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.GetRevenueCatKeysResponse: + r"""Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app. + + :param organization_slug: + :param env: \"test\" and \"sandbox\" both target the sandbox environment + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.GetRevenueCatKeysParams( + organization_slug=organization_slug, + env=env, + ) + + req = self._build_request( + method="POST", + path="/v1/platform.get_revenuecat_keys", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + _globals=models.GetRevenueCatKeysGlobals( + x_api_version=self.sdk_configuration.globals.x_api_version, + ), + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.GetRevenueCatKeysParams + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="getRevenueCatKeys", + oauth2_scopes=None, + security_source=self.sdk_configuration.security, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.GetRevenueCatKeysResponse, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + + raise errors.AutumnDefaultError("Unexpected response received", http_res) + + async def get_revenue_cat_keys_async( + self, + *, + organization_slug: str, + env: models.GetRevenueCatKeysEnv, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.GetRevenueCatKeysResponse: + r"""Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app. + + :param organization_slug: + :param env: \"test\" and \"sandbox\" both target the sandbox environment + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(base_url, url_variables) + + request = models.GetRevenueCatKeysParams( + organization_slug=organization_slug, + env=env, + ) + + req = self._build_request_async( + method="POST", + path="/v1/platform.get_revenuecat_keys", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + _globals=models.GetRevenueCatKeysGlobals( + x_api_version=self.sdk_configuration.globals.x_api_version, + ), + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.GetRevenueCatKeysParams + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="getRevenueCatKeys", + oauth2_scopes=None, + security_source=self.sdk_configuration.security, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.GetRevenueCatKeysResponse, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.AutumnDefaultError( + "API error occurred", http_res, http_res_text + ) + + raise errors.AutumnDefaultError("Unexpected response received", http_res) diff --git a/others/python-sdk/src/autumn_sdk/sdk.py b/others/python-sdk/src/autumn_sdk/sdk.py index 19d77062e..8feede30c 100644 --- a/others/python-sdk/src/autumn_sdk/sdk.py +++ b/others/python-sdk/src/autumn_sdk/sdk.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from autumn_sdk.events import Events from autumn_sdk.features import Features from autumn_sdk.plans import Plans + from autumn_sdk.platform import Platform from autumn_sdk.referrals import Referrals from autumn_sdk.rewards_sdk import RewardsSDK @@ -48,6 +49,7 @@ class Autumn(BaseSDK): entities: "Entities" referrals: "Referrals" rewards: "RewardsSDK" + platform: "Platform" _sub_sdk_map = { "customers": ("autumn_sdk.customers", "Customers"), "plans": ("autumn_sdk.plans", "Plans"), @@ -58,6 +60,7 @@ class Autumn(BaseSDK): "entities": ("autumn_sdk.entities", "Entities"), "referrals": ("autumn_sdk.referrals", "Referrals"), "rewards": ("autumn_sdk.rewards_sdk", "RewardsSDK"), + "platform": ("autumn_sdk.platform", "Platform"), } def __init__( diff --git a/package.json b/package.json index 4358e5570..4f1ec1338 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "apps/checkout", "apps/docs", "apps/website", - "apps/mcp-server", + "apps/leaf", "apps/sdk-test", "packages/atmn", "packages/atmn-tests", + "packages/auth", + "packages/logging", "packages/mcp", "packages/sdk", "packages/autumn-js", @@ -49,9 +51,7 @@ } }, "overrides": { - "@better-auth/core": "1.6.5", "@better-auth/passkey": "1.6.5", - "better-auth": "1.6.5", "@modelcontextprotocol/sdk": "1.29.0", "@isaacs/brace-expansion": "5.0.1", "fast-xml-parser": "5.3.4", @@ -79,7 +79,7 @@ "type": "module", "scripts": { "dev": "bun scripts/dev.ts", - "dev:services": "bun scripts/devServices/index.ts", + "dev:services": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/devServices/index.ts", "vite:build": "bun -F @autumn/vite build:bun", "t": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/testScripts/testDispatcher.ts", "cm": "cd server && bun cm", @@ -92,6 +92,7 @@ "dw:run": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts run", "dw:enable": "bun scripts/dw/index.ts enable", "dw:disable": "bun scripts/dw/index.ts disable", + "dw:admin": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts admin", "d": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dev.ts", "d:prod": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dev.ts --production", "dx": "bun scripts/dx.ts", @@ -113,6 +114,10 @@ "tb": "bun scripts/tinybird/index.ts", "tb:prod": "bun scripts/tinybird/index.ts prod", "tb:prod-legacy": "bun scripts/tinybird/index.ts prod-legacy", + "axiom": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/axiom/cli.ts", + "axiom:prod": "ENV_FILE=.env.prod infisical run --env=prod --recursive -- bun scripts/axiom/cli.ts", + "slack": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/slack/index.ts", + "add-mcp": "bun scripts/mcp/addMcp.ts", "trigger:deploy": "bunx trigger.dev deploy", "setupci": "node scripts/setup/setupci.js", "replicate": "bun scripts/db/replicate.ts", @@ -126,14 +131,14 @@ "knip:fix-all": "knip --fix --allow-remove-files", "prepare": "husky", "api": "cd packages/openapi && bun generate", - "mcp": "bun scripts/mcp.ts", "svix:push": "infisical run --env=dev --recursive -- bun packages/openapi/scripts/svixPush.ts", "svix:push:prod": "infisical run --env=prod --recursive -- bun packages/openapi/scripts/svixPush.ts", "docs:pull": "bun -F @autumn/docs pull", + "leaf": "bun -F @autumn/leaf dev", "site": "cd apps/website && bun dev && cd ../..", "docs": "bun -F @autumn/docs dev", "docs:build": "bun -F @autumn/docs build", -"ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@useautumn/ai-sdk --filter=@autumn/mcp --filter=@autumn/mcp-server", + "ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@useautumn/ai-sdk --filter=@autumn/auth --filter=@autumn/mcp --filter=@autumn/leaf", "kill:ts": "while pgrep -f tsgo > /dev/null; do pkill -9 -f tsgo; sleep 0.1; done", "atmn:build": "bun -F atmn build", "openapi:ts": "bun -F @autumn/openapi ts", diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index f8adf149d..6907b1fb9 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -20,9 +20,13 @@ }, "scripts": { "ts": "tsgo --noEmit --skipLibCheck", + "test": "bun test tests/unit", "build": "rm -rf dist && tsup", "prepublishOnly": "bun run build" }, + "dependencies": { + "@ai-sdk/provider": "^3.0.0" + }, "peerDependencies": { "ai": "^6.0.116", "autumn-js": "*" diff --git a/packages/ai-sdk/src/index.ts b/packages/ai-sdk/src/index.ts index ce2f5a9ff..e786c48be 100644 --- a/packages/ai-sdk/src/index.ts +++ b/packages/ai-sdk/src/index.ts @@ -1,50 +1,39 @@ -import type { LanguageModelV3, LanguageModelV3Usage } from "@ai-sdk/provider"; -import { - type LanguageModelMiddleware, - type LanguageModelUsage, - wrapLanguageModel, -} from "ai"; -import type { Autumn } from "autumn-js"; +import type { LanguageModelV3 } from "@ai-sdk/provider"; +import { type LanguageModelMiddleware, wrapLanguageModel } from "ai"; +import { normalizeUsage, type TokenPools, type UsageLike } from "./usage.js"; -// Standalone published package: must not import from the internal @autumn/shared workspace. -const PROVIDER_SEPARATOR = "/"; +export type { TokenPools, UsageLike } from "./usage.js"; -type NestedCount = { total?: number | null } | null; - -/** - * Lenient view over the AI SDK usage shapes we accept: the nested - * `LanguageModelV3Usage`, the flat `ai` `LanguageModelUsage` (with token details), and - * legacy `promptTokens`/`completionTokens` objects. - */ -type AnyUsage = (LanguageModelV3Usage | LanguageModelUsage) & { - promptTokens?: number | NestedCount; - completionTokens?: number | NestedCount; - inputTokenDetails?: { - noCacheTokens?: number | null; - cacheReadTokens?: number | null; - cacheWriteTokens?: number | null; - } | null; - outputTokenDetails?: { - textTokens?: number | null; - reasoningTokens?: number | null; - } | null; - cachedInputTokens?: number | null; - reasoningTokens?: number | null; +type TrackTokensParams = TokenPools & { + customerId: string; + modelId: string; + featureId?: string; + entityId?: string; + properties?: Record; }; -type ExclusivePools = { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - reasoningTokens: number; +/** Structural view of the autumn-js client; older versions may not ship balances.trackTokens. */ +export type AutumnClient = { + balances?: { + trackTokens?: (params: TrackTokensParams) => Promise; + }; }; -const flatCount = ( - value: number | NestedCount | undefined, -): number | undefined => { - if (typeof value === "number") return value; - return value?.total ?? undefined; +export type WithAutumnOptions = { + /** Autumn SDK client instance. */ + autumn: AutumnClient; + /** The AI SDK language model to wrap. */ + model: LanguageModelV3; + /** The Autumn customer ID to attribute usage to. */ + customerId: string; + /** Override the provider prefix used in the model name (e.g. "openrouter", "custom"). Falls back to `model.provider`. */ + providerId?: string; + /** Target a specific AI credit system feature. Auto-detected if omitted. */ + featureId?: string; + /** Entity ID for entity-scoped balance tracking. */ + entityId?: string; + /** Additional properties to attach to each usage event. */ + properties?: Record; }; export const withAutumn = ({ @@ -55,103 +44,21 @@ export const withAutumn = ({ featureId, entityId, properties, -}: { - /** Autumn SDK client instance. */ - autumn: Autumn; - /** The AI SDK language model to wrap. */ - model: LanguageModelV3; - /** The Autumn customer ID to attribute usage to. */ - customerId: string; - /** Override the provider prefix used in the model name. Falls back to `model.provider`. */ - providerId?: "custom" | string; - /** Target a specific AI credit system feature. Auto-detected if omitted. */ - featureId?: string; - /** Entity ID for entity-scoped balance tracking. */ - entityId?: string; - /** Additional properties to attach to the usage event. */ - properties?: Record; -}) => { - const provider = providerId ?? model.provider; - const modelName = `${provider}${PROVIDER_SEPARATOR}${model.modelId}`; +}: WithAutumnOptions): LanguageModelV3 => { + const modelName = `${providerId ?? model.provider}/${model.modelId}`; - const required = (value: number | undefined, label: string): number => { - if (value == null) { - throw new Error( - `[Autumn] ${label} token usage was not returned by the model provider (${modelName}). This provider may not support usage tracking.`, - ); - } - return value; - }; - - const normalizeUsage = (usage: AnyUsage): ExclusivePools => { - const input = usage.inputTokens; - const output = usage.outputTokens; - - if (input != null && typeof input === "object") { - const cacheReadTokens = input.cacheRead ?? 0; - const cacheWriteTokens = input.cacheWrite ?? 0; - const textInput = - input.noCache ?? - (input.total != null - ? input.total - cacheReadTokens - cacheWriteTokens - : undefined); - const out = typeof output === "object" ? output : null; - const reasoningTokens = out?.reasoning ?? 0; - const textOutput = - out?.text ?? - (out?.total != null ? out.total - reasoningTokens : undefined); - return { - inputTokens: required(textInput, "Input"), - outputTokens: required(textOutput, "Output"), - cacheReadTokens: Math.max(0, cacheReadTokens), - cacheWriteTokens: Math.max(0, cacheWriteTokens), - reasoningTokens: Math.max(0, reasoningTokens), - }; - } - - const inputDetails = usage.inputTokenDetails; - const outputDetails = usage.outputTokenDetails; - const cacheReadTokens = - inputDetails?.cacheReadTokens ?? usage.cachedInputTokens ?? 0; - const cacheWriteTokens = inputDetails?.cacheWriteTokens ?? 0; - const reasoningTokens = - outputDetails?.reasoningTokens ?? usage.reasoningTokens ?? 0; - - const rawInput = - typeof input === "number" ? input : flatCount(usage.promptTokens); - const textInput = - inputDetails?.noCacheTokens ?? - (rawInput != null - ? rawInput - cacheReadTokens - cacheWriteTokens - : undefined); - - const rawOutput = - typeof output === "number" ? output : flatCount(usage.completionTokens); - const textOutput = - outputDetails?.textTokens ?? - (rawOutput != null ? rawOutput - reasoningTokens : undefined); - - return { - inputTokens: Math.max(0, required(textInput, "Input")), - outputTokens: Math.max(0, required(textOutput, "Output")), - cacheReadTokens: Math.max(0, cacheReadTokens), - cacheWriteTokens: Math.max(0, cacheWriteTokens), - reasoningTokens: Math.max(0, reasoningTokens), - }; - }; - - const trackUsage = async (usage: AnyUsage) => { + const trackUsage = async (usage: UsageLike) => { try { - const pools = normalizeUsage(usage); - // @ts-expect-error trackTokens is generated from OpenAPI; local autumn-js types may not include it yet. - await autumn.balances.trackTokens({ + const trackTokens = autumn.balances?.trackTokens; + if (!trackTokens) { + throw new Error( + "autumn-js client does not support balances.trackTokens — upgrade autumn-js.", + ); + } + await trackTokens({ + ...normalizeUsage(usage, modelName), customerId, modelId: modelName, - inputTokens: pools.inputTokens, - outputTokens: pools.outputTokens, - cacheReadTokens: pools.cacheReadTokens, - cacheWriteTokens: pools.cacheWriteTokens, - reasoningTokens: pools.reasoningTokens, featureId, entityId, properties, @@ -165,7 +72,7 @@ export const withAutumn = ({ specificationVersion: "v3", wrapGenerate: async ({ doGenerate }) => { const result = await doGenerate(); - await trackUsage(result.usage as AnyUsage); + await trackUsage(result.usage as UsageLike); return result; }, wrapStream: async ({ doStream }) => { @@ -180,14 +87,12 @@ export const withAutumn = ({ const transformStream = new TransformStream({ transform(chunk, controller) { if (chunk.type === "finish" && chunk.usage) { - trackingPromise = trackUsage(chunk.usage as AnyUsage); + trackingPromise = trackUsage(chunk.usage as UsageLike); } controller.enqueue(chunk); }, async flush() { - if (trackingPromise) { - await trackingPromise; - } + await trackingPromise; }, }); @@ -198,5 +103,5 @@ export const withAutumn = ({ }, }; - return wrapLanguageModel({ model: model, middleware }); + return wrapLanguageModel({ model, middleware }); }; diff --git a/packages/ai-sdk/src/usage.ts b/packages/ai-sdk/src/usage.ts new file mode 100644 index 000000000..eb73be51a --- /dev/null +++ b/packages/ai-sdk/src/usage.ts @@ -0,0 +1,117 @@ +type NestedTokens = { + total?: number | null; + noCache?: number | null; + cacheRead?: number | null; + cacheWrite?: number | null; + text?: number | null; + reasoning?: number | null; +}; + +type LegacyCount = number | { total?: number | null } | null; + +/** Lenient view over AI SDK usage shapes: nested V3 counts, flat counts with token details, and legacy prompt/completion counts. */ +export type UsageLike = { + inputTokens?: number | NestedTokens | null; + outputTokens?: number | NestedTokens | null; + promptTokens?: LegacyCount; + completionTokens?: LegacyCount; + inputTokenDetails?: { + noCacheTokens?: number | null; + cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; + } | null; + outputTokenDetails?: { + textTokens?: number | null; + reasoningTokens?: number | null; + } | null; + cachedInputTokens?: number | null; + reasoningTokens?: number | null; +}; + +export type TokenPools = { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; +}; + +const flatCount = (value: LegacyCount | undefined): number | undefined => + typeof value === "number" ? value : (value?.total ?? undefined); + +const isNested = ( + value: number | NestedTokens | null | undefined, +): value is NestedTokens => value != null && typeof value === "object"; + +const toParts = (usage: UsageLike) => { + const input = usage.inputTokens; + const output = usage.outputTokens; + + if (isNested(input)) { + const out = isNested(output) ? output : undefined; + return { + cacheRead: input.cacheRead ?? 0, + cacheWrite: input.cacheWrite ?? 0, + reasoning: out?.reasoning ?? 0, + textInput: input.noCache, + totalInput: input.total, + textOutput: out?.text, + totalOutput: out?.total, + }; + } + + return { + cacheRead: + usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens ?? 0, + cacheWrite: usage.inputTokenDetails?.cacheWriteTokens ?? 0, + reasoning: + usage.outputTokenDetails?.reasoningTokens ?? usage.reasoningTokens ?? 0, + textInput: usage.inputTokenDetails?.noCacheTokens, + totalInput: + typeof input === "number" ? input : flatCount(usage.promptTokens), + textOutput: usage.outputTokenDetails?.textTokens, + totalOutput: + typeof output === "number" ? output : flatCount(usage.completionTokens), + }; +}; + +const clamp = (value: number) => Math.max(0, value); + +/** Splits provider usage into exclusive token pools; throws if the provider returned no usable counts. */ +export const normalizeUsage = ( + usage: UsageLike, + modelName: string, +): TokenPools => { + const parts = toParts(usage); + + const required = ( + value: number | null | undefined, + label: string, + ): number => { + if (value == null) { + throw new Error( + `[Autumn] ${label} token usage was not returned by the model provider (${modelName}). This provider may not support usage tracking.`, + ); + } + return value; + }; + + const textInput = + parts.textInput ?? + (parts.totalInput != null + ? parts.totalInput - parts.cacheRead - parts.cacheWrite + : undefined); + const textOutput = + parts.textOutput ?? + (parts.totalOutput != null + ? parts.totalOutput - parts.reasoning + : undefined); + + return { + inputTokens: clamp(required(textInput, "Input")), + outputTokens: clamp(required(textOutput, "Output")), + cacheReadTokens: clamp(parts.cacheRead), + cacheWriteTokens: clamp(parts.cacheWrite), + reasoningTokens: clamp(parts.reasoning), + }; +}; diff --git a/packages/ai-sdk/tests/unit/index.test.ts b/packages/ai-sdk/tests/unit/index.test.ts new file mode 100644 index 000000000..fd28f5863 --- /dev/null +++ b/packages/ai-sdk/tests/unit/index.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import type { LanguageModelV3, LanguageModelV3Usage } from "@ai-sdk/provider"; +import { generateText, streamText } from "ai"; +import { withAutumn } from "../../src/index.js"; + +type TrackTokensParams = { + customerId: string; + modelId: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; + featureId?: string; + entityId?: string; + properties?: Record; +}; + +const usage: LanguageModelV3Usage = { + inputTokens: { + total: 13, + noCache: 10, + cacheRead: 2, + cacheWrite: 1, + }, + outputTokens: { + total: 7, + text: 5, + reasoning: 2, + }, +}; + +const finishReason = { unified: "stop" as const, raw: "stop" }; + +const createAutumn = () => { + const calls: TrackTokensParams[] = []; + + return { + calls, + autumn: { + balances: { + trackTokens: async (params: TrackTokensParams) => { + calls.push(params); + }, + }, + }, + }; +}; + +const createModel = (): LanguageModelV3 => ({ + specificationVersion: "v3", + provider: "openai", + modelId: "gpt-test", + supportedUrls: {}, + async doGenerate() { + return { + content: [{ type: "text", text: "hello" }], + finishReason, + usage, + warnings: [], + }; + }, + async doStream() { + return { + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: "text-start", id: "text-1" }); + controller.enqueue({ + type: "text-delta", + id: "text-1", + delta: "hello", + }); + controller.enqueue({ type: "text-end", id: "text-1" }); + controller.enqueue({ type: "finish", finishReason, usage }); + controller.close(); + }, + }), + }; + }, +}); + +describe("withAutumn", () => { + test("tracks token usage from generateText", async () => { + const { autumn, calls } = createAutumn(); + + const model = withAutumn({ + autumn, + model: createModel(), + customerId: "cus_test", + featureId: "ai_credits", + entityId: "entity_test", + properties: { source: "test" }, + }); + + const result = await generateText({ model, prompt: "Say hello" }); + + expect(result.text).toBe("hello"); + expect(calls).toEqual([ + { + customerId: "cus_test", + modelId: "openai/gpt-test", + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 2, + cacheWriteTokens: 1, + reasoningTokens: 2, + featureId: "ai_credits", + entityId: "entity_test", + properties: { source: "test" }, + }, + ]); + }); + + test("tracks token usage from streamText when the stream finishes", async () => { + const { autumn, calls } = createAutumn(); + + const model = withAutumn({ + autumn, + model: createModel(), + customerId: "cus_stream", + providerId: "custom-openai", + }); + + const result = streamText({ model, prompt: "Say hello" }); + const chunks: string[] = []; + + for await (const chunk of result.textStream) { + chunks.push(chunk); + } + + expect(chunks.join("")).toBe("hello"); + expect(calls).toEqual([ + { + customerId: "cus_stream", + modelId: "custom-openai/gpt-test", + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 2, + cacheWriteTokens: 1, + reasoningTokens: 2, + }, + ]); + }); +}); diff --git a/packages/ai-sdk/tests/unit/usage.test.ts b/packages/ai-sdk/tests/unit/usage.test.ts new file mode 100644 index 000000000..132e39107 --- /dev/null +++ b/packages/ai-sdk/tests/unit/usage.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeUsage } from "../../src/usage.js"; + +const MODEL = "openai/gpt-test"; + +describe("normalizeUsage", () => { + test("nested V3 counts split into exclusive pools", () => { + expect( + normalizeUsage( + { + inputTokens: { total: 13, noCache: 10, cacheRead: 2, cacheWrite: 1 }, + outputTokens: { total: 7, text: 5, reasoning: 2 }, + }, + MODEL, + ), + ).toEqual({ + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 2, + cacheWriteTokens: 1, + reasoningTokens: 2, + }); + }); + + test("nested totals without breakdowns derive text pools", () => { + expect( + normalizeUsage( + { + inputTokens: { total: 13, cacheRead: 2, cacheWrite: 1 }, + outputTokens: { total: 7, reasoning: 2 }, + }, + MODEL, + ), + ).toEqual({ + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 2, + cacheWriteTokens: 1, + reasoningTokens: 2, + }); + }); + + test("flat counts with token details", () => { + expect( + normalizeUsage( + { + inputTokens: 13, + outputTokens: 7, + inputTokenDetails: { cacheReadTokens: 2, cacheWriteTokens: 1 }, + outputTokenDetails: { reasoningTokens: 2 }, + }, + MODEL, + ), + ).toEqual({ + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 2, + cacheWriteTokens: 1, + reasoningTokens: 2, + }); + }); + + test("legacy prompt/completion counts", () => { + expect( + normalizeUsage( + { promptTokens: 100, completionTokens: { total: 50 } }, + MODEL, + ), + ).toEqual({ + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + }); + }); + + test("inconsistent totals clamp to zero instead of going negative", () => { + const pools = normalizeUsage( + { + inputTokens: { total: 1, cacheRead: 5, cacheWrite: 0 }, + outputTokens: { total: 1, reasoning: 5 }, + }, + MODEL, + ); + expect(pools.inputTokens).toBe(0); + expect(pools.outputTokens).toBe(0); + }); + + test("missing usage throws with the model name", () => { + expect(() => normalizeUsage({}, MODEL)).toThrow(/gpt-test/); + }); +}); diff --git a/packages/atmn/src/commands/auth/constants.ts b/packages/atmn/src/commands/auth/constants.ts index b174d59a2..704a3f480 100644 --- a/packages/atmn/src/commands/auth/constants.ts +++ b/packages/atmn/src/commands/auth/constants.ts @@ -1,9 +1,10 @@ // OAuth constants for CLI authentication -/** The OAuth client ID for the CLI (public client) */ -// export const CLI_CLIENT_ID = "khicXGthBbGMIWmpgodOTDcCCJHJMDpN"; (local i think) -// export const CLI_CLIENT_ID = "NiKwaSyAfaeEEKEvFaUYihTXdTPtIRCk" (dev i think) -export const CLI_CLIENT_ID = "hAWUopQqLnsSwuRgeRzIBzKslwXmQUSr"; // (prod i think) +// Historical Better Auth OAuth clients for atmn CLI environments. +// Server auth should identify atmn from oauth_client metadata/name instead. +export const LOCAL_CLI_CLIENT_ID = "khicXGthBbGMIWmpgodOTDcCCJHJMDpN"; +export const DEV_CLI_CLIENT_ID = "NiKwaSyAfaeEEKEvFaUYihTXdTPtIRCk"; +export const CLI_CLIENT_ID = "hAWUopQqLnsSwuRgeRzIBzKslwXmQUSr"; /** Base port for the local OAuth callback server */ export const OAUTH_PORT_BASE = 31448; diff --git a/packages/atmn/src/commands/push/push.ts b/packages/atmn/src/commands/push/push.ts index fe3480669..a3384ce97 100644 --- a/packages/atmn/src/commands/push/push.ts +++ b/packages/atmn/src/commands/push/push.ts @@ -349,6 +349,32 @@ function normalizeFeatureForCompare(f: Feature): Record { })); } + if (f.type === "ai_credit_system") { + const ai = f as Extract; + if (ai.modelMarkups && Object.keys(ai.modelMarkups).length > 0) { + result.modelMarkups = Object.fromEntries( + Object.entries(ai.modelMarkups) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([modelId, entry]) => [ + modelId, + { + markup: entry.markup, + inputCost: entry.inputCost, + outputCost: entry.outputCost, + }, + ]), + ); + } + if (ai.defaultMarkup != null) result.defaultMarkup = ai.defaultMarkup; + if (ai.providerMarkups && Object.keys(ai.providerMarkups).length > 0) { + result.providerMarkups = Object.fromEntries( + Object.entries(ai.providerMarkups).sort(([a], [b]) => + a.localeCompare(b), + ), + ); + } + } + return result; } diff --git a/packages/atmn/src/lib/transforms/apiToSdk/feature.ts b/packages/atmn/src/lib/transforms/apiToSdk/feature.ts index e4ba94ffb..793025736 100644 --- a/packages/atmn/src/lib/transforms/apiToSdk/feature.ts +++ b/packages/atmn/src/lib/transforms/apiToSdk/feature.ts @@ -60,6 +60,8 @@ export const featureTransformer = createTransformer({ ...BASE_COMPUTE, type: () => "ai_credit_system" as const, modelMarkups: (api) => mapModelMarkups(api), + defaultMarkup: (api) => api.default_markup ?? undefined, + providerMarkups: (api) => api.provider_markups ?? undefined, }, }, diff --git a/packages/atmn/src/lib/transforms/sdkToApi/feature.ts b/packages/atmn/src/lib/transforms/sdkToApi/feature.ts index a67702701..d963639e3 100644 --- a/packages/atmn/src/lib/transforms/sdkToApi/feature.ts +++ b/packages/atmn/src/lib/transforms/sdkToApi/feature.ts @@ -12,10 +12,12 @@ export interface ApiFeatureParams { credit_cost: number; }>; model_markups?: Record; + default_markup?: number; + provider_markups?: Record; } export function transformFeatureToApi(feature: Feature): ApiFeatureParams { @@ -44,17 +46,25 @@ export function transformFeatureToApi(feature: Feature): ApiFeatureParams { })); } - if (feature.type === "ai_credit_system" && feature.modelMarkups) { - base.model_markups = Object.fromEntries( - Object.entries(feature.modelMarkups).map(([modelId, entry]) => [ - modelId, - { - markup: entry.markup, - input_cost: entry.inputCost, - output_cost: entry.outputCost, - }, - ]) - ); + if (feature.type === "ai_credit_system") { + if (feature.modelMarkups) { + base.model_markups = Object.fromEntries( + Object.entries(feature.modelMarkups).map(([modelId, entry]) => [ + modelId, + { + markup: entry.markup, + input_cost: entry.inputCost, + output_cost: entry.outputCost, + }, + ]) + ); + } + if (feature.defaultMarkup !== undefined) { + base.default_markup = feature.defaultMarkup; + } + if (feature.providerMarkups) { + base.provider_markups = feature.providerMarkups; + } } return base; diff --git a/packages/atmn/src/lib/transforms/sdkToCode/feature.ts b/packages/atmn/src/lib/transforms/sdkToCode/feature.ts index 54a728f17..eddbd30b1 100644 --- a/packages/atmn/src/lib/transforms/sdkToCode/feature.ts +++ b/packages/atmn/src/lib/transforms/sdkToCode/feature.ts @@ -42,9 +42,17 @@ export function buildFeatureCode(feature: Feature, varNameOverride?: string): st lines.push(`\tcreditSchema: ${formatValue(feature.creditSchema)},`); } - // Add modelMarkups for ai_credit_system features - if (feature.type === "ai_credit_system" && feature.modelMarkups) { - lines.push(`\tmodelMarkups: ${formatValue(feature.modelMarkups)},`); + // Add markup config for ai_credit_system features + if (feature.type === "ai_credit_system") { + if (feature.modelMarkups) { + lines.push(`\tmodelMarkups: ${formatValue(feature.modelMarkups)},`); + } + if (feature.defaultMarkup !== undefined) { + lines.push(`\tdefaultMarkup: ${feature.defaultMarkup},`); + } + if (feature.providerMarkups) { + lines.push(`\tproviderMarkups: ${formatValue(feature.providerMarkups)},`); + } } lines.push(`});`); diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 000000000..0f7ca5625 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,41 @@ +{ + "name": "@autumn/auth", + "version": "0.0.1", + "author": "Autumn", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + }, + "./utils": { + "types": "./src/utils/index.ts", + "import": "./src/utils/index.ts", + "default": "./src/utils/index.ts" + }, + "./oauth": { + "types": "./src/oauth/index.ts", + "import": "./src/oauth/index.ts", + "default": "./src/oauth/index.ts" + } + }, + "files": [ + "src" + ], + "scripts": { + "build": "tsc", + "ts": "tsc --noEmit", + "prepack": "bun run build", + "prepublishOnly": "bun run build" + }, + "dependencies": { + "@autumn/shared": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.2.13", + "@types/node": "^18.19.3", + "typescript": "~5.8.3" + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 000000000..68d71e8cd --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,2 @@ +export * from "./oauth/index.js"; +export * from "./utils/index.js"; diff --git a/packages/auth/src/oauth/index.ts b/packages/auth/src/oauth/index.ts new file mode 100644 index 000000000..553c3c91e --- /dev/null +++ b/packages/auth/src/oauth/index.ts @@ -0,0 +1,3 @@ +export * from "./leafOAuth.js"; +export * from "./mcpOAuth.js"; +export * from "./oauthUrls.js"; diff --git a/packages/auth/src/oauth/leafOAuth.ts b/packages/auth/src/oauth/leafOAuth.ts new file mode 100644 index 000000000..b91ef7de3 --- /dev/null +++ b/packages/auth/src/oauth/leafOAuth.ts @@ -0,0 +1,15 @@ +import { LEAF_OAUTH_SCOPES } from "@autumn/shared/leafOAuthScopes"; +import type { ScopeString } from "@autumn/shared/scopeDefinitions"; + +const leafScopeSet = new Set(LEAF_OAUTH_SCOPES); + +export const getDefaultOAuthScopes = (requestedScopes?: string[] | null) => { + const requested = + requestedScopes && requestedScopes.length > 0 + ? requestedScopes + : [...LEAF_OAUTH_SCOPES]; + + return [...new Set(requested)].filter((scope): scope is ScopeString => + leafScopeSet.has(scope), + ); +}; diff --git a/packages/auth/src/oauth/mcpOAuth.ts b/packages/auth/src/oauth/mcpOAuth.ts new file mode 100644 index 000000000..756894f08 --- /dev/null +++ b/packages/auth/src/oauth/mcpOAuth.ts @@ -0,0 +1,92 @@ +export const MCP_CLIENT_KIND = "mcp_client"; +export const SLACK_MCP_OAUTH_CLIENT_ID = "autumn_mcp_slack"; +export const AUTUMN_ADMIN_OAUTH_CLIENT_ID = "autumn_admin"; + +export const MCP_OAUTH_CLIENTS = [ + { type: "claude", name: "Claude", clientId: "autumn_mcp_claude" }, + { type: "codex", name: "Codex", clientId: "autumn_mcp_codex" }, + { type: "cursor", name: "Cursor", clientId: "autumn_mcp_cursor" }, + { type: "opencode", name: "OpenCode", clientId: "autumn_mcp_opencode" }, + { type: "slack", name: "Slack", clientId: SLACK_MCP_OAUTH_CLIENT_ID }, +] as const; + +export type KnownMpcClientType = (typeof MCP_OAUTH_CLIENTS)[number]["type"]; +export type MpcClientType = KnownMpcClientType | "dynamic"; +export type MpcClientInfo = { + type: MpcClientType; + name: string; + clientId: string; +}; + +export const MCP_OAUTH_CLIENT_IDS = MCP_OAUTH_CLIENTS.map( + (client) => client.clientId, +); + +export const isKnownMcpOAuthClientId = ({ + clientId, +}: { + clientId: string | null | undefined; +}) => + !!clientId && (MCP_OAUTH_CLIENT_IDS as readonly string[]).includes(clientId); + +const parseMetadata = (metadata: unknown) => { + if (!metadata) return {}; + if (typeof metadata === "string") { + try { + const parsed = JSON.parse(metadata); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } + } + + return typeof metadata === "object" ? metadata : {}; +}; + +export const isMcpOAuthClientRecord = ({ + clientId, + metadata, +}: { + clientId: string | null | undefined; + metadata?: unknown; +}) => { + if (isKnownMcpOAuthClientId({ clientId })) return true; + const parsedMetadata = parseMetadata(metadata); + return parsedMetadata.kind === MCP_CLIENT_KIND; +}; + +export const returnsOAuthAccessTokenForClientId = ({ + clientId, +}: { + clientId: string; +}) => + isKnownMcpOAuthClientId({ clientId }) || + clientId === AUTUMN_ADMIN_OAUTH_CLIENT_ID; + +export const isMcpOAuthResource = (resource: string | null | undefined) => { + if (!resource || !URL.canParse(resource)) return false; + return new URL(resource).pathname.replace(/\/+$/, "").endsWith("/mcp"); +}; + +export const getResourceFromOAuthTokenRequest = async (request: Request) => { + const contentType = request.headers.get("content-type") ?? ""; + const rawBody = await request.text(); + if (!rawBody) return null; + + if (contentType.includes("application/json")) { + try { + const body = JSON.parse(rawBody) as Record; + const resource = body.resource; + if (Array.isArray(resource)) return getString(resource[0]); + return getString(resource); + } catch { + return null; + } + } + + const params = new URLSearchParams(rawBody); + return params.getAll("resource")[0] ?? null; +}; + +const getString = (value: unknown) => + typeof value === "string" && value.length > 0 ? value : null; diff --git a/packages/auth/src/oauth/oauthUrls.ts b/packages/auth/src/oauth/oauthUrls.ts new file mode 100644 index 000000000..33a8ff48f --- /dev/null +++ b/packages/auth/src/oauth/oauthUrls.ts @@ -0,0 +1,32 @@ +const trimTrailingSlash = (url: string) => + url.endsWith("/") ? url.slice(0, -1) : url; + +export const getOAuthIssuerUrl = ({ + authPath = "/api/auth", + baseUrl, +}: { + authPath?: string; + baseUrl: string; +}): string => trimTrailingSlash(new URL(authPath, baseUrl).href); + +export const getProtectedResourceMetadataUrl = ({ + resourceUrl, +}: { + resourceUrl: string; +}): string => { + const url = new URL(resourceUrl); + const path = url.pathname === "/" ? "" : url.pathname; + return new URL(`/.well-known/oauth-protected-resource${path}`, url).href; +}; + +export const getWwwAuthenticateHeader = ({ + error, + resourceMetadataUrl, +}: { + error?: string; + resourceMetadataUrl: string; +}): string => { + const params = [`resource_metadata="${resourceMetadataUrl}"`]; + if (error) params.push(`error="${error}"`); + return `Bearer ${params.join(", ")}`; +}; diff --git a/packages/auth/src/utils/authTokenUtils.ts b/packages/auth/src/utils/authTokenUtils.ts new file mode 100644 index 000000000..71880a5bf --- /dev/null +++ b/packages/auth/src/utils/authTokenUtils.ts @@ -0,0 +1,23 @@ +const AUTUMN_SECRET_KEY_PREFIX = "am_sk"; +const AUTUMN_PUBLISHABLE_KEY_PREFIX = "am_pk"; +const AUTUMN_OAUTH_TOKEN_PREFIX = "am_oauth_"; + +export const isSecretKeyPrefix = ({ token }: { token: string }) => + token.startsWith(AUTUMN_SECRET_KEY_PREFIX); + +export const isPublishableKeyPrefix = ({ token }: { token: string }) => + token.startsWith(AUTUMN_PUBLISHABLE_KEY_PREFIX); + +export const isAutumnApiKey = ({ token }: { token: string }) => + isSecretKeyPrefix({ token }) || isPublishableKeyPrefix({ token }); + +export const isOAuthToken = ({ token }: { token: string }) => + token.startsWith(AUTUMN_OAUTH_TOKEN_PREFIX); + +export const prefixOAuthToken = ({ token }: { token: string }) => + isOAuthToken({ token }) ? token : `${AUTUMN_OAUTH_TOKEN_PREFIX}${token}`; + +export const stripOAuthTokenPrefix = ({ token }: { token: string }) => + isOAuthToken({ token }) + ? token.slice(AUTUMN_OAUTH_TOKEN_PREFIX.length) + : token; diff --git a/packages/auth/src/utils/getBearerToken.ts b/packages/auth/src/utils/getBearerToken.ts new file mode 100644 index 000000000..a704c92b7 --- /dev/null +++ b/packages/auth/src/utils/getBearerToken.ts @@ -0,0 +1,13 @@ +const BEARER_PREFIX = "Bearer "; + +export const getBearerToken = ({ + headers, +}: { + headers: Headers; +}): string | undefined => { + const authorization = headers.get("authorization"); + if (!authorization?.startsWith(BEARER_PREFIX)) return undefined; + + const token = authorization.slice(BEARER_PREFIX.length).trim(); + return token.length ? token : undefined; +}; diff --git a/packages/auth/src/utils/index.ts b/packages/auth/src/utils/index.ts new file mode 100644 index 000000000..5a76706d0 --- /dev/null +++ b/packages/auth/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./authTokenUtils.js"; +export * from "./getBearerToken.js"; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 000000000..ef9ed3373 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "checkJs": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "exactOptionalPropertyTypes": false, + "forceConsistentCasingInFileNames": true, + "incremental": false, + "isolatedModules": true, + "lib": ["dom", "dom.iterable", "es2024"], + "module": "Preserve", + "moduleResolution": "bundler", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": false, + "noImplicitReturns": false, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "es2022", + "types": ["bun", "node"], + "useUnknownInCatchVariables": true + }, + "exclude": ["node_modules"], + "include": ["src/**/*.ts"] +} diff --git a/packages/autumn-js/src/backend/core/handlers/executeRoute.ts b/packages/autumn-js/src/backend/core/handlers/executeRoute.ts index 2503fe13f..a007189b0 100644 --- a/packages/autumn-js/src/backend/core/handlers/executeRoute.ts +++ b/packages/autumn-js/src/backend/core/handlers/executeRoute.ts @@ -13,11 +13,13 @@ import { resolveIdentity } from "./resolveIdentity"; const buildSdkArgs = ({ body, identity, + route, }: { body: unknown; identity: ResolvedIdentity; + route: RouteDefinition; }): Record => { - const args = sanitizeBody(body); + const args = sanitizeBody(body, route.protectedBodyFields); if (identity.customerId) { args.customerId = identity.customerId; @@ -71,7 +73,7 @@ export const executeRoute = async ({ } // 3. Build args and call SDK - const sdkArgs = buildSdkArgs({ body, identity }); + const sdkArgs = buildSdkArgs({ body, identity, route }); try { const result = await route.sdkMethod(autumn, sdkArgs); diff --git a/packages/autumn-js/src/backend/core/routes/routeConfigs.ts b/packages/autumn-js/src/backend/core/routes/routeConfigs.ts index 261729cd0..719ddd7d0 100644 --- a/packages/autumn-js/src/backend/core/routes/routeConfigs.ts +++ b/packages/autumn-js/src/backend/core/routes/routeConfigs.ts @@ -16,7 +16,12 @@ import { updateSubscriptionParamsSchema, } from "../../../generated"; import type { RouteDefinition, RouteName } from "../types"; -import { backendError, backendSuccess, sanitizeBody } from "../utils"; +import { + backendError, + backendSuccess, + CUSTOMER_PROTECTED_BODY_FIELDS, + sanitizeBody, +} from "../utils"; const getEntityBodySchema = z.object({ entityId: z.string(), @@ -33,8 +38,9 @@ export const routeConfigs: RouteDefinition[] = [ // expand: z.array(z.enum(CustomerExpand)).optional(), expand: z.array(z.string()).optional(), }), + protectedBodyFields: CUSTOMER_PROTECTED_BODY_FIELDS, customHandler: async ({ autumn, identity, body }) => { - const sanitizedBody = sanitizeBody(body); + const sanitizedBody = sanitizeBody(body, CUSTOMER_PROTECTED_BODY_FIELDS); // Special case: if no customer and errorOnNotFound is false, return 204 if (!identity?.customerId && sanitizedBody.errorOnNotFound === false) { diff --git a/packages/autumn-js/src/backend/core/types/routeTypes.ts b/packages/autumn-js/src/backend/core/types/routeTypes.ts index 6ad13c98b..82559a565 100644 --- a/packages/autumn-js/src/backend/core/types/routeTypes.ts +++ b/packages/autumn-js/src/backend/core/types/routeTypes.ts @@ -1,5 +1,6 @@ import type { Autumn } from "@useautumn/sdk"; import type { z } from "zod/v4"; +import type { ProtectedBodyField } from "../utils/sanitizeBody"; import type { ResolvedIdentity } from "./authTypes"; import type { BackendResult } from "./responseTypes"; @@ -48,6 +49,8 @@ export type RouteDefinition = { customHandler?: CustomHandlerFn; /** Whether customer ID is required (default: true) */ requireCustomer?: boolean; + /** Body fields that must come from identity, not frontend */ + protectedBodyFields?: readonly ProtectedBodyField[]; /** Zod schema for request body validation (used by better-auth plugin) */ bodySchema?: z.ZodTypeAny; }; diff --git a/packages/autumn-js/src/backend/core/utils/index.ts b/packages/autumn-js/src/backend/core/utils/index.ts index 3e1926ac6..f1ce2dcf6 100644 --- a/packages/autumn-js/src/backend/core/utils/index.ts +++ b/packages/autumn-js/src/backend/core/utils/index.ts @@ -1,3 +1,8 @@ export { secretKeyCheck } from "./secretKeyCheck"; export { backendSuccess, backendError, isBackendResult } from "./backendRes"; -export { sanitizeBody } from "./sanitizeBody"; \ No newline at end of file +export { + CUSTOMER_PROTECTED_BODY_FIELDS, + DEFAULT_PROTECTED_BODY_FIELDS, + sanitizeBody, +} from "./sanitizeBody"; +export type { ProtectedBodyField } from "./sanitizeBody"; diff --git a/packages/autumn-js/src/backend/core/utils/sanitizeBody.ts b/packages/autumn-js/src/backend/core/utils/sanitizeBody.ts index fab0640bd..dfe8de604 100644 --- a/packages/autumn-js/src/backend/core/utils/sanitizeBody.ts +++ b/packages/autumn-js/src/backend/core/utils/sanitizeBody.ts @@ -1,19 +1,31 @@ /** Fields that must come from identity, not frontend */ -const PROTECTED_FIELDS = [ +export const DEFAULT_PROTECTED_BODY_FIELDS = [ "customerId", + "customerData", "name", "email", - "metadata", "stripeId", -]; +] as const; + +export const CUSTOMER_PROTECTED_BODY_FIELDS = [ + ...DEFAULT_PROTECTED_BODY_FIELDS, + "metadata", +] as const; + +export type ProtectedBodyField = + | (typeof DEFAULT_PROTECTED_BODY_FIELDS)[number] + | (typeof CUSTOMER_PROTECTED_BODY_FIELDS)[number]; /** Strip protected fields from body to prevent spoofing */ -export const sanitizeBody = (body: unknown): Record => { +export const sanitizeBody = ( + body: unknown, + protectedFields: readonly ProtectedBodyField[] = DEFAULT_PROTECTED_BODY_FIELDS, +): Record => { const rawBody = (body as Record) || {}; const sanitized: Record = {}; for (const [key, value] of Object.entries(rawBody)) { - if (!PROTECTED_FIELDS.includes(key)) { + if (!protectedFields.includes(key as ProtectedBodyField)) { sanitized[key] = value; } } diff --git a/packages/autumn-js/src/generated/attachSchemas.ts b/packages/autumn-js/src/generated/attachSchemas.ts index 75f4aaba2..92466a639 100644 --- a/packages/autumn-js/src/generated/attachSchemas.ts +++ b/packages/autumn-js/src/generated/attachSchemas.ts @@ -31,6 +31,8 @@ export const attachInvoiceModeSchema = z.object({ enabled: z.boolean(), enablePlanImmediately: z.union([z.boolean(), z.undefined()]).optional(), finalize: z.union([z.boolean(), z.undefined()]).optional(), + invoiceTemplateId: z.union([z.string(), z.undefined()]).optional(), + netTermsDays: z.union([z.number(), z.undefined()]).optional(), }); export const attachAttachDiscountSchema = z.object({ @@ -214,6 +216,8 @@ export const attachInvoiceModeOutboundSchema = z.object({ enabled: z.boolean(), enable_plan_immediately: z.boolean(), finalize: z.boolean(), + invoice_template_id: z.union([z.string(), z.undefined()]).optional(), + net_terms_days: z.union([z.number(), z.undefined()]).optional(), }); export const attachAttachDiscountOutboundSchema = z.object({ diff --git a/packages/autumn-js/src/generated/multiAttachSchemas.ts b/packages/autumn-js/src/generated/multiAttachSchemas.ts index e402dd0da..2502c8a8f 100644 --- a/packages/autumn-js/src/generated/multiAttachSchemas.ts +++ b/packages/autumn-js/src/generated/multiAttachSchemas.ts @@ -23,6 +23,8 @@ export const multiAttachInvoiceModeSchema = z.object({ enabled: z.boolean(), enablePlanImmediately: z.union([z.boolean(), z.undefined()]).optional(), finalize: z.union([z.boolean(), z.undefined()]).optional(), + invoiceTemplateId: z.union([z.string(), z.undefined()]).optional(), + netTermsDays: z.union([z.number(), z.undefined()]).optional(), }); export const multiAttachAttachDiscountSchema = z.object({ @@ -146,6 +148,8 @@ export const multiAttachInvoiceModeOutboundSchema = z.object({ enabled: z.boolean(), enable_plan_immediately: z.boolean(), finalize: z.boolean(), + invoice_template_id: z.union([z.string(), z.undefined()]).optional(), + net_terms_days: z.union([z.number(), z.undefined()]).optional(), }); export const multiAttachAttachDiscountOutboundSchema = z.object({ diff --git a/packages/autumn-js/src/generated/previewAttachSchemas.ts b/packages/autumn-js/src/generated/previewAttachSchemas.ts index 3ef234a2d..1bf684170 100644 --- a/packages/autumn-js/src/generated/previewAttachSchemas.ts +++ b/packages/autumn-js/src/generated/previewAttachSchemas.ts @@ -31,6 +31,8 @@ export const previewAttachInvoiceModeSchema = z.object({ enabled: z.boolean(), enablePlanImmediately: z.union([z.boolean(), z.undefined()]).optional(), finalize: z.union([z.boolean(), z.undefined()]).optional(), + invoiceTemplateId: z.union([z.string(), z.undefined()]).optional(), + netTermsDays: z.union([z.number(), z.undefined()]).optional(), }); export const previewAttachAttachDiscountSchema = z.object({ @@ -313,6 +315,8 @@ export const previewAttachInvoiceModeOutboundSchema = z.object({ enabled: z.boolean(), enable_plan_immediately: z.boolean(), finalize: z.boolean(), + invoice_template_id: z.union([z.string(), z.undefined()]).optional(), + net_terms_days: z.union([z.number(), z.undefined()]).optional(), }); export const previewAttachAttachDiscountOutboundSchema = z.object({ diff --git a/packages/autumn-js/src/generated/previewMultiAttachSchemas.ts b/packages/autumn-js/src/generated/previewMultiAttachSchemas.ts index 1e1741c34..f86f80df9 100644 --- a/packages/autumn-js/src/generated/previewMultiAttachSchemas.ts +++ b/packages/autumn-js/src/generated/previewMultiAttachSchemas.ts @@ -23,6 +23,8 @@ export const previewMultiAttachInvoiceModeSchema = z.object({ enabled: z.boolean(), enablePlanImmediately: z.union([z.boolean(), z.undefined()]).optional(), finalize: z.union([z.boolean(), z.undefined()]).optional(), + invoiceTemplateId: z.union([z.string(), z.undefined()]).optional(), + netTermsDays: z.union([z.number(), z.undefined()]).optional(), }); export const previewMultiAttachAttachDiscountSchema = z.object({ @@ -241,6 +243,8 @@ export const previewMultiAttachInvoiceModeOutboundSchema = z.object({ enabled: z.boolean(), enable_plan_immediately: z.boolean(), finalize: z.boolean(), + invoice_template_id: z.union([z.string(), z.undefined()]).optional(), + net_terms_days: z.union([z.number(), z.undefined()]).optional(), }); export const previewMultiAttachAttachDiscountOutboundSchema = z.object({ diff --git a/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts b/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts index ccb4f3d08..732a6c7af 100644 --- a/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts +++ b/packages/autumn-js/src/generated/previewUpdateSubscriptionSchemas.ts @@ -31,6 +31,8 @@ export const previewUpdateInvoiceModeSchema = z.object({ enabled: z.boolean(), enablePlanImmediately: z.union([z.boolean(), z.undefined()]).optional(), finalize: z.union([z.boolean(), z.undefined()]).optional(), + invoiceTemplateId: z.union([z.string(), z.undefined()]).optional(), + netTermsDays: z.union([z.number(), z.undefined()]).optional(), }); export const previewUpdateAttachDiscountSchema = z.object({ @@ -302,6 +304,8 @@ export const previewUpdateInvoiceModeOutboundSchema = z.object({ enabled: z.boolean(), enable_plan_immediately: z.boolean(), finalize: z.boolean(), + invoice_template_id: z.union([z.string(), z.undefined()]).optional(), + net_terms_days: z.union([z.number(), z.undefined()]).optional(), }); export const previewUpdateAttachDiscountOutboundSchema = z.object({ diff --git a/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts b/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts index 9ce0308b2..1099bee89 100644 --- a/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts +++ b/packages/autumn-js/src/generated/updateSubscriptionSchemas.ts @@ -31,6 +31,8 @@ export const billingUpdateInvoiceModeSchema = z.object({ enabled: z.boolean(), enablePlanImmediately: z.union([z.boolean(), z.undefined()]).optional(), finalize: z.union([z.boolean(), z.undefined()]).optional(), + invoiceTemplateId: z.union([z.string(), z.undefined()]).optional(), + netTermsDays: z.union([z.number(), z.undefined()]).optional(), }); export const billingUpdateAttachDiscountSchema = z.object({ @@ -217,6 +219,8 @@ export const billingUpdateInvoiceModeOutboundSchema = z.object({ enabled: z.boolean(), enable_plan_immediately: z.boolean(), finalize: z.boolean(), + invoice_template_id: z.union([z.string(), z.undefined()]).optional(), + net_terms_days: z.union([z.number(), z.undefined()]).optional(), }); export const billingUpdateAttachDiscountOutboundSchema = z.object({ diff --git a/packages/logging/package.json b/packages/logging/package.json new file mode 100644 index 000000000..c4912dd34 --- /dev/null +++ b/packages/logging/package.json @@ -0,0 +1,30 @@ +{ + "name": "@autumn/logging", + "version": "0.0.1", + "author": "Autumn", + "type": "module", + "sideEffects": false, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "README.md", + "src" + ], + "scripts": { + "build": "tsc", + "ts": "tsc --noEmit", + "test": "bun test tests/unit", + "prepack": "bun run build", + "prepublishOnly": "bun run build" + }, + "dependencies": { + "@axiomhq/pino": "^1.3.1", + "pino": "^9.6.0" + }, + "devDependencies": { + "@types/bun": "^1.2.13", + "@types/node": "^18.19.3", + "typescript": "~5.8.3" + } +} diff --git a/packages/logging/src/context/addContextToLogs.ts b/packages/logging/src/context/addContextToLogs.ts new file mode 100644 index 000000000..45a7a63e9 --- /dev/null +++ b/packages/logging/src/context/addContextToLogs.ts @@ -0,0 +1,38 @@ +import type { AutumnLogger } from "../types.js"; +import type { + LogAppContext, + LogRequestContext, + LogTriggerContext, +} from "./types.js"; + +export const addRequestToLogs = ({ + logger, + requestContext, +}: { + logger: AutumnLogger; + requestContext: LogRequestContext; +}): AutumnLogger => logger.child({ context: { req: requestContext } }); + +export const addAppContextToLogs = ({ + logger, + appContext, +}: { + logger: AutumnLogger; + appContext: LogAppContext; +}): AutumnLogger => logger.child({ context: { context: appContext } }); + +export const addTriggerToLogs = ({ + logger, + triggerContext, +}: { + logger: AutumnLogger; + triggerContext: LogTriggerContext; +}): AutumnLogger => logger.child({ context: { trigger: triggerContext } }); + +export const addExtrasToLogs = ({ + logger, + extras, +}: { + logger: AutumnLogger; + extras: Record; +}): AutumnLogger => logger.child({ context: { extras } }); diff --git a/packages/logging/src/context/types.ts b/packages/logging/src/context/types.ts new file mode 100644 index 000000000..830a0a861 --- /dev/null +++ b/packages/logging/src/context/types.ts @@ -0,0 +1,35 @@ +export type LogRequestContext = { + id: string; + method: string; + url: string; + timestamp: number; + customer_id?: string; + entity_id?: string; + user_agent?: string; + ip_address?: string; + region?: string; + query: Record; + body: unknown; + name: string; +}; + +export type LogAppContext = { + org_id?: string; + org_slug?: string; + env?: string; + auth_type?: string; + customer_id?: string; + entity_id?: string; + user_id?: string; + user_email?: string; + api_version?: string; + scopes?: string[]; + full_subject_bucket?: number; + full_subject_rollout_enabled?: boolean; +}; + +export type LogTriggerContext = { + run_id: string; + task_id: string; + attempt_number?: number; +}; diff --git a/packages/logging/src/ids/createSessionId.ts b/packages/logging/src/ids/createSessionId.ts new file mode 100644 index 000000000..4cd3832a1 --- /dev/null +++ b/packages/logging/src/ids/createSessionId.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; + +const stableStringify = ({ value }: { value: unknown }): string => { + if (!value || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) + return `[${value.map((item) => stableStringify({ value: item })).join(",")}]`; + + return `{${Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([key, item]) => + `${JSON.stringify(key)}:${stableStringify({ value: item })}`, + ) + .join(",")}}`; +}; + +export const createSessionId = ({ parts }: { parts: unknown }): string => + createHash("sha256") + .update(stableStringify({ value: parts })) + .digest("hex") + .slice(0, 24); diff --git a/packages/logging/src/ids/createTraceId.ts b/packages/logging/src/ids/createTraceId.ts new file mode 100644 index 000000000..124381e7d --- /dev/null +++ b/packages/logging/src/ids/createTraceId.ts @@ -0,0 +1,3 @@ +import { randomUUID } from "node:crypto"; + +export const createTraceId = (): string => randomUUID(); diff --git a/packages/logging/src/index.ts b/packages/logging/src/index.ts new file mode 100644 index 000000000..fbf99ad76 --- /dev/null +++ b/packages/logging/src/index.ts @@ -0,0 +1,40 @@ +export { + addAppContextToLogs, + addExtrasToLogs, + addRequestToLogs, + addTriggerToLogs, +} from "./context/addContextToLogs.js"; +export type { + LogAppContext, + LogRequestContext, + LogTriggerContext, +} from "./context/types.js"; +export { createSessionId } from "./ids/createSessionId.js"; +export { createTraceId } from "./ids/createTraceId.js"; +export { + createAppLogger, + createAutumnLogger, +} from "./logger/autumnLogger.js"; +export { createConsoleLogger } from "./logger/consoleLogger.js"; +export { createLogger } from "./logger/createLogger.js"; +export { + mirrorLogger, + withLogPrefix, +} from "./logger/loggerWrappers.js"; +export { resolveLoggerOptions } from "./logger/resolveLoggerOptions.js"; +export { asAxiomMap } from "./payload/asAxiomMap.js"; +export { + type GuardLogPayloadOptions, + guardLogPayload, +} from "./payload/guardLogPayload.js"; +export type { + AutumnLogger, + ConsoleLogger, + ConsoleLoggerLevel, + CreateLoggerParams, + LoggerLevel, + LoggerOutput, + LoggerPreset, + PinoLogger, + ResolvedLoggerOptions, +} from "./types.js"; diff --git a/packages/logging/src/logger/autumnLogger.ts b/packages/logging/src/logger/autumnLogger.ts new file mode 100644 index 000000000..ccf946ac7 --- /dev/null +++ b/packages/logging/src/logger/autumnLogger.ts @@ -0,0 +1,69 @@ +import type pino from "pino"; +import type { + AutumnLogger, + ConsoleLoggerLevel, + CreateLoggerParams, + LogArgs, +} from "../types.js"; +import { createLogger } from "./createLogger.js"; + +const rewriteAppPath = (value: string): string => + value.replace("file:///app/", "./").replace(/\/app\//g, "./"); + +const errorToObject = (error: Error) => ({ + name: error.name, + message: error.message, + stack: error.stack ? rewriteAppPath(error.stack) : undefined, +}); + +const normalizeLogArgs = ({ args }: { args: LogArgs }) => { + const strings = args + .filter((arg): arg is string => typeof arg === "string") + .map(rewriteAppPath); + const objects = args + .filter( + (arg) => typeof arg !== "string" && arg !== null && arg !== undefined, + ) + .map((arg) => (arg instanceof Error ? { error: errorToObject(arg) } : arg)); + const error = args.find((arg): arg is Error => arg instanceof Error); + const message = + strings.at(-1) ?? + (error + ? rewriteAppPath(error.stack || error.message || "Error occurred") + : ""); + + return { + message, + merged: Object.assign({}, ...objects) as Record, + }; +}; + +const createLogMethod = + ({ method }: { method: pino.LogFn }) => + (...args: LogArgs) => { + const { message, merged } = normalizeLogArgs({ args }); + if (Object.keys(merged).length > 0) method(merged, message); + else method(message); + }; + +export const createAutumnLogger = ({ + logger, +}: { + logger: pino.Logger; +}): AutumnLogger => ({ + level: logger.level as ConsoleLoggerLevel, + debug: createLogMethod({ method: logger.debug.bind(logger) }), + info: createLogMethod({ method: logger.info.bind(logger) }), + warn: createLogMethod({ method: logger.warn.bind(logger) }), + warning: createLogMethod({ method: logger.warn.bind(logger) }), + error: createLogMethod({ method: logger.error.bind(logger) }), + child: ({ context, onlyProd = false }) => { + if (onlyProd && process.env.NODE_ENV !== "production") { + return createAutumnLogger({ logger }); + } + return createAutumnLogger({ logger: logger.child(context) }); + }, +}); + +export const createAppLogger = (params: CreateLoggerParams): AutumnLogger => + createAutumnLogger({ logger: createLogger(params) }); diff --git a/packages/logging/src/logger/consoleLogger.ts b/packages/logging/src/logger/consoleLogger.ts new file mode 100644 index 000000000..e6c5795b4 --- /dev/null +++ b/packages/logging/src/logger/consoleLogger.ts @@ -0,0 +1,28 @@ +import type { ConsoleLogger, ConsoleLoggerLevel, LogArgs } from "../types.js"; + +export const createConsoleLogger = ({ + level, +}: { + level: ConsoleLoggerLevel; +}): ConsoleLogger => { + const levels: ConsoleLoggerLevel[] = ["debug", "info", "warning", "error"]; + const min = levels.indexOf(level); + const noop = () => {}; + const log = + ({ method }: { method: "debug" | "info" | "warn" | "error" }) => + (...args: LogArgs) => { + console[method](...args); + }; + + const logger: ConsoleLogger = { + level, + debug: min <= 0 ? log({ method: "debug" }) : noop, + info: min <= 1 ? log({ method: "info" }) : noop, + warn: min <= 2 ? log({ method: "warn" }) : noop, + warning: min <= 2 ? log({ method: "warn" }) : noop, + error: min <= 3 ? log({ method: "error" }) : noop, + child: () => logger, + }; + + return logger; +}; diff --git a/packages/logging/src/logger/createLogger.ts b/packages/logging/src/logger/createLogger.ts new file mode 100644 index 000000000..ff4b08c20 --- /dev/null +++ b/packages/logging/src/logger/createLogger.ts @@ -0,0 +1,60 @@ +import pino from "pino"; +import { createConsoleJsonStream } from "../streams/consoleJsonStream.js"; +import { createPrettyLogStream } from "../streams/prettyLogStream.js"; +import type { CreateLoggerParams } from "../types.js"; +import { resolveLoggerOptions } from "./resolveLoggerOptions.js"; + +export const createLogger = (params: CreateLoggerParams): pino.Logger => { + const resolved = resolveLoggerOptions({ options: params }); + const axiomToken = params.axiomToken ?? process.env.AXIOM_TOKEN; + const axiomOrgId = params.axiomOrgId ?? process.env.AXIOM_ORG_ID; + const streams: pino.StreamEntry[] = []; + + for (const output of resolved.outputs) { + if (output === "console-pretty") { + streams.push({ + level: resolved.level, + stream: createPrettyLogStream({ + trailingNewline: resolved.preset !== "dual", + useConsoleLog: params.useConsoleLog ?? resolved.preset === "dual", + }), + }); + } + + if (output === "console-json") { + streams.push({ + level: resolved.level, + stream: createConsoleJsonStream(), + }); + } + + if (output === "axiom" && axiomToken) { + streams.push({ + level: resolved.level, + stream: pino.transport({ + target: "@axiomhq/pino", + options: { + dataset: resolved.dataset, + token: axiomToken, + orgId: axiomOrgId, + }, + }), + }); + } + } + + return pino( + { + level: resolved.level, + base: { + service: resolved.service, + ...(params.context ?? {}), + }, + mixin: params.mixin, + formatters: { + level: (label: string) => ({ level: label.toUpperCase() }), + }, + }, + pino.multistream(streams), + ); +}; diff --git a/packages/logging/src/logger/loggerWrappers.ts b/packages/logging/src/logger/loggerWrappers.ts new file mode 100644 index 000000000..08d51e1f4 --- /dev/null +++ b/packages/logging/src/logger/loggerWrappers.ts @@ -0,0 +1,71 @@ +import type { AutumnLogger, LogArgs } from "../types.js"; + +const logToStdout = ({ + level, + args, +}: { + level: "debug" | "info" | "warn" | "error"; + args: LogArgs; +}) => { + const method = + level === "debug" + ? console.debug + : level === "info" + ? console.info + : level === "warn" + ? console.warn + : console.error; + method(...args); +}; + +export const mirrorLogger = ({ + logger, +}: { + logger: AutumnLogger; +}): AutumnLogger => ({ + debug: (...args) => { + logger.debug(...args); + logToStdout({ level: "debug", args }); + }, + info: (...args) => { + logger.info(...args); + logToStdout({ level: "info", args }); + }, + warn: (...args) => { + logger.warn(...args); + logToStdout({ level: "warn", args }); + }, + warning: (...args) => { + logger.warn(...args); + logToStdout({ level: "warn", args }); + }, + error: (...args) => { + logger.error(...args); + logToStdout({ level: "error", args }); + }, + child: (params) => mirrorLogger({ logger: logger.child(params) }), +}); + +const prefixArgs = ({ prefix, args }: { prefix: string; args: LogArgs }) => { + if (typeof args[0] !== "string") return [prefix, ...args]; + if (args[0].startsWith(prefix)) return args; + return [`${prefix} ${args[0]}`, ...args.slice(1)]; +}; + +export const withLogPrefix = ({ + logger, + label, +}: { + logger: AutumnLogger; + label: string; +}): AutumnLogger => { + const prefix = `[${label}]`; + return { + debug: (...args) => logger.debug(...prefixArgs({ prefix, args })), + info: (...args) => logger.info(...prefixArgs({ prefix, args })), + warn: (...args) => logger.warn(...prefixArgs({ prefix, args })), + warning: (...args) => logger.warn(...prefixArgs({ prefix, args })), + error: (...args) => logger.error(...prefixArgs({ prefix, args })), + child: (params) => withLogPrefix({ logger: logger.child(params), label }), + }; +}; diff --git a/packages/logging/src/logger/resolveLoggerOptions.ts b/packages/logging/src/logger/resolveLoggerOptions.ts new file mode 100644 index 000000000..ddd79caa4 --- /dev/null +++ b/packages/logging/src/logger/resolveLoggerOptions.ts @@ -0,0 +1,67 @@ +import type { + CreateLoggerParams, + LoggerLevel, + LoggerOutput, + ResolvedLoggerOptions, +} from "../types.js"; + +const parseOutputs = ( + value: string | undefined, +): LoggerOutput[] | undefined => { + if (!value) return undefined; + const outputs = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + + if ( + outputs.every( + (output): output is LoggerOutput => + output === "console-pretty" || + output === "console-json" || + output === "axiom", + ) + ) { + return outputs; + } + + return undefined; +}; + +export const resolveLoggerOptions = ({ + options, + env = process.env, +}: { + options: CreateLoggerParams; + env?: NodeJS.ProcessEnv; +}): ResolvedLoggerOptions => { + const preset = options.preset ?? "default"; + const isDevOrTest = env.NODE_ENV === "development" || env.NODE_ENV === "test"; + const hasAxiomToken = Boolean(options.axiomToken ?? env.AXIOM_TOKEN); + + let outputs = options.outputs ?? parseOutputs(env.LOG_OUTPUTS); + if (!outputs) { + if (preset === "console-only") outputs = ["console-pretty"]; + else if (preset === "axiom-only") outputs = ["axiom"]; + else if (preset === "dual") + outputs = [isDevOrTest ? "console-pretty" : "console-json", "axiom"]; + else if (isDevOrTest) outputs = ["console-pretty", "axiom"]; + else outputs = ["axiom"]; + } + + const filteredOutputs = outputs.filter( + (output) => output !== "axiom" || hasAxiomToken, + ); + + return { + service: options.service, + dataset: options.dataset ?? options.service, + preset, + level: + options.level ?? + ((env.LOG_LEVEL as LoggerLevel | undefined) || + (isDevOrTest || preset === "dual" ? "debug" : "info")), + outputs: filteredOutputs.length > 0 ? filteredOutputs : ["console-pretty"], + hasAxiomToken, + }; +}; diff --git a/packages/logging/src/payload/asAxiomMap.ts b/packages/logging/src/payload/asAxiomMap.ts new file mode 100644 index 000000000..7c73aa94c --- /dev/null +++ b/packages/logging/src/payload/asAxiomMap.ts @@ -0,0 +1,8 @@ +export const asAxiomMap = ({ + value, +}: { + value: unknown; +}): Record => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : { value }; diff --git a/packages/logging/src/payload/guardLogPayload.ts b/packages/logging/src/payload/guardLogPayload.ts new file mode 100644 index 000000000..64171178e --- /dev/null +++ b/packages/logging/src/payload/guardLogPayload.ts @@ -0,0 +1,147 @@ +const defaultMaxPayloadBytes = 512_000; +const defaultTruncateAboveBytes = 4_000; +const defaultMaxArrayItems = 5; +const defaultMaxStringLength = 500; +const defaultMaxDepth = 6; + +export type GuardLogPayloadOptions = { + maxPayloadBytes?: number; + truncateAboveBytes?: number; + maxArrayItems?: number; + maxStringLength?: number; + maxDepth?: number; +}; + +const envNumber = ({ + value, + fallback, +}: { + value?: string; + fallback: number; +}) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const resolveOptions = ({ + options = {}, +}: { + options?: GuardLogPayloadOptions; +}) => ({ + maxPayloadBytes: + options.maxPayloadBytes ?? + envNumber({ + value: process.env.LOG_MAX_PAYLOAD_BYTES, + fallback: defaultMaxPayloadBytes, + }), + truncateAboveBytes: + options.truncateAboveBytes ?? + envNumber({ + value: process.env.LOG_TRUNCATE_ABOVE_BYTES, + fallback: defaultTruncateAboveBytes, + }), + maxArrayItems: + options.maxArrayItems ?? + envNumber({ + value: process.env.LOG_MAX_ARRAY_ITEMS, + fallback: defaultMaxArrayItems, + }), + maxStringLength: + options.maxStringLength ?? + envNumber({ + value: process.env.LOG_MAX_STRING_LENGTH, + fallback: defaultMaxStringLength, + }), + maxDepth: options.maxDepth ?? defaultMaxDepth, +}); + +type ResolvedGuardOptions = ReturnType; + +const truncateString = ({ + value, + maxStringLength, +}: { + value: string; + maxStringLength: number; +}): string => + value.length > maxStringLength + ? `${value.slice(0, maxStringLength)}...[+${value.length - maxStringLength} chars]` + : value; + +const truncateValue = ({ + value, + options, + depth = 0, +}: { + value: unknown; + options: ResolvedGuardOptions; + depth?: number; +}): unknown => { + if (typeof value === "string") + return truncateString({ + value, + maxStringLength: options.maxStringLength, + }); + if (!value || typeof value !== "object") return value; + + if (depth >= options.maxDepth) { + if (Array.isArray(value)) return `...[${value.length} items]`; + return "...[object]"; + } + + if (Array.isArray(value)) { + const kept = value.slice(0, options.maxArrayItems).map((item) => + truncateValue({ + value: item, + options, + depth: depth + 1, + }), + ); + if (value.length > options.maxArrayItems) { + kept.push(`...[+${value.length - options.maxArrayItems} more items]`); + } + return kept; + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + out[key] = truncateValue({ + value: item, + options, + depth: depth + 1, + }); + } + return out; +}; + +export const guardLogPayload = ({ + value, + options: guardOptions, +}: { + value: unknown; + options?: GuardLogPayloadOptions; +}): unknown => { + if (value === undefined) return undefined; + const options = resolveOptions({ options: guardOptions }); + try { + const json = JSON.stringify(value); + if (!json || json.length <= options.truncateAboveBytes) return value; + + const truncated = truncateValue({ value, options }); + const truncatedJson = JSON.stringify(truncated); + if (truncatedJson && truncatedJson.length > options.maxPayloadBytes) { + return { _truncated: true, _bytes: truncatedJson.length }; + } + return truncated; + } catch { + return { _unserializable: true }; + } +}; diff --git a/packages/logging/src/streams/consoleJsonStream.ts b/packages/logging/src/streams/consoleJsonStream.ts new file mode 100644 index 000000000..a204dde79 --- /dev/null +++ b/packages/logging/src/streams/consoleJsonStream.ts @@ -0,0 +1,9 @@ +import { Writable } from "node:stream"; + +export const createConsoleJsonStream = () => + new Writable({ + write(chunk, _encoding, callback) { + console.log(chunk.toString().trimEnd()); + callback(); + }, + }); diff --git a/packages/logging/src/streams/prettyLogStream.ts b/packages/logging/src/streams/prettyLogStream.ts new file mode 100644 index 000000000..e9bce2f08 --- /dev/null +++ b/packages/logging/src/streams/prettyLogStream.ts @@ -0,0 +1,117 @@ +import { Writable } from "node:stream"; + +const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([ + "time", + "level", + "msg", + "pid", + "hostname", + "req", + "res", + "statusCode", + "body", + "query", + "durationMs", + "duration_ms", + "event", + "context", + "workflow", + "trigger", + "stripe_event", + "vercel_event", + "worker", + "extras", + "type", + "data", + "aws", + "service", +]); + +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + white: "\x1b[37m", + gray: "\x1b[90m", + bgRed: "\x1b[41m", +}; + +const levelColors: Record = { + 10: colors.gray, + 20: colors.blue, + 30: colors.green, + 40: colors.yellow, + 50: colors.red, + 60: colors.bgRed, + TRACE: colors.gray, + DEBUG: colors.blue, + INFO: colors.green, + WARN: colors.yellow, + ERROR: colors.red, + FATAL: colors.bgRed, +}; + +const levelNames: Record = { + 10: "TRACE", + 20: "DEBUG", + 30: "INFO", + 40: "WARN", + 50: "ERROR", + 60: "FATAL", + TRACE: "TRACE", + DEBUG: "DEBUG", + INFO: "INFO", + WARN: "WARN", + ERROR: "ERROR", + FATAL: "FATAL", +}; + +export const createPrettyLogStream = ({ + trailingNewline = true, + useConsoleLog = false, +}: { + trailingNewline?: boolean; + useConsoleLog?: boolean; +} = {}) => + new Writable({ + write(chunk, _encoding, callback) { + try { + const log = JSON.parse(chunk.toString()); + const timestamp = new Date(log.time) + .toISOString() + .replace("T", " ") + .replace("Z", ""); + const level = log.level; + const levelColor = levelColors[level] || colors.white; + const levelName = + levelNames[level] || (typeof level === "string" ? level : "UNKNOWN"); + let message = log.msg || ""; + + const additionalFields = Object.keys(log) + .filter((key) => !FORMATTED_LOG_EXCLUDE_FIELDS.has(key)) + .reduce( + (acc, key) => { + acc[key] = log[key]; + return acc; + }, + {} as Record, + ); + + if (Object.keys(additionalFields).length > 0) { + message += ` ${JSON.stringify(additionalFields, null, 2)}`; + } + + const formattedLog = `${colors.gray}${timestamp}${colors.reset} ${levelColor}${colors.bright}${levelName}${colors.reset} ${message}${trailingNewline ? "\n" : ""}`; + if (useConsoleLog) console.log(formattedLog); + else process.stdout.write(formattedLog); + callback(); + } catch { + if (useConsoleLog) console.log(chunk.toString()); + else process.stdout.write(chunk); + callback(); + } + }, + }); diff --git a/packages/logging/src/types.ts b/packages/logging/src/types.ts new file mode 100644 index 000000000..0a46ee93d --- /dev/null +++ b/packages/logging/src/types.ts @@ -0,0 +1,56 @@ +import type pino from "pino"; + +export type LoggerOutput = "console-pretty" | "console-json" | "axiom"; +export type LoggerPreset = "default" | "dual" | "console-only" | "axiom-only"; +export type LoggerLevel = + | "trace" + | "debug" + | "info" + | "warn" + | "error" + | "fatal"; + +export type CreateLoggerParams = { + service: string; + dataset?: string; + level?: LoggerLevel; + preset?: LoggerPreset; + outputs?: LoggerOutput[]; + context?: Record; + mixin?: () => Record; + axiomToken?: string; + axiomOrgId?: string; + useConsoleLog?: boolean; +}; + +export type ResolvedLoggerOptions = Required< + Pick +> & { + dataset: string; + level: LoggerLevel; + outputs: LoggerOutput[]; + hasAxiomToken: boolean; +}; + +export type LogArgs = unknown[]; + +export type AutumnLogger = { + level?: string; + debug: (...args: LogArgs) => void; + info: (...args: LogArgs) => void; + warn: (...args: LogArgs) => void; + warning: (...args: LogArgs) => void; + error: (...args: LogArgs) => void; + child: (params: { + context: Record; + onlyProd?: boolean; + }) => AutumnLogger; +}; + +export type ConsoleLoggerLevel = "debug" | "info" | "warning" | "error"; + +export type ConsoleLogger = AutumnLogger & { + level: ConsoleLoggerLevel; +}; + +export type PinoLogger = pino.Logger; diff --git a/packages/logging/tsconfig.json b/packages/logging/tsconfig.json new file mode 100644 index 000000000..c4f77bb62 --- /dev/null +++ b/packages/logging/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "checkJs": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "exactOptionalPropertyTypes": false, + "forceConsistentCasingInFileNames": true, + "incremental": false, + "isolatedModules": true, + "lib": ["es2024"], + "module": "Preserve", + "moduleResolution": "bundler", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": false, + "noImplicitReturns": false, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "es2022", + "types": ["bun", "node"], + "useUnknownInCatchVariables": true + }, + "exclude": ["node_modules"], + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ec219be1f..ff749f061 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -2,11 +2,10 @@ Mastra-backed MCP library for Autumn operations. -The hosted runtime lives in `apps/mcp-server` and exposes two Streamable HTTP -MCP routes: +The hosted runtime lives in `apps/leaf` (see `src/mcp/mcpRouter.ts`) and exposes a +Streamable HTTP MCP route: - `/mcp` - public, API-shaped operational tools. -- `/internal/mcp` - internal Autumn agent tool. ## `/mcp` @@ -15,47 +14,38 @@ Use this for external MCP clients that should call Autumn operations directly. Tools: - `listCustomers` +- `getOrCreateCustomer` +- `updateCustomer` - `getCustomer` - `listPlans` +- `createPlan` - `getPlan` - `previewAttach` - `attach` - `previewUpdateSubscription` - `updateSubscription` +- `previewCreateSchedule` +- `createSchedule` The write tools are marked destructive. Clients should call the matching preview -tool first and only call a write tool after explicit user confirmation. - -## `/internal/mcp` - -Use this for Autumn-controlled agent flows. - -Tools: - -- `ask_autumn({ message, context? })` - -`ask_autumn` can look up customers/plans, inspect scoped Axiom logs when -available, preview billing changes, and apply confirmed billing writes. Billing -writes are preview-first: the server stores the pending action internally and -executes it only after a follow-up confirmation. +tool first where one exists and only call a write tool after explicit user +confirmation. ## Local -From the repo root: +The routes are served by the `@autumn/leaf` app. From the repo root: ```sh -bun run mcp +bun run leaf ``` -This starts both MCP routes: +This starts the MCP route (on the leaf port, `3099` by default): -- `http://localhost:2718/mcp` -- `http://localhost:2718/internal/mcp` +- `http://localhost:3099/mcp` OAuth metadata is route-aware: -- `http://localhost:2718/.well-known/oauth-protected-resource/mcp` -- `http://localhost:2718/.well-known/oauth-protected-resource/internal/mcp` +- `http://localhost:3099/.well-known/oauth-protected-resource/mcp` OAuth uses the Autumn Better Auth issuer from `--server-url`: OAuth uses the Autumn Better Auth issuer from `MCP_SERVER_URL`: @@ -66,5 +56,5 @@ OAuth uses the Autumn Better Auth issuer from `MCP_SERVER_URL`: For production-like local testing: ```sh -MCP_SERVER_URL=https://api.useautumn.com bun -F @autumn/mcp-server start +MCP_SERVER_URL=https://api.useautumn.com bun -F @autumn/leaf start ``` diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e6ba4f841..0cffccb4c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -14,12 +14,15 @@ "scripts": { "build": "tsc", "ts": "tsc --noEmit", - "test": "bun test src", + "test": "bun test tests/unit", + "test:eval": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test tests/evals", "prepack": "bun run build", "prepublishOnly": "bun run build" }, "dependencies": { - "@autumn/shared": "workspace:*", + "@autumn/auth": "workspace:*", + "@autumn/logging": "workspace:*", + "@autumn/shared": "workspace:*", "@axiomhq/js": "^1.6.1", "@mastra/core": "^1.36.0", "@mastra/mcp": "^1.8.0", diff --git a/packages/mcp/src/mcp-server/agent/axiom.ts b/packages/mcp/src/agent/axiom.ts similarity index 79% rename from packages/mcp/src/mcp-server/agent/axiom.ts rename to packages/mcp/src/agent/axiom.ts index 82f7fe73e..dcfcd640a 100644 --- a/packages/mcp/src/mcp-server/agent/axiom.ts +++ b/packages/mcp/src/agent/axiom.ts @@ -1,4 +1,10 @@ import { createHash } from "node:crypto"; +import { + makeScopeChecker, + type ScopeString, + Scopes, +} from "@autumn/shared/scopeDefinitions"; +import { ms } from "@autumn/shared/unixUtils"; import { Axiom } from "@axiomhq/js"; import { createTool } from "@mastra/core/tools"; import { @@ -9,18 +15,12 @@ import { isValid, parseISO, } from "date-fns"; -import { - makeScopeChecker, - Scopes, - type ScopeString, -} from "@autumn/shared/scopeDefinitions"; -import { ms } from "@autumn/shared/unixUtils"; import * as z from "zod/v4"; import { + type AutumnMcpAuth, createAutumnClient, getAutumnAuth, - type AutumnMcpAuth, -} from "./auth.js"; +} from "../server/auth/auth.js"; const axiomDataset = "express"; const defaultStartTime = "now-30m"; @@ -28,8 +28,10 @@ const defaultEndTime = "now"; const maxRangeMs = ms.days(7); const searchMaxRangeMs = ms.hours(1); +type AutumnOrg = { id: string; slug?: string | undefined }; + let axiomClient: Axiom | null = null; -const orgCache = new Map(); +const orgCache = new Map(); const getAxiomClient = () => { if (!process.env.AXIOM_ADMIN_TOKEN) { @@ -78,14 +80,22 @@ const getRangeMs = (startTime: string, endTime: string) => { }; const assertCanUseAxiom = (auth: AutumnMcpAuth) => { - if (!makeScopeChecker(auth.scopes).has(Scopes.Analytics.Read as ScopeString)) { + if ( + !makeScopeChecker(auth.scopes).has(Scopes.Analytics.Read as ScopeString) + ) { throw new Error("analytics:read scope is required to query Axiom logs."); } }; -export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => { - if (auth.orgId) return auth.orgId; - +/** + * Resolves the Autumn org (id + slug) for an authenticated request. Cached + * (~5min) per credential. Unlike `resolveAutumnOrgId`, this always hits + * `/v1/organization` when uncached so the slug is available — the id alone may + * already be on `auth`, but the slug never is. + */ +export const resolveAutumnOrg = async ( + auth: AutumnMcpAuth, +): Promise => { const cacheKey = [ auth.serverURL ?? "https://api.useautumn.com", auth.env, @@ -94,7 +104,7 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => { String(auth.failOpen), ].join(":"); const cached = orgCache.get(cacheKey); - if (cached && isFuture(cached.expiresAt)) return cached.orgId; + if (cached && isFuture(cached.expiresAt)) return cached.org; const client = createAutumnClient(auth); const response = await fetch(new URL("/v1/organization", client.baseUrl), { @@ -105,17 +115,26 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => { throw new Error("Could not resolve Autumn organization for MCP request."); } - const body = (await response.json()) as { id?: unknown }; + const body = (await response.json()) as { id?: unknown; slug?: unknown }; if (typeof body.id !== "string" || !body.id) { throw new Error("Autumn organization response did not include an id."); } + const org: AutumnOrg = { + id: body.id, + slug: typeof body.slug === "string" ? body.slug : undefined, + }; orgCache.set(cacheKey, { - orgId: body.id, + org, expiresAt: addMilliseconds(new Date(), ms.minutes(5)), }); - return body.id; + return org; +}; + +export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => { + if (auth.orgId) return auth.orgId; + return (await resolveAutumnOrg(auth)).id; }; export const prepareAxiomQuery = ({ @@ -133,7 +152,9 @@ export const prepareAxiomQuery = ({ const rangeMs = getRangeMs(startTime, endTime); if (rangeMs === null || rangeMs <= 0 || rangeMs > maxRangeMs) { - throw new Error("Axiom queries must use a bounded time range of at most 7 days."); + throw new Error( + "Axiom queries must use a bounded time range of at most 7 days.", + ); } const trimmed = apl.trim(); @@ -152,7 +173,9 @@ export const prepareAxiomQuery = ({ } if (/\|\s*\[\s*['"][^'"]+['"]\s*\](?=\s*(?:\||$))/i.test(rest)) { - throw new Error("Axiom queries may only use the express dataset source once."); + throw new Error( + "Axiom queries may only use the express dataset source once.", + ); } if (/\bsearch\b/i.test(rest) && rangeMs > searchMaxRangeMs) { @@ -165,7 +188,9 @@ export const prepareAxiomQuery = ({ `| where ['context.org_id'] == '${escapeAplString(auth.orgId)}'`, `| where ['context.env'] == '${escapeAplString(auth.env)}'`, rest, - ].filter(Boolean).join("\n"), + ] + .filter(Boolean) + .join("\n"), startTime, endTime, }; @@ -180,11 +205,13 @@ export const createAxiomTools = () => ({ id: "queryAxiomLogs", description: "Run a read-only Axiom APL query against Autumn logs. The query is always constrained to the authenticated Autumn org and environment.", - inputSchema: z.object({ - apl: z.string().min(1), - startTime: z.string().optional(), - endTime: z.string().optional(), - }).strict(), + inputSchema: z + .object({ + apl: z.string().min(1), + startTime: z.string().optional(), + endTime: z.string().optional(), + }) + .strict(), execute: async ({ apl, startTime, endTime }, context) => { const auth = await withAxiomOrg(getAutumnAuth(context)); const query = prepareAxiomQuery({ auth, apl, startTime, endTime }); @@ -198,9 +225,11 @@ export const createAxiomTools = () => ({ id: "getAxiomDatasetFields", description: "List available Axiom field metadata for the express dataset, scoped to the authenticated Autumn org and environment.", - inputSchema: z.object({ - dataset: z.literal(axiomDataset), - }).strict(), + inputSchema: z + .object({ + dataset: z.literal(axiomDataset), + }) + .strict(), execute: async ({ dataset }, context) => { const auth = await withAxiomOrg(getAutumnAuth(context)); const query = prepareAxiomQuery({ diff --git a/packages/mcp/src/mcp-server/agent/pending-actions.ts b/packages/mcp/src/agent/pending-actions.ts similarity index 93% rename from packages/mcp/src/mcp-server/agent/pending-actions.ts rename to packages/mcp/src/agent/pending-actions.ts index a19116eb5..5cfd9512d 100644 --- a/packages/mcp/src/mcp-server/agent/pending-actions.ts +++ b/packages/mcp/src/agent/pending-actions.ts @@ -2,9 +2,14 @@ import { createHash } from "node:crypto"; import { ms } from "@autumn/shared/unixUtils"; import { addMilliseconds, isPast } from "date-fns"; import { Redis } from "ioredis"; -import type { AutumnMcpAuth } from "./auth.js"; +import type { AutumnMcpAuth } from "../server/auth/auth.js"; -export type BillingToolName = "attach" | "updateSubscription"; +export type BillingToolName = + | "attach" + | "updateSubscription" + | "createPlan" + | "createSchedule" + | "createBalance"; export type PendingBillingAction = { token: string; @@ -84,7 +89,7 @@ const getRedis = (): PendingActionRedis => { }; const parseStoredAction = (value: string | null) => - (value ? (JSON.parse(value) as PendingBillingAction) : null); + value ? (JSON.parse(value) as PendingBillingAction) : null; const createAction = ({ auth, @@ -145,7 +150,11 @@ export const claimLatestPendingAction = async (auth: AutumnMcpAuth) => { if (!token || !action || isExpired(action)) { logPendingAction("claim-miss", { backend: "redis", - reason: !token ? "missing_latest" : !action ? "missing_action" : "expired", + reason: !token + ? "missing_latest" + : !action + ? "missing_action" + : "expired", token: token ? shortHash(token) : null, ...actionDebug(auth), }); diff --git a/packages/mcp/src/analytics/analyticsSink.ts b/packages/mcp/src/analytics/analyticsSink.ts new file mode 100644 index 000000000..928754031 --- /dev/null +++ b/packages/mcp/src/analytics/analyticsSink.ts @@ -0,0 +1,37 @@ +import type { AnalyticsSink } from "./analyticsTypes.js"; +import { createLoggerAnalyticsSink } from "./loggerSink.js"; + +const DEFAULT_DATASET = "leaf"; + +const noopSink: AnalyticsSink = { + emit() {}, + flush: async () => {}, +}; + +let cachedSink: AnalyticsSink | null | undefined; +let overrideSink: AnalyticsSink | null | undefined; + +/** + * Override the analytics sink (tests, or wiring a pino/OTEL sink from the host + * app). Pass `null` to disable. Pass `undefined` to fall back to env defaults. + */ +export const setAnalyticsSink = (sink: AnalyticsSink | null | undefined) => { + overrideSink = sink; + if (sink !== undefined) cachedSink = undefined; +}; + +export const getAnalyticsSink = (): AnalyticsSink => { + if (overrideSink !== undefined) return overrideSink ?? noopSink; + if (cachedSink === undefined) { + cachedSink = createLoggerAnalyticsSink({ + token: process.env.AXIOM_TOKEN, + orgId: process.env.AXIOM_ORG_ID, + dataset: process.env.MCP_ANALYTICS_DATASET ?? DEFAULT_DATASET, + }); + } + return cachedSink ?? noopSink; +}; + +/** True when a real sink is configured — lets callers skip hot-path work. */ +export const isAnalyticsEnabled = (): boolean => + getAnalyticsSink() !== noopSink; diff --git a/packages/mcp/src/analytics/analyticsTypes.ts b/packages/mcp/src/analytics/analyticsTypes.ts new file mode 100644 index 000000000..7307a3557 --- /dev/null +++ b/packages/mcp/src/analytics/analyticsTypes.ts @@ -0,0 +1,54 @@ +/** + * Where a tool call originated: + * - `mcp` — an external MCP client hitting our hosted server (e.g. Claude + * Code, Cursor). The #1 usage-analytics target. + * - `agent` — our own Autumn Ops agent (e.g. Slack) invoking tools + * internally. Drives agent reliability / failure detection. + */ +export type McpAnalyticsSurface = "mcp" | "agent"; + +/** + * Org/auth context for a tool call. Mirrors the server's `context.*` log shape + * (see server/src/utils/logging) so MCP analytics and agent logs unify cleanly. + */ +export type McpAnalyticsContext = { + /** Autumn org id. Resolved lazily; may be absent if resolution fails. */ + orgId?: string | undefined; + /** Autumn org slug. Resolved lazily; may be absent if resolution fails. */ + orgSlug?: string | undefined; + env: string; + scopes?: string[] | undefined; +}; + +export type McpAnalyticsEvent = { + event: "mcp.tool_call"; + surface: McpAnalyticsSurface; + tool: string; + /** One-sentence statement of what the caller is trying to do. */ + intent?: string | undefined; + status: "ok" | "error"; + durationMs: number; + principalId: string; + /** HTTP User-Agent of the calling MCP client. Absent for `agent` surface. */ + client?: string | undefined; + /** MCP transport session id, or fallback hash(principal + client + window). */ + sessionId: string; + context: McpAnalyticsContext; + /** Tool request payload (stored as an Axiom map field). */ + input?: unknown; + /** Tool result payload (stored as an Axiom map field). */ + output?: unknown; + error?: string | undefined; +}; + +/** + * Pluggable destination for analytics events. Implementations must be + * non-blocking: `emit` runs on the hot path of every tool call and must never + * throw or await network I/O inline. Swap this (pino/Axiom, an OTEL exporter, + * a test spy) without touching the instrumentation layer. + */ +export interface AnalyticsSink { + emit(event: McpAnalyticsEvent): void; + /** Drain any buffered events. Call on graceful shutdown. */ + flush(): Promise; +} diff --git a/packages/mcp/src/analytics/emitToolEvent.ts b/packages/mcp/src/analytics/emitToolEvent.ts new file mode 100644 index 000000000..86a342968 --- /dev/null +++ b/packages/mcp/src/analytics/emitToolEvent.ts @@ -0,0 +1,78 @@ +import { resolveAutumnOrg } from "../agent/axiom.js"; +import type { AutumnMcpAuth } from "../server/auth/auth.js"; +import { getAnalyticsSink } from "./analyticsSink.js"; +import type { McpAnalyticsSurface } from "./analyticsTypes.js"; +import { deriveSessionId } from "./sessionId.js"; + +/** + * Builds and dispatches a single tool-call analytics event. Org resolution and + * the actual sink write run off the hot path so the tool response is never + * delayed by analytics. + */ +export const emitMcpToolEvent = ({ + surface, + toolId, + auth, + client, + transportSessionId, + intent, + status, + durationMs, + input, + output, + error, +}: { + surface: McpAnalyticsSurface; + toolId: string; + auth: AutumnMcpAuth; + client: string | undefined; + transportSessionId?: string | undefined; + intent?: string | undefined; + status: "ok" | "error"; + durationMs: number; + input?: unknown; + output?: unknown; + error?: string | undefined; +}) => { + const sink = getAnalyticsSink(); + + // Resolve org off the hot path; resolveAutumnOrg is cached (~5min). + void (async () => { + let orgId = auth.orgId; + let orgSlug: string | undefined; + try { + const org = await resolveAutumnOrg(auth); + orgId = org.id; + orgSlug = org.slug; + } catch { + // Best-effort: emit without org context rather than dropping the event. + } + const now = Date.now(); + sink.emit({ + event: "mcp.tool_call", + surface, + tool: toolId, + intent, + status, + durationMs, + principalId: auth.principalId, + client, + sessionId: + transportSessionId ?? + deriveSessionId({ + principalId: auth.principalId, + client, + now, + }), + context: { + orgId, + orgSlug, + env: auth.env, + scopes: auth.scopes, + }, + input, + output, + error, + }); + })(); +}; diff --git a/packages/mcp/src/analytics/index.ts b/packages/mcp/src/analytics/index.ts new file mode 100644 index 000000000..7c2769d98 --- /dev/null +++ b/packages/mcp/src/analytics/index.ts @@ -0,0 +1,15 @@ +export { + getAnalyticsSink, + isAnalyticsEnabled, + setAnalyticsSink, +} from "./analyticsSink.js"; +export type { + AnalyticsSink, + McpAnalyticsEvent, + McpAnalyticsSurface, +} from "./analyticsTypes.js"; +export { instrumentToolsWithAnalytics } from "./instrumentTools.js"; +export { + createAxiomAnalyticsSink, + createLoggerAnalyticsSink, +} from "./loggerSink.js"; diff --git a/packages/mcp/src/analytics/instrumentTools.ts b/packages/mcp/src/analytics/instrumentTools.ts new file mode 100644 index 000000000..a7c989391 --- /dev/null +++ b/packages/mcp/src/analytics/instrumentTools.ts @@ -0,0 +1,115 @@ +import type { createTool } from "@mastra/core/tools"; +import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js"; +import { getIntent } from "../tools/utils/intent.js"; +import { isAnalyticsEnabled } from "./analyticsSink.js"; +import type { McpAnalyticsSurface } from "./analyticsTypes.js"; +import { emitMcpToolEvent } from "./emitToolEvent.js"; + +type AnyTool = ReturnType; +type ToolContext = Parameters>[1]; + +const getHeadersFromContext = ( + context: ToolContext, +): Record | undefined => { + const extra = ( + context as { + mcp?: { + extra?: { + requestInfo?: { headers?: Record }; + }; + }; + } + )?.mcp?.extra; + return extra?.requestInfo?.headers; +}; + +const getHeader = ( + headers: Record | undefined, + name: string, +): string | undefined => { + const direct = headers?.[name] ?? headers?.[name.toLowerCase()]; + if (direct) return direct; + const entry = Object.entries(headers ?? {}).find( + ([key]) => key.toLowerCase() === name.toLowerCase(), + ); + return entry?.[1]; +}; + +const extractRequest = (input: unknown): unknown => + input && typeof input === "object" && "request" in input + ? (input as { request: unknown }).request + : input; + +/** + * Wraps each tool's `execute` to emit a usage event per call. Auth/identity is + * read from the same MCP context the tools already use, so an unauthenticated + * call simply skips analytics (it would have failed in the tool anyway). + * + * Tools are wrapped once when the MCP server is created. The wrapper keeps no + * per-request mutable state; auth/session data is read from the execution + * context for each tool call. + * + * @param tools The toolset to instrument (mutated in place and returned). + * @param surface Origin of the calls — `mcp` (external clients) or `agent` + * (our own Autumn Ops agent). + */ +export const instrumentToolsWithAnalytics = < + T extends Record, +>({ + tools, + surface, +}: { + tools: T; + surface: McpAnalyticsSurface; +}): T => { + if (!isAnalyticsEnabled()) return tools; + + for (const [toolId, tool] of Object.entries(tools)) { + const original = tool.execute; + if (!original) continue; + tool.execute = (async (input: unknown, context: ToolContext) => { + const started = Date.now(); + let auth: AutumnMcpAuth | undefined; + try { + auth = getAutumnAuth(context); + } catch { + return original(input as never, context as never); + } + const headers = getHeadersFromContext(context); + const client = getHeader(headers, "user-agent"); + const transportSessionId = getHeader(headers, "mcp-session-id"); + const intent = getIntent(input); + try { + const output = await original(input as never, context as never); + emitMcpToolEvent({ + surface, + toolId, + auth, + client, + transportSessionId, + intent, + status: "ok", + durationMs: Date.now() - started, + input: extractRequest(input), + output, + }); + return output; + } catch (error) { + emitMcpToolEvent({ + surface, + toolId, + auth, + client, + transportSessionId, + intent, + status: "error", + durationMs: Date.now() - started, + input: extractRequest(input), + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }) as AnyTool["execute"]; + } + return tools; +}; diff --git a/packages/mcp/src/analytics/loggerSink.ts b/packages/mcp/src/analytics/loggerSink.ts new file mode 100644 index 000000000..6fb15c4fc --- /dev/null +++ b/packages/mcp/src/analytics/loggerSink.ts @@ -0,0 +1,60 @@ +import { asAxiomMap, createLogger, guardLogPayload } from "@autumn/logging"; +import type { AnalyticsSink, McpAnalyticsEvent } from "./analyticsTypes.js"; + +const toLoggerRecord = (event: McpAnalyticsEvent) => ({ + _time: new Date().toISOString(), + event: event.event, + surface: event.surface, + tool: event.tool, + intent: event.intent, + status: event.status, + duration_ms: event.durationMs, + principal_id: event.principalId, + client: event.client, + session_id: event.sessionId, + context: { + org_id: event.context.orgId, + org_slug: event.context.orgSlug, + env: event.context.env, + scopes: event.context.scopes, + }, + input: asAxiomMap({ value: guardLogPayload({ value: event.input }) }), + output: asAxiomMap({ value: guardLogPayload({ value: event.output }) }), + error: event.error, +}); + +export const createLoggerAnalyticsSink = ({ + token, + orgId, + dataset, +}: { + token?: string | undefined; + orgId?: string | undefined; + dataset: string; +}): AnalyticsSink | null => { + if (!token) return null; + const logger = createLogger({ + service: "mcp", + dataset, + preset: "axiom-only", + outputs: ["axiom"], + axiomToken: token, + axiomOrgId: orgId, + }); + + return { + emit(event) { + logger.info(toLoggerRecord(event), "MCP tool call"); + }, + flush: async () => { + await new Promise((resolve) => { + const flush = logger.flush; + if (typeof flush !== "function") return resolve(); + flush.call(logger, () => resolve()); + }); + }, + }; +}; + +/** @deprecated Use createLoggerAnalyticsSink. */ +export const createAxiomAnalyticsSink = createLoggerAnalyticsSink; diff --git a/packages/mcp/src/analytics/sessionId.ts b/packages/mcp/src/analytics/sessionId.ts new file mode 100644 index 000000000..717386cfd --- /dev/null +++ b/packages/mcp/src/analytics/sessionId.ts @@ -0,0 +1,23 @@ +import { createHash } from "node:crypto"; +import { ms } from "@autumn/shared/unixUtils"; + +const sessionWindowMs = ms.minutes(30); + +const hash = (value: string) => + createHash("sha256").update(value).digest("hex").slice(0, 32); + +/** + * Fallback session grouping. Stateful MCP clients send Mcp-Session-Id; when it + * is absent, synthesize a coarse principal/client bucket so calls from the same + * client within the window still collapse into one session. + */ +export const deriveSessionId = ({ + principalId, + client, + now, +}: { + principalId: string; + client: string | undefined; + now: number; +}) => + hash(`${principalId}|${client ?? ""}|${Math.floor(now / sessionWindowMs)}`); diff --git a/packages/mcp/src/mcp-server/console-logger.ts b/packages/mcp/src/console-logger.ts similarity index 91% rename from packages/mcp/src/mcp-server/console-logger.ts rename to packages/mcp/src/console-logger.ts index 299a1e0ea..bf1da90f1 100644 --- a/packages/mcp/src/mcp-server/console-logger.ts +++ b/packages/mcp/src/console-logger.ts @@ -16,7 +16,8 @@ export type ConsoleLogger = Record & { export function createConsoleLogger(level: ConsoleLoggerLevel): ConsoleLogger { const min = consoleLoggerLevels.indexOf(level); const noop = () => {}; - const log = (method: "debug" | "info" | "warn" | "error"): LogMethod => + const log = + (method: "debug" | "info" | "warn" | "error"): LogMethod => (message, data) => { if (data) console[method](message, data); else console[method](message); diff --git a/packages/mcp/src/constants.ts b/packages/mcp/src/constants.ts new file mode 100644 index 000000000..f6482c8f2 --- /dev/null +++ b/packages/mcp/src/constants.ts @@ -0,0 +1,18 @@ +import type { ScopeString } from "@autumn/shared/scopeDefinitions"; +import { Scopes } from "@autumn/shared/scopeDefinitions"; + +/** Shared defaults for talking to the Autumn API from the MCP server. */ +export const DEFAULT_AUTUMN_API_URL = "https://api.useautumn.com"; +export const DEFAULT_API_VERSION = "2.3.0"; + +/** Scopes requested when exchanging an OAuth token for an Autumn API key. */ +export const MCP_OAUTH_SCOPES = [ + Scopes.Customers.Read, + Scopes.Customers.Write, + Scopes.Plans.Read, + Scopes.Plans.Write, + Scopes.Billing.Read, + Scopes.Billing.Write, + Scopes.Balances.Write, + Scopes.Analytics.Read, +] as const satisfies readonly ScopeString[]; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 598a83854..50afd12df 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,19 +1,28 @@ export { - consoleLoggerLevels, - createConsoleLogger, + type AnalyticsSink, + createAxiomAnalyticsSink, + getAnalyticsSink, + isAnalyticsEnabled, + type McpAnalyticsEvent, + type McpAnalyticsSurface, + setAnalyticsSink, +} from "./analytics/index.js"; +export { type ConsoleLogger, type ConsoleLoggerLevel, -} from "./mcp-server/console-logger.js"; + consoleLoggerLevels, + createConsoleLogger, +} from "./console-logger.js"; export { - createAskAutumnMCPServer, - createAutumnOperationsMCPServer, - createMCPServer, -} from "./mcp-server/agent/server.js"; -export type { MCPServerFlags } from "./mcp-server/flags.js"; + DEFAULT_API_VERSION, + DEFAULT_AUTUMN_API_URL, + MCP_OAUTH_SCOPES, +} from "./constants.js"; export { - buildAuthForRequest, - getAuthorizationServerMetadata, - getProtectedResourceMetadata, - OAuthHttpError, + type AutumnMcpAuth, + createRequestContext, + environmentSchema, type OAuthEnvironment, -} from "./mcp-server/oauth.js"; +} from "./server/auth/auth.js"; +export type { MCPServerFlags } from "./server/flags.js"; +export { createAutumnOperationsMCPServer } from "./server/server.js"; diff --git a/packages/mcp/src/mcp-server/agent/ask-autumn.test.ts b/packages/mcp/src/mcp-server/agent/ask-autumn.test.ts deleted file mode 100644 index 468afef42..000000000 --- a/packages/mcp/src/mcp-server/agent/ask-autumn.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import type { AutumnMcpAuth } from "./auth.js"; -import { setPendingActionsRedis } from "./pending-actions.js"; -import { createTestRedis } from "./test-redis.js"; - -const systemPrompts: string[] = []; -let agentConfirms = true; -let agentCalls = 0; - -mock.module("@mastra/core/agent", () => ({ - Agent: class { - private readonly tools: Record; - - constructor(config: { tools: Record }) { - this.tools = config.tools; - } - - async generate( - message: string, - options: { - requestContext: unknown; - context: { content: string }[]; - }, - ) { - agentCalls += 1; - const systemPrompt = options.context[0]?.content ?? ""; - systemPrompts.push(systemPrompt); - const context = { requestContext: options.requestContext }; - if (message.toLowerCase().includes("customers")) { - const result = await this.tools.listCustomers.execute?.( - { request: {} }, - context, - ); - return { text: JSON.stringify(result) }; - } - - if (agentConfirms && systemPrompt.includes("Pending billing action")) { - const result = await this.tools.confirmBillingAction.execute?.( - {}, - context, - ); - return { text: JSON.stringify(result) }; - } - if (systemPrompt.includes("Pending billing action")) { - return { text: "There is no pending billing action to confirm." }; - } - - const result = await this.tools.previewAttach.execute?.( - { request: { customer_id: "cus_1", plan_id: "pro" } }, - context, - ); - return { text: JSON.stringify(result) }; - } - }, -})); - -const { createAskAutumnTool } = await import("./ask-autumn.js"); - -const auth: AutumnMcpAuth = { - apiKey: "sk_test", - env: "sandbox", - principalId: "user_1", - resource: "http://localhost:2718/mcp", - scopes: ["billing:read", "billing:write"], - serverURL: "http://localhost:8080", -}; - -const mockFetch = (calls: { url: string; body: unknown }[]) => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url, init) => { - const body = JSON.parse(init?.body as string); - calls.push({ url: String(url), body }); - - if (String(url).endsWith("/v1/billing.preview_attach")) { - return Response.json({ total: 50 }); - } - - if (String(url).endsWith("/v1/billing.attach")) { - return Response.json({ applied: true }); - } - - if (String(url).endsWith("/v1/customers.list")) { - return Response.json({ customers: [] }); - } - - return Response.json({ error: "unexpected" }, { status: 500 }); - }) as typeof fetch; - return () => { - globalThis.fetch = originalFetch; - }; -}; - -describe("ask_autumn billing confirmation flow", () => { - test("confirms a pending attach across separate ask_autumn calls", async () => { - setPendingActionsRedis(createTestRedis()); - systemPrompts.length = 0; - agentConfirms = true; - agentCalls = 0; - const calls: { url: string; body: unknown }[] = []; - const restoreFetch = mockFetch(calls); - - try { - const tool = createAskAutumnTool(); - if (!tool.execute) throw new Error("ask_autumn is not executable"); - const context = { mcp: { extra: { authInfo: auth } } } as never; - - const preview = await tool.execute( - { message: "attach pro to cus_1" }, - context, - ); - expect(String(preview)).toContain("Preview ready"); - expect(systemPrompts.at(-1)).not.toContain("Pending billing action"); - expect(calls.map((call) => call.url)).toEqual([ - "http://localhost:8080/v1/billing.preview_attach", - ]); - - const confirm = await tool.execute({ message: "confirm" }, context); - expect(String(confirm)).toContain("Confirmed and applied attach."); - expect(calls).toEqual([ - { - url: "http://localhost:8080/v1/billing.preview_attach", - body: { - customer_id: "cus_1", - plan_id: "pro", - redirect_mode: "if_required", - }, - }, - { - url: "http://localhost:8080/v1/billing.attach", - body: { - customer_id: "cus_1", - plan_id: "pro", - redirect_mode: "if_required", - }, - }, - ]); - } finally { - restoreFetch(); - } - }); - - test("semantic confirmation gets the pending preview context", async () => { - setPendingActionsRedis(createTestRedis()); - systemPrompts.length = 0; - agentConfirms = true; - agentCalls = 0; - const calls: { url: string; body: unknown }[] = []; - const restoreFetch = mockFetch(calls); - - try { - const tool = createAskAutumnTool(); - if (!tool.execute) throw new Error("ask_autumn is not executable"); - const context = { mcp: { extra: { authInfo: auth } } } as never; - - await tool.execute({ message: "attach pro to cus_1" }, context); - expect(agentCalls).toBe(1); - - const confirm = await tool.execute( - { message: "that looks good, go ahead" }, - context, - ); - expect(String(confirm)).toContain("Confirmed and applied attach."); - expect(agentCalls).toBe(2); - expect(systemPrompts.at(-1)).toContain("Pending billing action:"); - expect(systemPrompts.at(-1)).toContain("Preview:"); - expect(systemPrompts.at(-1)).toContain('"total":50'); - expect(calls.map((call) => call.url)).toEqual([ - "http://localhost:8080/v1/billing.preview_attach", - "http://localhost:8080/v1/billing.attach", - ]); - } finally { - restoreFetch(); - } - }); - - test("question-like confirmation text does not bypass the agent", async () => { - setPendingActionsRedis(createTestRedis()); - systemPrompts.length = 0; - agentConfirms = false; - agentCalls = 0; - const calls: { url: string; body: unknown }[] = []; - const restoreFetch = mockFetch(calls); - - try { - const tool = createAskAutumnTool(); - if (!tool.execute) throw new Error("ask_autumn is not executable"); - const context = { mcp: { extra: { authInfo: auth } } } as never; - - await tool.execute({ message: "attach pro to cus_1" }, context); - const response = await tool.execute( - { message: "can you confirm what this changes?" }, - context, - ); - - expect(String(response)).toContain("no pending billing action"); - expect(agentCalls).toBe(2); - expect(systemPrompts.at(-1)).toContain("Pending billing action:"); - expect(calls.map((call) => call.url)).toEqual([ - "http://localhost:8080/v1/billing.preview_attach", - ]); - } finally { - restoreFetch(); - } - }); - - test("read requests continue when pending lookup fails", async () => { - setPendingActionsRedis({ - multi: () => { - throw new Error("unavailable"); - }, - get: async () => { - throw new Error("unavailable"); - }, - getdel: async () => { - throw new Error("unavailable"); - }, - del: async () => undefined, - keys: async () => [], - }); - systemPrompts.length = 0; - agentConfirms = true; - agentCalls = 0; - const calls: { url: string; body: unknown }[] = []; - const restoreFetch = mockFetch(calls); - - try { - const tool = createAskAutumnTool(); - if (!tool.execute) throw new Error("ask_autumn is not executable"); - const context = { mcp: { extra: { authInfo: auth } } } as never; - - const response = await tool.execute({ message: "list customers" }, context); - - expect(String(response)).toContain("customers"); - expect(agentCalls).toBe(1); - expect(calls.map((call) => call.url)).toEqual([ - "http://localhost:8080/v1/customers.list", - ]); - } finally { - restoreFetch(); - } - }); -}); diff --git a/packages/mcp/src/mcp-server/agent/ask-autumn.ts b/packages/mcp/src/mcp-server/agent/ask-autumn.ts deleted file mode 100644 index 179e10cb0..000000000 --- a/packages/mcp/src/mcp-server/agent/ask-autumn.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Agent } from "@mastra/core/agent"; -import { createTool } from "@mastra/core/tools"; -import * as z from "zod/v4"; -import { - type AutumnMcpAuth, - createRequestContext, - getAutumnAuth, -} from "./auth.js"; -import { getLatestPendingAction } from "./pending-actions.js"; -import { createAgentAutumnOperationTools } from "./tools.js"; - -const model = "anthropic/claude-sonnet-4-6"; - -const instructions = `You are Autumn's operational billing assistant. -Use Autumn tools for customer, plan, and billing work. -Use Axiom tools only for read-only investigation of Autumn logs. - -Rules: -- Read requests can be answered directly. -- For customer lookup, use listCustomers first when the id/email/name is ambiguous. -- For plan lookup, use listPlans first when the plan is ambiguous. -- For billing changes, call previewAttach or previewUpdateSubscription first. These preview tools automatically create the pending billing action. -- Never expose internal ids or server bookkeeping details. -- After a billing preview, tell the user to explicitly apply or approve the exact previewed change. -- If the user semantically confirms, applies, or approves a billing preview, call confirmBillingAction even if the preview is not visible in the current message. The tool validates whether a pending action exists. -- Never claim a billing write has been applied unless confirmBillingAction succeeds. -- If customer, plan, entity, subscription, or environment is ambiguous, ask a short clarifying question. -- Keep responses concise. Use JSON only when it materially helps debugging.`; - -// To be added when we add axiom: -// - For log investigations, start with narrow structured fields such as context.customer_id, context.org_slug, req.url, req.id, stripe_event.id, stripe_event.type, workflow.id, or workflow.name. -// - For wide log windows, use a cheap aggregate query first, then focused <= 1 hour queries. Prefer ERROR/WARN levels first. -// - Axiom queries are already scoped to the authenticated org and environment; do not add or mention separate org filters unless useful to explain the investigation. -// - Axiom tools are read-only and must never be used as part of a billing confirmation or write flow. - -const createAgent = () => - new Agent({ - id: "autumn-ops", - name: "Autumn Ops", - description: - "Answers Autumn customer, plan, and billing questions using controlled Autumn operations.", - instructions, - model, - tools: createAgentAutumnOperationTools(), - }); - -const getAuth = ( - toolContext: Parameters< - NonNullable["execute"]> - >[1], - defaultAuth?: AutumnMcpAuth, -) => { - try { - return getAutumnAuth(toolContext); - } catch (error) { - if (defaultAuth) return defaultAuth; - throw error; - } -}; - -const getPendingAction = async (auth: AutumnMcpAuth) => { - try { - return await getLatestPendingAction(auth); - } catch { - return null; - } -}; - -export const createAskAutumnTool = (defaultAuth?: AutumnMcpAuth) => - createTool({ - id: "ask_autumn", - description: - "Ask Autumn to look up customers/plans or safely preview and confirm billing changes.", - inputSchema: z.object({ - message: z.string().min(1), - context: z.record(z.string(), z.unknown()).optional(), - }), - mcp: { - annotations: { - title: "Ask Autumn", - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false, - }, - }, - execute: async ({ message, context }, toolContext) => { - const auth = getAuth(toolContext, defaultAuth); - const pendingAction = await getPendingAction(auth); - const contextText = context - ? `\n\nCaller context:\n${JSON.stringify(context, null, 2)}` - : ""; - const pendingText = pendingAction - ? `\n\nPending billing action:\nTool: ${pendingAction.toolName}\nPreview: ${pendingAction.preview}\nIf the user confirms this preview, call confirmBillingAction.` - : ""; - const output = await createAgent().generate(message, { - maxSteps: 8, - requestContext: createRequestContext(auth), - context: [ - { - role: "system", - content: `Current Autumn environment: ${auth.env}.${pendingText}${contextText}`, - }, - ], - }); - - return output.text; - }, - }); - -export const askAutumnTool = createAskAutumnTool(); diff --git a/packages/mcp/src/mcp-server/agent/auth.ts b/packages/mcp/src/mcp-server/agent/auth.ts deleted file mode 100644 index aba42a7ab..000000000 --- a/packages/mcp/src/mcp-server/agent/auth.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createHash } from "node:crypto"; -import { RequestContext } from "@mastra/core/request-context"; -import type { ToolExecutionContext } from "@mastra/core/tools"; -import type { OAuthEnvironment } from "../oauth.js"; - -export type AutumnMcpAuth = { - apiKey: string; - env: OAuthEnvironment; - principalId: string; - resource: string; - scopes: string[]; - orgId?: string | undefined; - serverURL?: string | undefined; - xApiVersion?: string | undefined; - failOpen?: boolean | undefined; -}; - -type MaybeToolContext = Pick; - -const hash = (value: string) => - createHash("sha256").update(value).digest("hex").slice(0, 32); - -export const principalFromSecret = (kind: string, value: string) => - `${kind}:${hash(value)}`; - -export const createAutumnClient = (auth: AutumnMcpAuth) => ({ - baseUrl: auth.serverURL ?? "https://api.useautumn.com", - headers: { - Authorization: `Bearer ${auth.apiKey}`, - "Content-Type": "application/json", - Accept: "application/json", - "x-api-version": auth.xApiVersion ?? "2.3.0", - ...(auth.failOpen === undefined - ? {} - : { "fail-open": String(auth.failOpen) }), - }, -}); - -export const getAutumnAuth = (context?: MaybeToolContext): AutumnMcpAuth => { - const direct = context?.mcp?.extra?.authInfo as AutumnMcpAuth | undefined; - const nested = context?.requestContext?.get?.("mcp.extra") as - | { authInfo?: AutumnMcpAuth } - | undefined; - const auth = direct ?? nested?.authInfo; - if (!auth?.apiKey) throw new Error("Autumn MCP authentication is required."); - return auth; -}; - -export const createRequestContext = (auth: AutumnMcpAuth) => { - const requestContext = new RequestContext(); - requestContext.set("mcp.extra", { authInfo: auth }); - return requestContext; -}; diff --git a/packages/mcp/src/mcp-server/agent/server.test.ts b/packages/mcp/src/mcp-server/agent/server.test.ts deleted file mode 100644 index 713fbdb54..000000000 --- a/packages/mcp/src/mcp-server/agent/server.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - createAskAutumnMCPServer, - createAutumnOperationsMCPServer, -} from "./server.js"; - -describe("Autumn MCP server", () => { - test("public server advertises raw operation tools", async () => { - const tools = await createAutumnOperationsMCPServer().getToolListInfo(); - - expect(tools.tools.map((tool) => tool.name)).toEqual([ - "listCustomers", - "getCustomer", - "listPlans", - "getPlan", - "previewAttach", - "previewUpdateSubscription", - "attach", - "updateSubscription", - ]); - expect(tools.tools.map((tool) => tool.name)).not.toContain("ask_autumn"); - expect(tools.tools.map((tool) => tool.name)).not.toContain( - "confirmBillingAction", - ); - }); - - test("internal server advertises only ask_autumn", async () => { - const tools = await createAskAutumnMCPServer().getToolListInfo(); - - expect(tools.tools.map((tool) => tool.name)).toEqual(["ask_autumn"]); - expect(tools.tools.map((tool) => tool.name)).not.toContain("attach"); - expect(tools.tools.map((tool) => tool.name)).not.toContain("listCustomers"); - }); -}); diff --git a/packages/mcp/src/mcp-server/agent/server.ts b/packages/mcp/src/mcp-server/agent/server.ts deleted file mode 100644 index 27956e205..000000000 --- a/packages/mcp/src/mcp-server/agent/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { MCPServer } from "@mastra/mcp"; -import { createAskAutumnTool } from "./ask-autumn.js"; -import type { AutumnMcpAuth } from "./auth.js"; -import { createRawAutumnOperationTools } from "./tools.js"; - -export const createAskAutumnMCPServer = (_opts?: { - defaultAuth?: AutumnMcpAuth; -}) => - new MCPServer({ - id: "autumn-internal-mcp", - name: "Autumn Internal MCP", - version: "0.0.1", - description: "Ask Autumn to safely operate on customers, plans, and billing.", - instructions: - "Use ask_autumn for all Autumn work. Billing writes require preview and explicit user confirmation.", - tools: { - ask_autumn: createAskAutumnTool(_opts?.defaultAuth), - }, - }); - -export const createAutumnOperationsMCPServer = () => - new MCPServer({ - id: "autumn-mcp", - name: "Autumn MCP", - version: "0.0.1", - description: "Operate on Autumn customers, plans, and billing.", - instructions: - "Use preview tools before billing writes. Write tools are destructive and should only be called after explicit user confirmation.", - tools: createRawAutumnOperationTools(), - }); - -export const createMCPServer = createAskAutumnMCPServer; diff --git a/packages/mcp/src/mcp-server/agent/tools.test.ts b/packages/mcp/src/mcp-server/agent/tools.test.ts deleted file mode 100644 index a8a183bc8..000000000 --- a/packages/mcp/src/mcp-server/agent/tools.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { AutumnMcpAuth } from "./auth.js"; -import { - clearPendingActions, - claimLatestPendingAction, - createPendingAction, - setPendingActionsRedis, -} from "./pending-actions.js"; -import { createTestRedis } from "./test-redis.js"; -import { - createAgentAutumnOperationTools, - createRawAutumnOperationTools, -} from "./tools.js"; - -setPendingActionsRedis(createTestRedis()); - -const auth: AutumnMcpAuth = { - apiKey: "sk_test", - env: "sandbox", - principalId: "user_1", - resource: "http://localhost:2718/mcp", - scopes: ["billing:read", "billing:write"], - serverURL: "http://localhost:8080", -}; - -describe("Autumn operation tools", () => { - test("raw listCustomers calls the list endpoint", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url, init) => { - expect(String(url)).toBe("http://localhost:8080/v1/customers.list"); - expect(JSON.parse(init?.body as string)).toMatchObject({ - search: "charlie", - }); - return Response.json({ customers: [] }); - }) as typeof fetch; - - try { - const tool = createRawAutumnOperationTools().listCustomers; - if (!tool.execute) throw new Error("listCustomers is not executable"); - - await expect( - tool.execute( - { request: { search: "charlie" } }, - { mcp: { extra: { authInfo: auth } } } as never, - ), - ).resolves.toEqual({ customers: [] }); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("raw previewAttach does not create a pending action", async () => { - await clearPendingActions(); - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url, init) => { - expect(String(url)).toBe("http://localhost:8080/v1/billing.preview_attach"); - expect(JSON.parse(init?.body as string)).toEqual({ - customer_id: "cus_1", - plan_id: "pro", - redirect_mode: "if_required", - }); - return Response.json({ total: 50 }); - }) as typeof fetch; - - try { - const tool = createRawAutumnOperationTools().previewAttach; - if (!tool.execute) throw new Error("previewAttach is not executable"); - - await expect( - tool.execute( - { request: { customer_id: "cus_1", plan_id: "pro" } }, - { mcp: { extra: { authInfo: auth } } } as never, - ), - ).resolves.toEqual({ total: 50 }); - await expect(claimLatestPendingAction(auth)).rejects.toThrow("No pending"); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("raw attach calls the write endpoint directly", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url, init) => { - expect(String(url)).toBe("http://localhost:8080/v1/billing.attach"); - expect(JSON.parse(init?.body as string)).toEqual({ - customer_id: "cus_1", - plan_id: "pro", - redirect_mode: "if_required", - }); - return Response.json({ ok: true }); - }) as typeof fetch; - - try { - const tool = createRawAutumnOperationTools().attach; - if (!tool.execute) throw new Error("attach is not executable"); - - await expect( - tool.execute( - { request: { customer_id: "cus_1", plan_id: "pro" } }, - { mcp: { extra: { authInfo: auth } } } as never, - ), - ).resolves.toEqual({ ok: true }); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("previewAttach stores the exact pending attach action", async () => { - await clearPendingActions(); - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url, init) => { - expect(String(url)).toBe("http://localhost:8080/v1/billing.preview_attach"); - expect(JSON.parse(init?.body as string)).toEqual({ - customer_id: "cus_1", - plan_id: "pro", - redirect_mode: "if_required", - }); - return Response.json({ total: 50 }); - }) as typeof fetch; - - try { - const tool = ( - createAgentAutumnOperationTools() as unknown as { - previewAttach: { - execute?: (input: unknown, context: unknown) => Promise; - }; - } - ).previewAttach; - if (!tool.execute) throw new Error("previewAttach is not executable"); - - await expect( - tool.execute( - { request: { customer_id: "cus_1", plan_id: "pro" } }, - { mcp: { extra: { authInfo: auth } } } as never, - ), - ).resolves.toMatchObject({ pending: true, preview: { total: 50 } }); - - await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({ - toolName: "attach", - request: { - customer_id: "cus_1", - plan_id: "pro", - redirect_mode: "if_required", - }, - }); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("confirmBillingAction executes only the stored pending billing action", async () => { - await clearPendingActions(); - await createPendingAction({ - auth, - toolName: "attach", - request: { customer_id: "cus_1", plan_id: "pro" }, - preview: "Attach pro", - }); - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url, init) => { - expect(String(url)).toBe("http://localhost:8080/v1/billing.attach"); - expect(JSON.parse(init?.body as string)).toEqual({ - customer_id: "cus_1", - plan_id: "pro", - redirect_mode: "if_required", - }); - return Response.json({ ok: true }); - }) as typeof fetch; - - try { - const tool = createAgentAutumnOperationTools().confirmBillingAction; - if (!tool.execute) throw new Error("confirmBillingAction is not executable"); - - await expect( - tool.execute({}, { mcp: { extra: { authInfo: auth } } } as never), - ).resolves.toMatchObject({ - message: "Confirmed and applied attach.", - result: { ok: true }, - }); - await expect(claimLatestPendingAction(auth)).rejects.toThrow("No pending"); - } finally { - globalThis.fetch = originalFetch; - } - }); -}); diff --git a/packages/mcp/src/mcp-server/agent/tools.ts b/packages/mcp/src/mcp-server/agent/tools.ts deleted file mode 100644 index ad94824df..000000000 --- a/packages/mcp/src/mcp-server/agent/tools.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { - AttachParamsV1Schema, - GetCustomerParamsV1Schema, - GetPlanParamsV0Schema, - ListCustomersV2_3ParamsSchema, - ListPlanParamsSchema, - UpdateSubscriptionV1ParamsSchema, -} from "@autumn/shared/publicApiSchemas"; -import { createTool } from "@mastra/core/tools"; -import * as z from "zod/v4"; -import { createAutumnClient, getAutumnAuth } from "./auth.js"; -import { - claimLatestPendingAction, - createPendingAction, -} from "./pending-actions.js"; - -type ToolContext = Parameters< - NonNullable["execute"]> ->[1]; -type BillingWriteToolName = "attach" | "updateSubscription"; -type OperationToolConfig = { - id: string; - description: string; - schema: z.ZodType; - endpoint: string; - destructive?: boolean; -}; -type BillingPreviewToolConfig = { - id: string; - description: string; - schema: z.ZodType; - previewEndpoint: string; - writeToolName: BillingWriteToolName; -}; - -const endpointByTool = { - listCustomers: "/v1/customers.list", - getCustomer: "/v1/customers.get", - listPlans: "/v1/plans.list", - getPlan: "/v1/plans.get", - previewAttach: "/v1/billing.preview_attach", - attach: "/v1/billing.attach", - previewUpdateSubscription: "/v1/billing.preview_update", - updateSubscription: "/v1/billing.update", -} as const; - -const billingWriteSchemaByTool = { - attach: AttachParamsV1Schema, - updateSubscription: UpdateSubscriptionV1ParamsSchema, -} as const satisfies Record; - -const toolConfigs: OperationToolConfig[] = [ - { - id: "listCustomers", - description: - "List Autumn customers. Use search to find a customer by id, name, or email.", - schema: ListCustomersV2_3ParamsSchema, - endpoint: endpointByTool.listCustomers, - }, - { - id: "getCustomer", - description: "Fetch one Autumn customer by id.", - schema: GetCustomerParamsV1Schema, - endpoint: endpointByTool.getCustomer, - }, - { - id: "listPlans", - description: "List Autumn plans.", - schema: ListPlanParamsSchema, - endpoint: endpointByTool.listPlans, - }, - { - id: "getPlan", - description: "Fetch one Autumn plan by id and optional version.", - schema: GetPlanParamsV0Schema, - endpoint: endpointByTool.getPlan, - }, -]; - -const billingPreviewConfigs: BillingPreviewToolConfig[] = [ - { - id: "previewAttach", - description: - "Preview attaching a plan to a customer.", - schema: AttachParamsV1Schema, - previewEndpoint: endpointByTool.previewAttach, - writeToolName: "attach", - }, - { - id: "previewUpdateSubscription", - description: "Preview updating a subscription.", - schema: UpdateSubscriptionV1ParamsSchema, - previewEndpoint: endpointByTool.previewUpdateSubscription, - writeToolName: "updateSubscription", - }, -]; - -const billingWriteConfigs: OperationToolConfig[] = [ - { - id: "attach", - description: "Attach a plan to a customer.", - schema: AttachParamsV1Schema, - endpoint: endpointByTool.attach, - destructive: true, - }, - { - id: "updateSubscription", - description: "Update a customer subscription.", - schema: UpdateSubscriptionV1ParamsSchema, - endpoint: endpointByTool.updateSubscription, - destructive: true, - }, -]; - -const callAutumn = async ({ - context, - endpoint, - request, -}: { - context?: ToolContext; - endpoint: string; - request: unknown; -}) => { - const auth = getAutumnAuth(context); - const client = createAutumnClient(auth); - const init: RequestInit = { - method: "POST", - headers: client.headers, - body: JSON.stringify(request), - }; - if (context?.mcp?.extra?.signal) init.signal = context.mcp.extra.signal; - const response = await fetch(new URL(endpoint, client.baseUrl), init); - const text = await response.text(); - const body = text ? parseBody(text) : null; - if (!response.ok) { - throw new Error( - `Autumn API request failed (${response.status}): ${typeof body === "string" ? body : JSON.stringify(body)}`, - ); - } - return body; -}; - -const parseBody = (text: string): unknown => { - try { - return JSON.parse(text); - } catch { - return text; - } -}; -const logTool = (event: string, data: Record) => { - if (process.env.MCP_DEBUG_PENDING_ACTIONS !== "1") return; - console.log(`[mcp:agent-tools] ${event} ${JSON.stringify(data)}`); -}; - -const mcpAnnotations = (destructive = false) => ({ - readOnlyHint: !destructive, - destructiveHint: destructive, - idempotentHint: false, - openWorldHint: false, -}); - -const toTools = ( - configs: Config[], - create: (config: Config) => ReturnType, -) => Object.fromEntries(configs.map((config) => [config.id, create(config)])); - -const operationTool = ({ - id, - description, - schema, - endpoint, - destructive = false, -}: OperationToolConfig) => - createTool({ - id, - description, - inputSchema: z.object({ request: schema }).strict(), - mcp: { - annotations: mcpAnnotations(destructive), - }, - execute: (input, context) => - callAutumn({ - context, - endpoint, - request: (input as { request: unknown }).request, - }), - }); - -const agentBillingPreviewTool = ({ - id, - description, - schema, - previewEndpoint, - writeToolName, -}: { - id: string; - description: string; - schema: z.ZodType; - previewEndpoint: string; - writeToolName: BillingWriteToolName; -}) => - createTool({ - id, - description: `${description} Store the exact pending billing action for later confirmation.`, - inputSchema: z.object({ request: schema }).strict(), - mcp: { - annotations: mcpAnnotations(), - }, - execute: async (input, context) => { - const request = (input as { request: unknown }).request; - const auth = getAutumnAuth(context); - logTool("preview-start", { previewTool: id, writeToolName }); - const preview = await callAutumn({ - context, - endpoint: previewEndpoint, - request, - }); - await createPendingAction({ - auth, - toolName: writeToolName, - request, - preview: JSON.stringify(preview), - }); - logTool("preview-stored", { previewTool: id, writeToolName }); - return { - preview, - pending: true, - message: - "Preview ready. Ask the user to explicitly apply or approve this exact change.", - }; - }, - }); - -export const createRawAutumnOperationTools = () => ({ - ...toTools(toolConfigs, operationTool), - ...toTools(billingPreviewConfigs, (config) => - operationTool({ ...config, endpoint: config.previewEndpoint }), - ), - ...toTools(billingWriteConfigs, operationTool), -}); - -export const createAgentAutumnOperationTools = () => ({ - ...toTools(toolConfigs, operationTool), - ...toTools(billingPreviewConfigs, agentBillingPreviewTool), - confirmBillingAction: createTool({ - id: "confirmBillingAction", - description: - "Apply the latest pending billing action after the user semantically confirms the preview.", - inputSchema: z.object({}).strict(), - execute: async (_input, context) => { - const auth = getAutumnAuth(context); - logTool("confirm-start", { env: auth.env }); - const action = await claimLatestPendingAction(auth); - logTool("confirm-claimed", { toolName: action.toolName }); - const result = await executeConfirmedBillingAction({ - auth, - toolName: action.toolName, - request: action.request, - }); - return { - message: `Confirmed and applied ${action.toolName}.`, - result, - }; - }, - }), -}); - -export const executeConfirmedBillingAction = async ({ - auth, - toolName, - request, -}: { - auth: ReturnType; - toolName: BillingWriteToolName; - request: unknown; -}) => - callAutumn({ - context: { mcp: { extra: { authInfo: auth } } } as never, - endpoint: endpointByTool[toolName], - request: billingWriteSchemaByTool[toolName].parse(request), - }); diff --git a/packages/mcp/src/mcp-server/oauth.test.ts b/packages/mcp/src/mcp-server/oauth.test.ts deleted file mode 100644 index d6f0ed092..000000000 --- a/packages/mcp/src/mcp-server/oauth.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - buildAuthForRequest, - getProtectedResourceMetadata, - MCP_OAUTH_SCOPES, - OAuthHttpError, - type MCPOAuthFlags, -} from "./oauth.js"; - -const flags = { - "oauth-enabled": true, - "oauth-environment": "sandbox", - "server-url": "http://localhost:8080", -} satisfies Partial; - -const logger = { - warning: () => {}, -} as never; - -describe("MCP OAuth auth resolution", () => { - test("returns a WWW-Authenticate challenge without a bearer token", async () => { - await expect( - buildAuthForRequest( - new Headers({ host: "localhost:2718" }), - flags as MCPOAuthFlags, - logger, - ), - ).rejects.toMatchObject({ - status: 401, - error: "invalid_token", - wwwAuthenticate: - 'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/mcp"', - } satisfies Partial); - }); - - test("returns an internal MCP resource challenge", async () => { - await expect( - buildAuthForRequest( - new Headers({ host: "localhost:2718" }), - flags as MCPOAuthFlags, - logger, - "/internal/mcp", - ), - ).rejects.toMatchObject({ - status: 401, - error: "invalid_token", - wwwAuthenticate: - 'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/internal/mcp"', - } satisfies Partial); - }); - - test("exchanges a bearer token for Autumn API credentials", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_url, init) => { - expect(init?.headers).toEqual({ - Authorization: "Bearer oauth_token", - "Content-Type": "application/json", - }); - expect(JSON.parse(init?.body as string)).toEqual({ - resource: "http://localhost:2718/mcp", - scopes: MCP_OAUTH_SCOPES, - }); - return Response.json({ - sandbox_key: "sk_sandbox", - prod_key: "sk_live", - org_id: "org_123", - user_id: "user_123", - client_id: "client_123", - scopes: MCP_OAUTH_SCOPES, - }); - }) as typeof fetch; - - try { - const auth = await buildAuthForRequest( - new Headers({ - authorization: "Bearer oauth_token", - host: "localhost:2718", - }), - flags as MCPOAuthFlags, - logger, - ); - - expect(auth.apiKey).toBe("sk_sandbox"); - expect(auth.env).toBe("sandbox"); - expect(auth.resource).toBe("http://localhost:2718/mcp"); - expect(auth.principalId).toBe("oauth:org_123:user_123:client_123"); - expect(auth.scopes).toEqual([...MCP_OAUTH_SCOPES]); - expect(auth.orgId).toBe("org_123"); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("uses route-specific resource URLs", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_url, init) => { - expect(JSON.parse(init?.body as string)).toMatchObject({ - resource: "http://localhost:2718/internal/mcp", - }); - return Response.json({ - sandbox_key: "sk_sandbox", - org_id: "org_123", - scopes: MCP_OAUTH_SCOPES, - }); - }) as typeof fetch; - - try { - const auth = await buildAuthForRequest( - new Headers({ - authorization: "Bearer internal_oauth_token", - host: "localhost:2718", - }), - flags as MCPOAuthFlags, - logger, - "/internal/mcp", - ); - - expect(auth.resource).toBe("http://localhost:2718/internal/mcp"); - expect( - getProtectedResourceMetadata( - new Headers({ host: "localhost:2718" }), - flags as MCPOAuthFlags, - "/internal/mcp", - ).resource, - ).toBe("http://localhost:2718/internal/mcp"); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("missing static secret-key returns the auth error path", async () => { - await expect( - buildAuthForRequest( - new Headers({ host: "localhost:2718" }), - { - ...flags, - "oauth-enabled": false, - } as MCPOAuthFlags, - logger, - ), - ).rejects.toMatchObject({ - status: 401, - error: "invalid_token", - } satisfies Partial); - }); -}); diff --git a/packages/mcp/src/mcp-server/oauth.ts b/packages/mcp/src/mcp-server/oauth.ts deleted file mode 100644 index 245176804..000000000 --- a/packages/mcp/src/mcp-server/oauth.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { type ScopeString, Scopes } from "@autumn/shared/scopeDefinitions"; -import { ms } from "@autumn/shared/unixUtils"; -import { addMilliseconds, isFuture } from "date-fns"; -import * as z from "zod/v4"; -import type { AutumnMcpAuth } from "./agent/auth.js"; -import { principalFromSecret } from "./agent/auth.js"; -import type { ConsoleLogger } from "./console-logger.js"; -import type { MCPServerFlags } from "./flags.js"; - -export const MCP_OAUTH_SCOPES = [ - Scopes.Customers.Read, - Scopes.Plans.Read, - Scopes.Billing.Read, - Scopes.Billing.Write, - Scopes.Analytics.Read, -] as const satisfies readonly ScopeString[]; - -const environmentSchema = z.enum(["sandbox", "live"]); -const xApiVersionSchema = z.string().default("2.3.0"); -const failOpenSchema = z - .union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]) - .default(true); -const secretKeySchema = z.string().min(1).optional(); -const tokenExchangeSchema = z.object({ - sandbox_key: z.string().optional(), - prod_key: z.string().optional(), - org_id: z.string().optional(), - user_id: z.string().optional(), - client_id: z.string().optional(), - scopes: z.array(z.string()).optional(), -}); - -export type OAuthEnvironment = z.infer; - -export interface MCPOAuthFlags extends MCPServerFlags { - readonly "oauth-enabled"?: boolean | undefined; - readonly "oauth-environment"?: OAuthEnvironment | undefined; -} - -export class OAuthHttpError extends Error { - constructor( - readonly status: number, - message: string, - readonly error = "invalid_token", - readonly wwwAuthenticate?: string, - ) { - super(message); - } -} - -const apiKeyCache = new Map< - string, - { - key: string; - orgId?: string | undefined; - userId?: string | undefined; - clientId?: string | undefined; - scopes?: string[] | undefined; - expiresAt: Date; - } ->(); - -function trimTrailingSlash(url: string): string { - return url.endsWith("/") ? url.slice(0, -1) : url; -} - -export function getResourceUrl( - headers: Headers, - _flags: MCPOAuthFlags, - resourcePath = "/mcp", -): string { - const host = - headers.get("x-autumn-forwarded-host") ?? - headers.get("x-forwarded-host") ?? - headers.get("host"); - if (!host) { - throw new OAuthHttpError(400, "Missing Host header", "invalid_request"); - } - - const proto = - headers.get("x-autumn-forwarded-proto") ?? - headers.get("x-forwarded-proto") ?? - "http"; - return new URL(resourcePath, `${proto}://${host}`).href; -} - -export function getProtectedResourceMetadataUrl(resourceUrl: string): string { - const url = new URL(resourceUrl); - const path = url.pathname === "/" ? "" : url.pathname; - return new URL(`/.well-known/oauth-protected-resource${path}`, url).href; -} - -function getIssuerUrl(flags: MCPOAuthFlags): string { - return trimTrailingSlash( - new URL("/api/auth", flags["server-url"] ?? "https://api.useautumn.com") - .href, - ); -} - -function getApiKeyUrl(flags: MCPOAuthFlags): string { - return new URL("/cli/api-keys", getIssuerUrl(flags)).href; -} - -function getWWWAuthenticate(resourceUrl: string, error?: string): string { - const params = [ - `resource_metadata="${getProtectedResourceMetadataUrl(resourceUrl)}"`, - ]; - if (error) params.push(`error="${error}"`); - return `Bearer ${params.join(", ")}`; -} - -export function getProtectedResourceMetadata( - headers: Headers, - flags: MCPOAuthFlags, - resourcePath = "/mcp", -) { - const resource = getResourceUrl(headers, flags, resourcePath); - return { - resource, - authorization_servers: [getIssuerUrl(flags)], - scopes_supported: [...MCP_OAUTH_SCOPES], - bearer_methods_supported: ["header"], - resource_name: "Autumn MCP", - }; -} - -export function getAuthorizationServerMetadata(flags: MCPOAuthFlags) { - const issuer = getIssuerUrl(flags); - return { - issuer, - authorization_endpoint: `${issuer}/oauth2/authorize`, - token_endpoint: `${issuer}/oauth2/token`, - registration_endpoint: `${issuer}/oauth2/register`, - revocation_endpoint: `${issuer}/oauth2/revoke`, - introspection_endpoint: `${issuer}/oauth2/introspect`, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - token_endpoint_auth_methods_supported: [ - "client_secret_post", - "client_secret_basic", - "none", - ], - code_challenge_methods_supported: ["S256"], - scopes_supported: [...MCP_OAUTH_SCOPES], - }; -} - -function getEnvironment( - headers: Headers, - flags: MCPOAuthFlags, -): OAuthEnvironment { - const value = - headers.get("x-autumn-environment") ?? - flags["oauth-environment"] ?? - "sandbox"; - const parsed = environmentSchema.safeParse(value); - if (parsed.success) return parsed.data; - - throw new OAuthHttpError( - 400, - "Invalid x-autumn-environment", - "invalid_request", - ); -} - -function parseRequestOption( - value: unknown, - schema: z.ZodType, - message: string, -): T { - const parsed = schema.safeParse(value); - if (parsed.success) return parsed.data; - - throw new OAuthHttpError(400, message, "invalid_request"); -} - -async function exchangeOAuthToken( - headers: Headers, - flags: MCPOAuthFlags, - resource: string, - token: string, -): Promise<{ - key: string; - orgId?: string | undefined; - userId?: string | undefined; - clientId?: string | undefined; - scopes?: string[]; -}> { - const env = getEnvironment(headers, flags); - const cacheKey = `${token}:${resource}:${env}`; - const cached = apiKeyCache.get(cacheKey); - if (cached && isFuture(cached.expiresAt)) return cached; - - const response = await fetch(getApiKeyUrl(flags), { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ resource, scopes: MCP_OAUTH_SCOPES }), - }); - - if (!response.ok) { - throw new OAuthHttpError( - response.status === 403 ? 403 : 401, - await response.text(), - response.status === 403 ? "insufficient_scope" : "invalid_token", - response.status === 403 - ? undefined - : getWWWAuthenticate(resource, "invalid_token"), - ); - } - - const data = tokenExchangeSchema.parse(await response.json()); - const key = env === "live" ? data.prod_key : data.sandbox_key; - if (!key) { - throw new OAuthHttpError( - 502, - "OAuth key exchange did not return an API key", - ); - } - - const exchanged = { - key, - orgId: data.org_id, - userId: data.user_id, - clientId: data.client_id, - scopes: data.scopes, - expiresAt: addMilliseconds(new Date(), ms.minutes(1)), - }; - apiKeyCache.set(cacheKey, exchanged); - return exchanged; -} - -function getOAuthPrincipalId( - token: string, - exchanged: Awaited>, -) { - if (!exchanged.orgId) return principalFromSecret("oauth", token); - - return [ - "oauth", - exchanged.orgId, - exchanged.userId ?? "unknown-user", - exchanged.clientId ?? "unknown-client", - ].join(":"); -} - -export async function buildAuthForRequest( - headers: Headers, - flags: MCPOAuthFlags, - logger: ConsoleLogger, - resourcePath = "/mcp", -): Promise { - const env = getEnvironment(headers, flags); - const resource = getResourceUrl(headers, flags, resourcePath); - const xApiVersion = parseRequestOption( - headers.get("x-api-version") ?? flags["x-api-version"], - xApiVersionSchema, - "Invalid x-api-version", - ); - const failOpen = parseRequestOption( - headers.get("fail-open") ?? flags["fail-open"], - failOpenSchema, - "Invalid fail-open", - ); - - if (flags["oauth-enabled"]) { - const authHeader = headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) { - throw new OAuthHttpError( - 401, - "Missing Authorization bearer token", - "invalid_token", - getWWWAuthenticate(resource), - ); - } - - const token = authHeader.slice("Bearer ".length); - const exchanged = await exchangeOAuthToken(headers, flags, resource, token); - return { - apiKey: exchanged.key, - env, - resource, - principalId: getOAuthPrincipalId(token, exchanged), - scopes: exchanged.scopes ?? [...MCP_OAUTH_SCOPES], - orgId: exchanged.orgId, - serverURL: flags["server-url"], - xApiVersion, - failOpen, - }; - } - - const apiKey = parseRequestOption( - headers.get("secret-key") ?? flags["secret-key"], - secretKeySchema, - "Invalid secret-key", - ); - if (!apiKey) { - logger.warning("Missing secret-key for MCP request"); - throw new OAuthHttpError(401, "Missing secret-key", "invalid_token"); - } - - return { - apiKey, - env, - resource, - principalId: principalFromSecret("secret-key", apiKey), - scopes: [...MCP_OAUTH_SCOPES], - serverURL: flags["server-url"], - xApiVersion, - failOpen, - }; -} diff --git a/packages/mcp/src/resources/balances/standalone-balances.md b/packages/mcp/src/resources/balances/standalone-balances.md new file mode 100644 index 000000000..9a58ea85a --- /dev/null +++ b/packages/mcp/src/resources/balances/standalone-balances.md @@ -0,0 +1,36 @@ +--- +name: balances +title: Standalone Balances +description: How to create standalone, expiring, and entity-scoped balance grants. +priority: 0.8 +audience: + - assistant +--- + +# Standalone Balances + +Use previewCreateBalance and createBalance for standalone grants that are independent of a plan, such as promotional credits, referral credits, manual adjustments, or one-time entity-scoped grants. + +Required fields: +- customer_id: parent customer receiving the grant +- feature_id: the balance feature, usually the credit pool such as "credits" +- included_grant: amount to grant + +Optional fields: +- entity_id: scope the balance to one entity/workspace/user under the customer +- expires_at: expiry timestamp as UTC epoch milliseconds +- balance_id: stable id for later update/delete targeting + +Rules: +- For "50k credits", use included_grant: 50000. +- For "expires in 2 months", use calendar months and compute expires_at from the current request date. +- If preview or response data includes expires_at or next_reset_at, use epochMillisecondsToDate before explaining those timestamps to the user. +- Do not include reset when using expires_at for a one-time expiring grant. +- Do not use rewards for direct operational credit grants. +- Do not grant the entity-count feature itself to the entity; grant the credit/balance feature. + +Useful docs: +- https://docs.useautumn.com/documentation/customers/managing-balances +- https://docs.useautumn.com/documentation/customers/balances +- https://docs.useautumn.com/documentation/modelling-pricing/sub-entity-balances +- https://docs.useautumn.com/api-reference/balances/createBalance diff --git a/packages/mcp/src/resources/billing/billing-safety.md b/packages/mcp/src/resources/billing/billing-safety.md new file mode 100644 index 000000000..fce2bb107 --- /dev/null +++ b/packages/mcp/src/resources/billing/billing-safety.md @@ -0,0 +1,31 @@ +--- +name: billing-safety +title: Billing Safety +description: Preview-first rules for Autumn billing changes. +priority: 0.8 +audience: + - assistant +--- + +# Billing Safety + +Billing mutations must be preview-first. + +- Use previewAttach before attach. +- Use previewUpdateSubscription before updateSubscription. +- Use previewCreateSchedule before createSchedule. +- Use previewCreateBalance before createBalance. +- Use createSchedule only after the user confirms the ordered phases, timing, and preview. +- Default paid billing changes should use a draft invoice: set enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false. Only change if the user specifies otherwise. +- invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing. +- Use listFeatures only when customizing plan items or passing non-zero prepaid feature_quantities and the required feature ids/types are not already known. +- Use previewAttach before attach, including feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior. +- Use createPlan only after the user confirms the plan configuration. +- Show the user the material billing impact before applying a change. +- Apply a write only after explicit confirmation of the exact previewed change. +- Never claim a billing change was applied unless the write tool succeeds. + +Useful docs: +- https://docs.useautumn.com/api-reference/billing/attach +- https://docs.useautumn.com/documentation/concepts/plan-items +- https://docs.useautumn.com/documentation/customers/balances diff --git a/packages/mcp/src/resources/billing/schedules.md b/packages/mcp/src/resources/billing/schedules.md new file mode 100644 index 000000000..9b76684c3 --- /dev/null +++ b/packages/mcp/src/resources/billing/schedules.md @@ -0,0 +1,36 @@ +--- +name: schedules +title: Billing Schedules +description: How to create multi-phase billing schedules safely. +priority: 0.8 +audience: + - assistant +--- + +# Billing Schedules + +Use previewCreateSchedule and createSchedule for multi-phase future billing changes. + +Before creating a schedule, resolve: +- customer_id and optional entity_id +- ordered phases with starts_at as UTC epoch millisecond timestamps +- plans in each phase, including versions, feature quantities, and customizations +- redirect_mode, success_url, invoice_mode, and checkout behavior if payment may be required + +Default paid schedule billing should use a draft invoice: set enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false. +invoice_mode requires customer email; if missing, ask for it and call updateCustomer with customer_id and email before billing. + +Use listFeatures only when a phase customizes plan items or sets non-zero prepaid feature_quantities and the exact feature ids or types are not already known. Scheduling an existing plan as-is does not need feature lookup. + +Use the exact calendar date from the user or contract. Convert date-only schedule starts to midnight UTC unless the user or contract specifies a timezone. Do not shift years when converting dates. +When preview or response data includes starts_at or billing period timestamps, use epochMillisecondsToDate before explaining those timestamps to the user. + +If the user says year 1 is already paid or should have no billing changes, do not create an immediate/year-1 phase with a null price or billing_behavior "none". Start the schedule at the first future billing change (for example year 2), then add later phases such as year 3. + +Custom feature mapping: +- "N credits per month/year" -> customize.items[].included = N and reset.interval = "month"/"year". +- "unlimited X" -> customize.items[].unlimited = true. +- Omit reset only for non-consumable, unlimited, or clearly one-time grants. +- Credit systems should customize the credit_system feature, not each underlying metered feature. + +There is no separate public update-schedule tool. For existing subscription changes, use previewUpdateSubscription and updateSubscription when the requested change fits that endpoint. For a new multi-phase transition, call previewCreateSchedule first, show the immediate billing impact and ordered phases, then call createSchedule only after explicit confirmation. diff --git a/packages/mcp/src/resources/compileResources.ts b/packages/mcp/src/resources/compileResources.ts new file mode 100644 index 000000000..ba478225c --- /dev/null +++ b/packages/mcp/src/resources/compileResources.ts @@ -0,0 +1,115 @@ +import { readFileSync } from "node:fs"; +import type { AutumnMcpResourceDoc, ResourceFrontmatter } from "./types.js"; + +const DEFAULT_PRIORITY = 0.8; +const DEFAULT_AUDIENCE = ["assistant"] as const; + +const parseScalar = (value: string): string | number => { + const trimmed = value.trim(); + const unquoted = trimmed.match(/^['"](.*)['"]$/); + if (unquoted) return unquoted[1] ?? ""; + + const number = Number(trimmed); + if (trimmed && Number.isFinite(number)) return number; + + return trimmed; +}; + +export const parseResourceMarkdown = ({ + path, + text, +}: { + path: string; + text: string; +}): ResourceFrontmatter & { body: string } => { + const match = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!match) { + throw new Error(`MCP resource ${path} is missing frontmatter`); + } + + const frontmatter = match[1] ?? ""; + const body = (match[2] ?? "").trim(); + const values: Record = {}; + let currentListKey: string | null = null; + + for (const rawLine of frontmatter.split("\n")) { + const line = rawLine.trimEnd(); + if (!line.trim()) continue; + + const listItem = line.match(/^\s*-\s+(.+)$/); + if (listItem && currentListKey) { + const existing = values[currentListKey]; + values[currentListKey] = [ + ...(Array.isArray(existing) ? existing : []), + String(parseScalar(listItem[1] ?? "")), + ]; + continue; + } + + const field = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/); + if (!field) { + throw new Error(`Invalid frontmatter line in ${path}: ${rawLine}`); + } + + const key = field[1] ?? ""; + const value = field[2] ?? ""; + currentListKey = value ? null : key; + values[key] = value ? parseScalar(value) : []; + } + + const name = values.name; + const title = values.title; + const description = values.description; + if (typeof name !== "string" || !name) { + throw new Error(`MCP resource ${path} is missing name`); + } + if (typeof title !== "string" || !title) { + throw new Error(`MCP resource ${path} is missing title`); + } + if (typeof description !== "string" || !description) { + throw new Error(`MCP resource ${path} is missing description`); + } + + const priority = + typeof values.priority === "number" ? values.priority : DEFAULT_PRIORITY; + const audience = Array.isArray(values.audience) + ? values.audience.map(String) + : [...DEFAULT_AUDIENCE]; + if (!audience.every((value) => value === "assistant")) { + throw new Error(`MCP resource ${path} has unsupported audience`); + } + + return { + name, + title, + description, + priority, + audience: audience as ResourceFrontmatter["audience"], + body, + }; +}; + +export const compileResourceFiles = ({ + baseUrl, + files, +}: { + baseUrl: string | URL; + files: readonly string[]; +}): AutumnMcpResourceDoc[] => + files.map((file) => { + const url = new URL(file, baseUrl); + const parsed = parseResourceMarkdown({ + path: file, + text: readFileSync(url, "utf8"), + }); + + return { + name: parsed.name, + title: parsed.title, + description: parsed.description, + priority: parsed.priority, + audience: parsed.audience, + uri: `autumn://docs/${parsed.name}`, + text: parsed.body, + }; + }); diff --git a/packages/mcp/src/resources/customers/querying-customers.md b/packages/mcp/src/resources/customers/querying-customers.md new file mode 100644 index 000000000..4dad27596 --- /dev/null +++ b/packages/mcp/src/resources/customers/querying-customers.md @@ -0,0 +1,22 @@ +--- +name: querying-customers +title: Querying Customers +description: How to answer customer-heavy questions with listCustomers. +priority: 0.8 +audience: + - assistant +--- + +# Querying Customers + +listCustomers is the primary primitive for customer-heavy queries. + +Prefer server-side filters before local filtering: +- search: customer id, name, or email +- plans: customers attached to specific plans and versions +- subscription_status: active or scheduled subscriptions +- processors: payment processor filters + +Use limit 1000 for broad scans; that is the maximum page size. +Always paginate until next_cursor is empty when the user asks for complete results. Use getCustomer only for details not returned by listCustomers. + diff --git a/packages/mcp/src/resources/features/feature-catalog.md b/packages/mcp/src/resources/features/feature-catalog.md new file mode 100644 index 000000000..78809428b --- /dev/null +++ b/packages/mcp/src/resources/features/feature-catalog.md @@ -0,0 +1,38 @@ +--- +name: feature-catalog +title: Feature Catalog +description: How to use Autumn features when configuring plans and billing changes. +priority: 0.8 +audience: + - assistant +--- + +# Feature Catalog + +Use listFeatures only when a task needs feature-specific inputs: creating plan items, customizing plan items, or passing non-zero feature_quantities for prepaid features. Ordinary billing changes that attach or update an existing plan as-is do not need feature lookup. Never invent feature ids. + +Feature fields: +- id: stable feature id used in plan items, /check, and /track. +- name: human-readable name; match user language to this, then use id in tool calls. +- type: boolean, metered, or credit_system. +- consumable: for metered features, true means usage resets periodically; false means persistent allocation such as seats or storage. +- event_names: events that can increment usage for a metered feature. +- credit_schema: for credit_system features, maps underlying metered feature ids to credit costs. +- archived: avoid archived features unless the user explicitly asks for them. + +Plan and billing usage: +- Attaching or updating an existing plan as-is usually does not need listFeatures. +- If a plan contains prepaid features and the request needs a non-zero quantity, use feature_quantities and know the feature_id. +- Boolean feature: include or remove access; do not ask for quantity. +- Metered consumable feature: ask for included amount or unlimited, and the reset interval unless it is clearly one-time. +- Metered non-consumable feature: ask for quantity or unlimited; do not add a reset interval. +- Credit system: grant the credit_system feature, not each underlying metered feature. +- Prepaid quantity changes belong in feature_quantities; custom contract grants or item-level prices belong in customize. + +For attach and updateSubscription, prefer patch-style customize.add_items, customize.remove_items, or customize.update_items when changing only part of an existing plan. customize.items replaces the full custom item list. createSchedule currently supports replacement-style customize.items for each phase. + +Useful docs: +- https://docs.useautumn.com/documentation/pricing/features +- https://docs.useautumn.com/documentation/pricing/plan-features +- https://docs.useautumn.com/documentation/modelling-pricing/prepaid-pricing +- https://docs.useautumn.com/documentation/modelling-pricing/credit-systems diff --git a/packages/mcp/src/resources/general/tool-composition.md b/packages/mcp/src/resources/general/tool-composition.md new file mode 100644 index 000000000..25e790483 --- /dev/null +++ b/packages/mcp/src/resources/general/tool-composition.md @@ -0,0 +1,27 @@ +--- +name: tool-composition +title: Tool Composition +description: How to compose Autumn MCP tools for operational questions. +priority: 0.8 +audience: + - assistant +--- + +# Tool Composition + +Use Autumn tools as composable primitives. + +- Use listPlans first for questions based on plan attributes. +- Use listCustomers for customer-heavy questions, with filters and pagination. +- Use getPlan or getCustomer only when list results are missing required detail. +- Do not fan out into many getCustomer calls unless the user needs per-customer details not present in listCustomers. +- Use getOrCreateCustomer only when the user explicitly asks to create/pre-create a customer. +- Use updateCustomer to set customer email before invoice-mode billing when an existing customer is missing email. +- Use createPlan for confirmed plan configuration writes. +- Use previewCreateBalance before createBalance for standalone balance or credit grants. +- Use previewCreateSchedule before createSchedule for multi-phase billing schedules. +- For custom feature grants, map "per month/year" to customize.items[].reset.interval. +- Use epochMillisecondsToDate before explaining epoch millisecond response fields such as starts_at, expires_at, next_reset_at, or billing period timestamps. +- For billing writes, always preview first and wait for explicit user confirmation before applying. + +Docs index: https://docs.useautumn.com/llms.txt diff --git a/packages/mcp/src/resources/index.ts b/packages/mcp/src/resources/index.ts new file mode 100644 index 000000000..af46b9fe4 --- /dev/null +++ b/packages/mcp/src/resources/index.ts @@ -0,0 +1,51 @@ +import type { MCPServerResources } from "@mastra/mcp"; +import { compileResourceFiles } from "./compileResources.js"; + +const resourceFiles = [ + "./general/tool-composition.md", + "./features/feature-catalog.md", + "./plans/querying-plans.md", + "./plans/creating-plans.md", + "./customers/querying-customers.md", + "./billing/schedules.md", + "./balances/standalone-balances.md", + "./billing/billing-safety.md", + "./logs/request-logs.md", + "./logs/customers.md", + "./logs/balances.md", + "./logs/billing.md", + "./logs/stripe-webhooks.md", + "./logs/analytics.md", +] as const; + +const docs = compileResourceFiles({ + baseUrl: import.meta.url, + files: resourceFiles, +}); + +const docByUri = new Map(docs.map((doc) => [doc.uri, doc])); + +export const autumnMcpResources: MCPServerResources = { + listResources: async () => + docs.map((doc) => ({ + uri: doc.uri, + name: doc.name, + title: doc.title, + description: doc.description, + mimeType: "text/markdown", + size: doc.text.length, + annotations: { + audience: doc.audience, + priority: doc.priority, + }, + })), + getResourceContent: async ({ uri }) => { + const doc = docByUri.get(uri); + if (!doc) { + throw new Error(`Unknown Autumn MCP resource: ${uri}`); + } + return { text: doc.text }; + }, +}; + +export const autumnMcpResourceUris = docs.map((doc) => doc.uri); diff --git a/packages/mcp/src/resources/logs/analytics.md b/packages/mcp/src/resources/logs/analytics.md new file mode 100644 index 000000000..167537ae8 --- /dev/null +++ b/packages/mcp/src/resources/logs/analytics.md @@ -0,0 +1,56 @@ +--- +name: request-log-analytics +title: Request Log Analytics +description: How to aggregate external request-log activity. +priority: 0.8 +audience: + - assistant +--- + +# Request Log Analytics + +Use queryRequestLogs for counts, grouping, and status-code summaries. Keep aggregates scoped by time range and avoid broad scans unless the user asks for organization-wide activity. + +Requests by path: + +```apl +where source == 'api_request' | summarize requests = count() by request_path | order by requests desc | limit 20 +``` + +Failed requests by path: + +```apl +where source == 'api_request' and status_code >= 400 | summarize failed = count() by request_path | order by failed desc | limit 20 +``` + +Status-code breakdown: + +```apl +summarize requests = count() by status_code | order by requests desc | limit 20 +``` + +Customer activity: + +```apl +where source == 'api_request' | summarize requests = count() by customer_id | order by requests desc | limit 20 +``` + +Entity activity for one customer: + +```apl +where customer_id == 'cus_123' | summarize requests = count() by entity_id | order by requests desc | limit 20 +``` + +Tracked feature activity: + +```apl +where source == 'api_request' and (request_path contains 'balances.track' or request_path contains 'track' or request_path contains 'events') and request_body.event_name != '' | summarize requests = count() by request_body.event_name | order by requests desc | limit 20 +``` + +Check outcomes: + +```apl +where source == 'api_request' and (request_path contains 'balances.check' or request_path contains 'check' or request_path contains 'entitled') | summarize allowed = countif(response_body.allowed == true), denied = countif(response_body.allowed == false) by request_body.feature_id | order by denied desc | limit 20 +``` + +When answering, describe the grouping and time range. Do not infer product usage beyond the request paths and payload fields returned by this interface. diff --git a/packages/mcp/src/resources/logs/balances.md b/packages/mcp/src/resources/logs/balances.md new file mode 100644 index 000000000..1ae91a5cb --- /dev/null +++ b/packages/mcp/src/resources/logs/balances.md @@ -0,0 +1,53 @@ +--- +name: request-log-balances +title: Request Log Balances +description: How to inspect balance, check, and track requests through the external request-log interface. +priority: 0.8 +audience: + - assistant +--- + +# Request Log Balances + +Use this resource for questions about checks, tracking, usage events, balances, credits, and whether a customer was allowed to use a feature. Many customers use RPC-style routes with dotted names, while older REST-style routes are legacy. + +Relevant request paths usually include: +- /v1/balances.check +- /v1/balances.track +- /v1/balances.update +- /v1/balances.finalize +- /v1/events.list +- /v1/events.aggregate +- legacy: /v1/check, /v1/entitled, /v1/track, /v1/events, /v1/balances + +Recent balance-related records for a customer: + +```apl +where customer_id == 'cus_123' and (request_path contains 'balances.' or request_path contains 'events.' or request_path contains 'check' or request_path contains 'track' or request_path contains 'events' or request_path contains 'balances') | order by timestamp desc | limit 25 +``` + +Failed balance-related calls: + +```apl +where customer_id == 'cus_123' and status_code >= 400 and (request_path contains 'balances.' or request_path contains 'events.' or request_path contains 'check' or request_path contains 'track' or request_path contains 'events' or request_path contains 'balances') | order by timestamp desc | limit 25 +``` + +Find checks that returned not allowed: + +```apl +where customer_id == 'cus_123' and (request_path contains 'balances.check' or request_path contains 'check' or request_path contains 'entitled') and response_body.allowed == false | order by timestamp desc | limit 25 +``` + +Feature activity for a customer: + +```apl +where customer_id == 'cus_123' and (request_path contains 'balances.track' or request_path contains 'track' or request_path contains 'events') and request_body.event_name != '' | summarize requests = count() by request_body.event_name | order by requests desc | limit 20 +``` + +Denied checks by feature: + +```apl +where customer_id == 'cus_123' and (request_path contains 'balances.check' or request_path contains 'check' or request_path contains 'entitled') and response_body.allowed == false | summarize denied = count() by request_body.feature_id | order by denied desc | limit 20 +``` + +Inspect request_body and response_body for feature ids, event names, allowed, balance, remaining, usage, granted, and next reset fields. Prefer dot-path filters such as request_body.feature_id and response_body.balance.remaining after narrowing by customer, path, and time range. diff --git a/packages/mcp/src/resources/logs/billing.md b/packages/mcp/src/resources/logs/billing.md new file mode 100644 index 000000000..73b954f52 --- /dev/null +++ b/packages/mcp/src/resources/logs/billing.md @@ -0,0 +1,47 @@ +--- +name: request-log-billing +title: Request Log Billing +description: How to inspect billing requests through the external request-log interface. +priority: 0.8 +audience: + - assistant +--- + +# Request Log Billing + +Use this resource for billing attach, update, setup payment, customer portal, and schedule questions that can be answered from API request and response records. Billing activity is commonly on RPC-style dotted routes. + +Relevant request paths usually include: +- /v1/billing.attach +- /v1/billing.update +- /v1/billing.multi_attach +- /v1/billing.setup_payment +- /v1/billing.open_customer_portal +- /v1/billing.create_schedule +- /v1/billing.preview_create_schedule + +Recent billing calls for a customer: + +```apl +where customer_id == 'cus_123' and request_path contains 'billing' | order by timestamp desc | limit 25 +``` + +Failed billing calls: + +```apl +where customer_id == 'cus_123' and request_path contains 'billing' and status_code >= 400 | order by timestamp desc | limit 25 +``` + +Attach or update timeline: + +```apl +where customer_id == 'cus_123' and (request_path contains 'billing.attach' or request_path contains 'billing.update') | order by timestamp desc | limit 50 +``` + +Billing activity by path: + +```apl +where source == 'api_request' and request_path contains 'billing' | summarize requests = count(), failed = countif(status_code >= 400) by request_path | order by requests desc | limit 20 +``` + +Inspect request_body and response_body for plan ids, product ids, checkout URLs, status codes, customer ids, entity ids, and returned billing results. If a user asks why a downstream payment provider changed state, use the Stripe webhook resource to inspect webhook records too. diff --git a/packages/mcp/src/resources/logs/customers.md b/packages/mcp/src/resources/logs/customers.md new file mode 100644 index 000000000..1a6a2610e --- /dev/null +++ b/packages/mcp/src/resources/logs/customers.md @@ -0,0 +1,39 @@ +--- +name: request-log-customers +title: Request Log Customers +description: How to investigate one customer through the external request-log interface. +priority: 0.8 +audience: + - assistant +--- + +# Request Log Customers + +Use customer_id as the primary filter when investigating one customer. Add entity_id when the customer has multiple entities and the user identifies one. + +Start with a narrow recent range and list the customer's newest records: + +```apl +where customer_id == 'cus_123' | order by timestamp desc | limit 25 +``` + +Find failed calls for a customer: + +```apl +where customer_id == 'cus_123' and status_code >= 400 | order by timestamp desc | limit 25 +``` + +Build a mixed API and Stripe webhook timeline: + +```apl +where customer_id == 'cus_123' | order by timestamp desc | limit 50 +``` + +Narrow to one entity: + +```apl +where customer_id == 'cus_123' and entity_id == 'ent_123' | order by timestamp desc | limit 25 +``` + +When answering, state the time range, customer_id, optional entity_id, and whether matching records were API requests, Stripe webhooks, or both. If no records match, say that this log interface did not return matching records for the selected range. + diff --git a/packages/mcp/src/resources/logs/request-logs.md b/packages/mcp/src/resources/logs/request-logs.md new file mode 100644 index 000000000..e9b5c5833 --- /dev/null +++ b/packages/mcp/src/resources/logs/request-logs.md @@ -0,0 +1,87 @@ +--- +name: request-logs +title: Request Logs +description: How to query tenant-scoped Autumn API request logs. +priority: 0.8 +audience: + - assistant +--- + +# Request Logs + +Use request-log tools to investigate Autumn API requests and Stripe webhook deliveries for the authenticated organization. Treat this as the complete log interface. Do not ask for information outside the documented fields. + +Use searchRequestLogs when the user needs matching request records: +- failed calls for a customer +- recent calls to a path +- request or response payload inspection +- a chronological list of relevant requests + +Use queryRequestLogs when the user needs aggregate statistics: +- count failed requests by path +- count requests by status code +- compare traffic across request methods +- summarize failures over a time range + +Queryable fields: +- timestamp +- source +- status_code +- request_method +- request_url +- request_path +- request_body +- response_body +- org_id +- customer_id +- entity_id +- stripe_event_id +- stripe_event_type +- stripe_object_id + +source is either api_request or stripe_webhook. + +Nested payload fields can be queried with dot paths under request_body and response_body: +- request_body.feature_id +- request_body.event_name +- request_body.customer_id +- response_body.allowed +- response_body.balance.remaining + +Only simple dot paths are supported. Do not use raw functions, brackets, or extraction syntax. For nested response_body fields, narrow by time range and customer, path, or status before filtering or grouping. + +Supported query stages are where, order by, limit, summarize, and project. Use searchRequestLogs for where/order/limit list queries. Use queryRequestLogs for summarize/project aggregate queries. + +Default raw searches to a narrow recent range. For count/aggregate queries, queryRequestLogs defaults to 30 days when the query filters customer_id and 15 days for org-scoped queries. Ask for a customer, path, source, or time range when the user gives no useful anchor and the query may scan broadly. + +Do not reference fields outside this document. If a fact is not present in these fields, say that the log interface does not expose it. + +Basic examples: + +```apl +where customer_id == 'cus_123' and status_code >= 400 | order by timestamp desc | limit 25 +``` + +```apl +where request_path startswith '/v1/billing' and status_code >= 400 | limit 20 +``` + +```apl +summarize requests = count() by source, request_path | order by requests desc | limit 20 +``` + +```apl +where customer_id == 'cus_123' and request_body.feature_id == 'credits' | summarize requests = count() by request_body.event_name | order by requests desc | limit 20 +``` + +```apl +where request_path contains 'balances.check' and response_body.allowed == false | summarize denied = count() by request_body.feature_id | order by denied desc | limit 20 +``` + +When answering in Slack, include: +- the time range used +- the filters or grouping used +- the most relevant findings in short bullets +- any uncertainty, such as no matching logs or a range that may be too narrow + +Never describe this interface as public API documentation. diff --git a/packages/mcp/src/resources/logs/stripe-webhooks.md b/packages/mcp/src/resources/logs/stripe-webhooks.md new file mode 100644 index 000000000..2a29641b3 --- /dev/null +++ b/packages/mcp/src/resources/logs/stripe-webhooks.md @@ -0,0 +1,37 @@ +--- +name: request-log-stripe-webhooks +title: Request Log Stripe Webhooks +description: How to inspect public-safe Stripe webhook timelines through the external request-log interface. +priority: 0.8 +audience: + - assistant +--- + +# Request Log Stripe Webhooks + +Use this resource for Stripe webhook timelines. Stripe webhook records are separated from normal API requests with source == 'stripe_webhook'. + +Webhook fields: +- stripe_event_id +- stripe_event_type +- stripe_object_id + +Recent Stripe webhooks for a customer: + +```apl +where source == 'stripe_webhook' and customer_id == 'cus_123' | order by timestamp desc | limit 25 +``` + +Webhook events by type: + +```apl +where source == 'stripe_webhook' and customer_id == 'cus_123' | summarize events = count() by stripe_event_type | order by events desc | limit 20 +``` + +Timeline for one Stripe object: + +```apl +where source == 'stripe_webhook' and stripe_object_id == 'sub_123' | order by timestamp desc | limit 50 +``` + +Inspect request_url, status_code, request_body, and response_body for what the webhook delivery returned. Do not ask for raw event payloads beyond the fields returned by this interface. diff --git a/packages/mcp/src/resources/plans/creating-plans.md b/packages/mcp/src/resources/plans/creating-plans.md new file mode 100644 index 000000000..ddc9dc3af --- /dev/null +++ b/packages/mcp/src/resources/plans/creating-plans.md @@ -0,0 +1,28 @@ +--- +name: creating-plans +title: Creating Plans +description: How to gather plan details before using createPlan. +priority: 0.8 +audience: + - assistant +--- + +# Creating Plans + +Use createPlan only after the requested plan shape is clear. + +Before creating a plan, resolve: +- plan_id and name +- whether it is a base plan or add-on +- base price, interval, and currency if paid +- items/features, included quantities, reset intervals, and item-level prices +- free trial settings +- whether the plan should auto-enable for new customers + +If the user names features but not exact ids, use listFeatures before drafting custom plan items. Never invent feature ids. + +For consumable features, recurring grants need reset intervals. "500 credits per month" means included 500 with reset.interval "month"; one-time grants use "one_off". + +For boolean features, include access without asking for quantity. For credit systems, grant the credit_system feature instead of each underlying metered feature. + +If any required pricing or feature detail is ambiguous, ask a concise clarification question before creating the plan. diff --git a/packages/mcp/src/resources/plans/querying-plans.md b/packages/mcp/src/resources/plans/querying-plans.md new file mode 100644 index 000000000..5356376d6 --- /dev/null +++ b/packages/mcp/src/resources/plans/querying-plans.md @@ -0,0 +1,23 @@ +--- +name: querying-plans +title: Querying Plans +description: How to answer plan-filtering questions with listPlans. +priority: 0.8 +audience: + - assistant +--- + +# Querying Plans + +listPlans is usually a cheap full scan because organizations generally have a small number of plans. + +Use listPlans for questions about: +- plan price thresholds +- free trials +- archived plans +- custom plan variants +- plan versions +- plan features and included quantities + +Filter the returned plans locally. If the user asks for customers on matching plans, first resolve the matching plans, then call listCustomers with those plan ids. For upcoming, queued, or scheduled version queries, pass only the relevant target versions to listCustomers; with numeric versions, exclude the earliest historical version unless the user asks for all historical versions. + diff --git a/packages/mcp/src/resources/types.ts b/packages/mcp/src/resources/types.ts new file mode 100644 index 000000000..5e8ae0bab --- /dev/null +++ b/packages/mcp/src/resources/types.ts @@ -0,0 +1,14 @@ +export type ResourceAudience = "assistant"; + +export type ResourceFrontmatter = { + name: string; + title: string; + description: string; + priority: number; + audience: ResourceAudience[]; +}; + +export type AutumnMcpResourceDoc = ResourceFrontmatter & { + uri: string; + text: string; +}; diff --git a/packages/mcp/src/server/auth/auth.ts b/packages/mcp/src/server/auth/auth.ts new file mode 100644 index 000000000..c17bc8e61 --- /dev/null +++ b/packages/mcp/src/server/auth/auth.ts @@ -0,0 +1,85 @@ +import { RequestContext } from "@mastra/core/request-context"; +import * as z from "zod/v4"; +import { + DEFAULT_API_VERSION, + DEFAULT_AUTUMN_API_URL, +} from "../../constants.js"; + +export const environmentSchema = z.enum(["sandbox", "live"]); +export type OAuthEnvironment = z.infer; + +/** + * Authenticated Autumn identity attached to every MCP request. Defined as a zod + * schema so the same definition both types the value and validates it when read + * back from the (loosely-typed) MCP execution context — no casts required. + */ +export const autumnMcpAuthSchema = z.object({ + apiKey: z.string().min(1), + authMethod: z.enum(["secret-key", "oauth"]).optional(), + env: environmentSchema, + principalId: z.string(), + resource: z.string(), + scopes: z.array(z.string()), + orgId: z.string().optional(), + serverURL: z.string().optional(), + xApiVersion: z.string().optional(), + failOpen: z.boolean().optional(), +}); + +export type AutumnMcpAuth = z.infer; + +/** + * Minimal structural view of the MCP tool execution context we read auth from. + * Kept intentionally loose so any Mastra `ToolExecutionContext` satisfies it + * without callers having to cast. + */ +type AuthContext = { + mcp?: { extra?: { authInfo?: unknown } | undefined } | undefined; + requestContext?: { get?: (key: string) => unknown } | undefined; +}; + +/** Reads `mcp.extra.authInfo` back out of a serialized request context. */ +const readNestedAuthInfo = ( + requestContext: AuthContext["requestContext"], +): unknown => { + const extra = requestContext?.get?.("mcp.extra"); + if (typeof extra === "object" && extra !== null && "authInfo" in extra) { + return extra.authInfo; + } + return undefined; +}; + +export const getAutumnAuth = (context?: AuthContext): AutumnMcpAuth => { + const candidate = + context?.mcp?.extra?.authInfo ?? + readNestedAuthInfo(context?.requestContext); + + const parsed = autumnMcpAuthSchema.safeParse(candidate); + if (!parsed.success) { + throw new Error("Autumn MCP authentication is required."); + } + return parsed.data; +}; + +export const createRequestContext = (auth: AutumnMcpAuth) => { + const requestContext = new RequestContext(); + requestContext.set("mcp.extra", { authInfo: auth }); + return requestContext; +}; + +export const createAutumnClient = (auth: AutumnMcpAuth) => ({ + baseUrl: auth.serverURL ?? DEFAULT_AUTUMN_API_URL, + headers: { + Authorization: `Bearer ${auth.apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + "x-api-version": auth.xApiVersion ?? DEFAULT_API_VERSION, + "x-autumn-environment": auth.env, + ...(auth.authMethod === "oauth" + ? { "x-autumn-oauth-resource": auth.resource } + : {}), + ...(auth.failOpen === undefined + ? {} + : { "fail-open": String(auth.failOpen) }), + }, +}); diff --git a/packages/mcp/src/mcp-server/flags.ts b/packages/mcp/src/server/flags.ts similarity index 100% rename from packages/mcp/src/mcp-server/flags.ts rename to packages/mcp/src/server/flags.ts diff --git a/packages/mcp/src/server/server.ts b/packages/mcp/src/server/server.ts new file mode 100644 index 000000000..dc4b40206 --- /dev/null +++ b/packages/mcp/src/server/server.ts @@ -0,0 +1,18 @@ +import { MCPServer } from "@mastra/mcp"; +import { autumnMcpResources } from "../resources/index.js"; +import { createRawAutumnOperationTools } from "../tools/index.js"; + +export const createAutumnOperationsMCPServer = () => + new MCPServer({ + id: "autumn-mcp", + name: "Autumn MCP", + version: "0.0.1", + description: "Operate on Autumn customers, plans, and billing.", + instructions: [ + "Before customer, billing, balance, entity, or plan work, call getAgentRules to read org-specific behavior.", + "Use preview tools before billing writes.", + "Write tools are destructive and should only be called after explicit user confirmation.", + ].join(" "), + tools: createRawAutumnOperationTools(), + resources: autumnMcpResources, + }); diff --git a/packages/mcp/src/tools/agent.ts b/packages/mcp/src/tools/agent.ts new file mode 100644 index 000000000..d843ad36d --- /dev/null +++ b/packages/mcp/src/tools/agent.ts @@ -0,0 +1,57 @@ +import * as z from "zod/v4"; +import { createDomainTools } from "./utils/builders.js"; +import type { ToolDomain } from "./utils/types.js"; + +const emptySchema = z.object({}).strict(); +const updateAgentRulesSchema = z + .object({ + credit_rules: z + .object({ + credit_feature_id: z.string().optional(), + }) + .optional(), + entity_rules: z + .object({ + attach_to_entities: z.boolean().optional(), + entity_feature_id: z.string().optional(), + }) + .optional(), + notes: z.string().optional(), + }) + .strict(); + +const endpoints = { + getAgentRules: "/v1/agent.get_rules", + updateAgentRules: "/v1/agent.update_rules", +} as const; + +const schemas = { + getAgentRules: emptySchema, + updateAgentRules: updateAgentRulesSchema, +} as const; + +const { operation } = createDomainTools({ endpoints, schemas }); + +const domain = { + operations: [ + operation({ + id: "getAgentRules", + description: ` +- Fetch current org agent rules. +- Use before customer, billing, balance, entity, or plan work. +- Includes entity defaults, credit defaults, and org notes. + `.trim(), + }), + operation({ + id: "updateAgentRules", + description: ` +- Update current org agent rules. +- Use only when the user asks to change org-specific behavior. +- Supports entity defaults, credit defaults, and org notes. + `.trim(), + idempotent: true, + }), + ], +} satisfies ToolDomain; + +export const agent = { endpoints, schemas, domain }; diff --git a/packages/mcp/src/tools/balances.ts b/packages/mcp/src/tools/balances.ts new file mode 100644 index 000000000..85aabe459 --- /dev/null +++ b/packages/mcp/src/tools/balances.ts @@ -0,0 +1,49 @@ +import { CreateBalanceParamsV0Schema } from "@autumn/shared/publicApiSchemas"; +import { createDomainTools } from "./utils/builders.js"; +import { epochMillisecondsSchema } from "./utils/dates.js"; +import type { ToolDomain } from "./utils/types.js"; + +const createBalanceMcpSchema = CreateBalanceParamsV0Schema.extend({ + expires_at: epochMillisecondsSchema.optional().meta({ + description: + "Expiry time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.", + }), +}); + +const endpoints = { + createBalance: "/v1/balances.create", +} as const; + +const schemas = { + previewCreateBalance: createBalanceMcpSchema, + createBalance: createBalanceMcpSchema, +} as const; + +const { operation, localPreview } = createDomainTools({ endpoints, schemas }); + +const domain = { + operations: [ + operation({ + id: "createBalance", + description: + "Create a standalone customer balance grant. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Destructive: preview first; use entity_id for entity-scoped credits, included_grant for the grant amount, expires_at for expiring grants, and omit reset when using expires_at. For relative expiries like '2 months', use calendar months, not a 30-day approximation. expires_at accepts epoch milliseconds or ISO/date strings.", + destructive: true, + }), + ], + localPreviews: [ + localPreview({ + id: "previewCreateBalance", + description: + "Preview a standalone balance grant before createBalance. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Use for one-time credit grants, referral/promotional credits, and entity-scoped credits. Does not mutate Autumn. For relative expiries like '2 months', use calendar months. expires_at accepts epoch milliseconds or ISO/date strings.", + writeToolName: "createBalance", + preview: (request) => ({ + action: "createBalance", + request, + impact: + "Creates a standalone balance grant. If entity_id is present, the balance is scoped to that entity. If expires_at is present, the grant expires at that timestamp.", + }), + }), + ], +} satisfies ToolDomain; + +export const balances = { endpoints, schemas, domain }; diff --git a/packages/mcp/src/tools/billing.ts b/packages/mcp/src/tools/billing.ts new file mode 100644 index 000000000..59238235b --- /dev/null +++ b/packages/mcp/src/tools/billing.ts @@ -0,0 +1,130 @@ +import { + AttachParamsV1Schema, + CreateScheduleParamsV0Schema, + CreateSchedulePhaseSchema, + UpdateSubscriptionV1ParamsSchema, +} from "@autumn/shared/publicApiSchemas"; +import * as z from "zod/v4"; +import { createDomainTools } from "./utils/builders.js"; +import { epochMillisecondsSchema } from "./utils/dates.js"; +import type { ToolDomain } from "./utils/types.js"; + +const createSchedulePhaseMcpSchema = CreateSchedulePhaseSchema.extend({ + starts_at: epochMillisecondsSchema.meta({ + description: + "Phase start time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.", + }), +}); + +const createScheduleMcpSchema = CreateScheduleParamsV0Schema.extend({ + phases: z + .tuple([createSchedulePhaseMcpSchema]) + .rest(createSchedulePhaseMcpSchema), +}); + +const endpoints = { + previewAttach: "/v1/billing.preview_attach", + attach: "/v1/billing.attach", + previewUpdateSubscription: "/v1/billing.preview_update", + updateSubscription: "/v1/billing.update", + previewCreateSchedule: "/v1/billing.preview_create_schedule", + createSchedule: "/v1/billing.create_schedule", +} as const; + +const schemas = { + previewAttach: AttachParamsV1Schema, + attach: AttachParamsV1Schema, + previewUpdateSubscription: UpdateSubscriptionV1ParamsSchema, + updateSubscription: UpdateSubscriptionV1ParamsSchema, + previewCreateSchedule: createScheduleMcpSchema, + createSchedule: createScheduleMcpSchema, +} as const; + +const { billingPreview, confirmedWrite } = createDomainTools({ + endpoints, + schemas, +}); + +const domain = { + billingPreviews: [ + billingPreview({ + id: "previewAttach", + description: ` +- Preview attaching a plan before attach. +- Include feature_quantities and custom items/prices. +- Map recurring custom grants like 'per month/year' to reset.interval. +- Default paid attach billing: set enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false. +- Only change the default invoice mode if the user asks for checkout, immediate finalization/payment, no invoice, or delayed access. +- invoice_mode requires customer email; if missing, call updateCustomer first. +`.trim(), + writeToolName: "attach", + }), + billingPreview({ + id: "previewUpdateSubscription", + description: ` +- Preview updating a subscription before updateSubscription. +- Include quantity and custom item changes. +- Recurring custom grants need reset.interval. +`.trim(), + writeToolName: "updateSubscription", + }), + billingPreview({ + id: "previewCreateSchedule", + description: ` +- Preview billing impact of a multi-phase schedule before createSchedule. +- First phase starts_at must be explicit: now or a past/backdated date. +- Do not infer first starts_at from 'year 1' or use a future first phase. +- Ask before previewing if first phase start is unclear. +- Preserve exact user/contract dates for later phases. +- Use redirect_mode if_required unless user asks otherwise. +- Default paid schedule billing: set enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false. +- Only change the default invoice mode if the user asks for checkout, immediate finalization/payment, no invoice, or delayed access. +- invoice_mode requires customer email; if missing, call updateCustomer first. +- Inspect customer first when changing an existing/customer contract schedule. +- Put schedule feature overrides in plan.customize.items, not feature_quantities. +- Map recurring grants like 'per month/year' to reset.interval month/year. +- If year 1 is already paid/no billing changes, omit it. +`.trim(), + writeToolName: "createSchedule", + }), + ], + confirmedWrites: [ + confirmedWrite({ + id: "attach", + description: ` +- Attach a plan to a customer. +- Destructive: preview first. +- Preserve feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior. +- Preserve the previewed billing mode. Default paid attach billing uses enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false. +- invoice_mode requires customer email; if missing, call updateCustomer first. +`.trim(), + }), + confirmedWrite({ + id: "updateSubscription", + description: ` +- Update a subscription. +- Destructive: preview first. +- Preserve quantity/custom item changes and reset intervals from the previewed request. +`.trim(), + }), + confirmedWrite({ + id: "createSchedule", + description: ` +- Create a multi-phase billing schedule. +- Destructive: preview first. +- Preserve phase starts_at and redirect_mode values from the previewed request. +- First phase starts_at must be explicit: now or a past/backdated date. +- Do not infer first starts_at from 'year 1' or use a future first phase. +- Ask before creating if first phase start is unclear. +- Use redirect_mode if_required unless user asks otherwise. +- Preserve the previewed billing mode. Default paid schedule billing uses enable_plan_immediately true and invoice_mode enabled true, enable_plan_immediately true, finalize false. +- invoice_mode requires customer email; if missing, call updateCustomer first. +- Inspect customer first when changing an existing/customer contract schedule. +- Put schedule feature overrides in plan.customize.items, not feature_quantities. +- If year 1 is already paid/no billing changes, omit it. +`.trim(), + }), + ], +} satisfies ToolDomain; + +export const billing = { endpoints, schemas, domain }; diff --git a/packages/mcp/src/tools/customers.ts b/packages/mcp/src/tools/customers.ts new file mode 100644 index 000000000..8785149b1 --- /dev/null +++ b/packages/mcp/src/tools/customers.ts @@ -0,0 +1,61 @@ +import { + CreateCustomerParamsV1Schema, + GetCustomerParamsV1Schema, + ListCustomersV2_3ParamsSchema, + UpdateCustomerParamsV1Schema, +} from "@autumn/shared/publicApiSchemas"; +import * as z from "zod/v4"; +import { createDomainTools } from "./utils/builders.js"; +import type { ToolDomain } from "./utils/types.js"; + +const listCustomersSchema = ListCustomersV2_3ParamsSchema.extend({ + limit: z + .preprocess( + (value) => (typeof value === "number" && value > 1000 ? 1000 : value), + z.number().int().positive().max(1000).optional(), + ) + .meta({ description: "Maximum customers per page. Max 1000." }), +}); + +const endpoints = { + listCustomers: "/v1/customers.list", + getOrCreateCustomer: "/v1/customers.get_or_create", + updateCustomer: "/v1/customers.update", + getCustomer: "/v1/customers.get", +} as const; + +const schemas = { + listCustomers: listCustomersSchema, + getOrCreateCustomer: CreateCustomerParamsV1Schema, + updateCustomer: UpdateCustomerParamsV1Schema, + getCustomer: GetCustomerParamsV1Schema, +} as const; + +const { operation } = createDomainTools({ endpoints, schemas }); + +const domain = { + operations: [ + operation({ + id: "listCustomers", + description: + "List Autumn customers. Use search, plans, subscription_status, and processors filters for customer-heavy queries. limit max is 1000. For queued/upcoming plan version queries, use subscription_status scheduled and omit the earliest matching version unless the user asks for all historical versions (versions 1,2,3 -> filter 2,3). 'live', 'paying', and active subscribers usually mean subscription_status active. When a plan is named, include the plans filter instead of listing broad customer sets. If listPlans returned matching versions, pass only relevant versions in plans[].versions, never guessed versions. For every/all/complete requests, paginate by calling again with start_cursor set to the previous response's next_cursor until next_cursor is empty.", + }), + operation({ + id: "getOrCreateCustomer", + description: + "Get an existing Autumn customer by id, or create it if missing. Use when the user explicitly wants a customer record created.", + idempotent: true, + }), + operation({ + id: "updateCustomer", + description: + "Update an existing Autumn customer. For invoice_mode billing, set missing email with customer_id and email before previewing billing so linked Stripe customer records are updated.", + }), + operation({ + id: "getCustomer", + description: "Fetch one Autumn customer by id.", + }), + ], +} satisfies ToolDomain; + +export const customers = { endpoints, schemas, domain }; diff --git a/packages/mcp/src/tools/features.ts b/packages/mcp/src/tools/features.ts new file mode 100644 index 000000000..96d48db9f --- /dev/null +++ b/packages/mcp/src/tools/features.ts @@ -0,0 +1,27 @@ +import * as z from "zod/v4"; +import { createDomainTools } from "./utils/builders.js"; +import type { ToolDomain } from "./utils/types.js"; + +const listFeaturesSchema = z.object({}).strict(); + +const endpoints = { + listFeatures: "/v1/features.list", +} as const; + +const schemas = { + listFeatures: listFeaturesSchema, +} as const; + +const { operation } = createDomainTools({ endpoints, schemas }); + +const domain = { + operations: [ + operation({ + id: "listFeatures", + description: + "List Autumn features. Use when creating/customizing plan items or setting non-zero prepaid feature quantities and feature ids, types, credit systems, or consumable behavior are not already known.", + }), + ], +} satisfies ToolDomain; + +export const features = { endpoints, schemas, domain }; diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 000000000..6e3f44662 --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1,167 @@ +import { createTool } from "@mastra/core/tools"; +import * as z from "zod/v4"; +import { claimLatestPendingAction } from "../agent/pending-actions.js"; +import { instrumentToolsWithAnalytics } from "../analytics/index.js"; +import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js"; +import { agent } from "./agent.js"; +import { balances } from "./balances.js"; +import { billing } from "./billing.js"; +import { customers } from "./customers.js"; +import { features } from "./features.js"; +import { logs } from "./logs.js"; +import { orgTools } from "./org.js"; +import { plans } from "./plans.js"; +import { callAutumn } from "./utils/client.js"; +import { + dateToEpochMillisecondsTool, + epochMillisecondsToDateTool, +} from "./utils/dates.js"; +import { logTool } from "./utils/debug.js"; +import { + agentBillingPreviewTool, + agentLocalPreviewTool, + agentPendingWriteTool, + operationTool, + rawLocalPreviewTool, + toTools, +} from "./utils/factories.js"; +import { requireIntentOnTools } from "./utils/intent.js"; +import type { ConfirmedWriteToolName, ToolDomain } from "./utils/types.js"; + +export { + dateToEpochMillisecondsTool, + epochMillisecondsToDateTool, +} from "./utils/dates.js"; + +/** Endpoint each tool calls, keyed by tool id (preview tools use their preview path). */ +export const endpointByTool = { + ...agent.endpoints, + ...customers.endpoints, + ...features.endpoints, + ...plans.endpoints, + ...billing.endpoints, + ...balances.endpoints, + ...logs.endpoints, +} as const; + +/** Request schema each tool validates against, keyed by tool id. */ +export const schemaByTool = { + ...agent.schemas, + ...customers.schemas, + ...features.schemas, + ...plans.schemas, + ...billing.schemas, + ...balances.schemas, + ...logs.schemas, +} as const satisfies Record< + keyof typeof endpointByTool | "previewCreateBalance", + z.ZodType +>; + +const domains: ToolDomain[] = [ + agent.domain, + customers.domain, + features.domain, + plans.domain, + billing.domain, + balances.domain, + logs.domain, +]; +const operations = domains.flatMap((domain) => domain.operations ?? []); +const billingPreviews = domains.flatMap( + (domain) => domain.billingPreviews ?? [], +); +const localPreviews = domains.flatMap((domain) => domain.localPreviews ?? []); +const confirmedWrites = domains.flatMap( + (domain) => domain.confirmedWrites ?? [], +); + +type ToolRecord = Record>; + +/** + * Public MCP toolset: previews call Autumn's preview endpoints directly and + * writes apply immediately (external clients gate destructive calls themselves). + */ +const createRawAutumnOperationToolset = (): ToolRecord => ({ + ...requireIntentOnTools({ + ...toTools(operations, operationTool), + ...toTools(billingPreviews, (config) => + operationTool({ ...config, endpoint: config.previewEndpoint }), + ), + ...toTools(localPreviews, rawLocalPreviewTool), + ...toTools(confirmedWrites, operationTool), + ...orgTools, + } as ToolRecord), + dateToEpochMilliseconds: dateToEpochMillisecondsTool, + epochMillisecondsToDate: epochMillisecondsToDateTool, +}); + +export const createRawAutumnOperationTools = () => + instrumentToolsWithAnalytics({ + // Require a one-sentence `intent` on every external tool call so we can + // see what clients are actually trying to do (captured in analytics). + tools: createRawAutumnOperationToolset(), + surface: "mcp", + }); + +/** Applies a previously-staged billing write after the user confirms it. */ +export const executeConfirmedBillingAction = ({ + auth, + toolName, + request, +}: { + auth: AutumnMcpAuth; + toolName: ConfirmedWriteToolName; + request: unknown; +}) => + callAutumn({ + auth, + endpoint: endpointByTool[toolName], + request: schemaByTool[toolName].parse(request), + }); + +/** + * Agent toolset: destructive operations and billing writes are staged as pending + * actions (preview-first), then applied via `confirmBillingAction` once approved. + */ +const createAgentAutumnOperationToolset = (): ToolRecord => ({ + ...toTools( + operations.filter(({ destructive }) => !destructive), + operationTool, + ), + ...toTools( + operations.filter(({ destructive }) => destructive), + agentPendingWriteTool, + ), + ...toTools(billingPreviews, agentBillingPreviewTool), + ...toTools(localPreviews, agentLocalPreviewTool), + dateToEpochMilliseconds: dateToEpochMillisecondsTool, + epochMillisecondsToDate: epochMillisecondsToDateTool, + confirmBillingAction: createTool({ + id: "confirmBillingAction", + description: + "Apply the latest pending billing action after the user semantically confirms the preview.", + inputSchema: z.object({}).strict(), + execute: async (_input, context) => { + const auth = getAutumnAuth(context); + logTool("confirm-start", { env: auth.env }); + const action = await claimLatestPendingAction(auth); + logTool("confirm-claimed", { toolName: action.toolName }); + const result = await executeConfirmedBillingAction({ + auth, + toolName: action.toolName, + request: action.request, + }); + return { + message: `Confirmed and applied ${action.toolName}.`, + result, + }; + }, + }), +}); + +export const createAgentAutumnOperationTools = () => + instrumentToolsWithAnalytics({ + tools: createAgentAutumnOperationToolset(), + surface: "agent", + }); diff --git a/packages/mcp/src/tools/logs.ts b/packages/mcp/src/tools/logs.ts new file mode 100644 index 000000000..775b85e9a --- /dev/null +++ b/packages/mcp/src/tools/logs.ts @@ -0,0 +1,55 @@ +import * as z from "zod/v4"; +import { createDomainTools } from "./utils/builders.js"; +import type { ToolDomain } from "./utils/types.js"; + +const logsRangeSchema = z + .object({ + start_date: z.string().optional(), + end_date: z.string().optional(), + }) + .strict(); + +const searchRequestLogsSchema = z + .object({ + query: z.string().max(4000).optional(), + range: logsRangeSchema.optional(), + limit: z.number().int().positive().max(200).optional(), + }) + .strict(); + +const queryRequestLogsSchema = z + .object({ + query: z.string().min(1).max(4000), + range: logsRangeSchema.optional(), + limit: z.number().int().positive().max(200).optional(), + }) + .strict(); + +const endpoints = { + searchRequestLogs: "/v1/logs.search", + queryRequestLogs: "/v1/logs.query", +} as const; + +const schemas = { + searchRequestLogs: searchRequestLogsSchema, + queryRequestLogs: queryRequestLogsSchema, +} as const; + +const { operation } = createDomainTools({ endpoints, schemas }); + +const domain = { + operations: [ + operation({ + id: "searchRequestLogs", + description: + "Search tenant-scoped Autumn API request logs. Use this for listing matching request records, inspecting request/response bodies, and debugging recent customer API calls. Supports restricted APL over projected request-log fields only.", + }), + operation({ + id: "queryRequestLogs", + description: + "Query tenant-scoped Autumn API request logs with aggregate restricted APL. Use this for counts, grouping, and request-log statistics such as errors by path or status-code breakdowns.", + }), + ], +} satisfies ToolDomain; + +export const logs = { endpoints, schemas, domain }; diff --git a/packages/mcp/src/tools/org.ts b/packages/mcp/src/tools/org.ts new file mode 100644 index 000000000..ab567d5e3 --- /dev/null +++ b/packages/mcp/src/tools/org.ts @@ -0,0 +1,34 @@ +import { createTool } from "@mastra/core/tools"; +import * as z from "zod/v4"; +import { getAutumnAuth } from "../server/auth/auth.js"; +import { mcpAnnotations } from "./utils/annotations.js"; +import { callAutumnGet } from "./utils/client.js"; + +const organizationMeSchema = z + .object({ + name: z.string(), + slug: z.string(), + env: z.string(), + }) + .strict(); + +const signalOf = (context: { mcp?: { extra?: { signal?: AbortSignal } } }) => + context?.mcp?.extra?.signal; + +export const orgTools = { + getCurrentOrganization: createTool({ + id: "getCurrentOrganization", + description: + "Fetch the current Autumn organization name, slug, and environment.", + inputSchema: z.object({}).strict(), + mcp: { annotations: mcpAnnotations() }, + execute: async (_input, context) => + organizationMeSchema.parse( + await callAutumnGet({ + auth: getAutumnAuth(context), + endpoint: "/v1/organization/me", + signal: signalOf(context), + }), + ), + }), +} as const; diff --git a/packages/mcp/src/tools/plans.ts b/packages/mcp/src/tools/plans.ts new file mode 100644 index 000000000..1eb37c1bb --- /dev/null +++ b/packages/mcp/src/tools/plans.ts @@ -0,0 +1,43 @@ +import { + CreatePlanParamsV2Schema, + GetPlanParamsV0Schema, + ListPlanParamsSchema, +} from "@autumn/shared/publicApiSchemas"; +import { createDomainTools } from "./utils/builders.js"; +import type { ToolDomain } from "./utils/types.js"; + +const endpoints = { + listPlans: "/v1/plans.list", + createPlan: "/v1/plans.create", + getPlan: "/v1/plans.get", +} as const; + +const schemas = { + listPlans: ListPlanParamsSchema, + createPlan: CreatePlanParamsV2Schema, + getPlan: GetPlanParamsV0Schema, +} as const; + +const { operation } = createDomainTools({ endpoints, schemas }); + +const domain = { + operations: [ + operation({ + id: "listPlans", + description: + "List Autumn plans. This is usually a cheap full scan; filter returned plans locally and use matching id/version pairs before customer queries based on plan attributes.", + }), + operation({ + id: "createPlan", + description: + "Create an Autumn plan. Destructive configuration write: gather plan_id, name, price, features/items, trials, and confirmation before running.", + destructive: true, + }), + operation({ + id: "getPlan", + description: "Fetch one Autumn plan by id and optional version.", + }), + ], +} satisfies ToolDomain; + +export const plans = { endpoints, schemas, domain }; diff --git a/packages/mcp/src/tools/utils/annotations.ts b/packages/mcp/src/tools/utils/annotations.ts new file mode 100644 index 000000000..0abf7af86 --- /dev/null +++ b/packages/mcp/src/tools/utils/annotations.ts @@ -0,0 +1,13 @@ +/** MCP tool hints describing the side effects of a tool call. */ +export const mcpAnnotations = ({ + destructive = false, + idempotent = false, +}: { + destructive?: boolean; + idempotent?: boolean; +} = {}) => ({ + readOnlyHint: !destructive && !idempotent, + destructiveHint: destructive, + idempotentHint: idempotent, + openWorldHint: false, +}); diff --git a/packages/mcp/src/tools/utils/builders.ts b/packages/mcp/src/tools/utils/builders.ts new file mode 100644 index 000000000..07663dbaa --- /dev/null +++ b/packages/mcp/src/tools/utils/builders.ts @@ -0,0 +1,100 @@ +import type * as z from "zod/v4"; +import type { + BillingPreviewToolConfig, + ConfirmedWriteToolName, + LocalPreviewToolConfig, + OperationToolConfig, +} from "./types.js"; + +/** + * Domain-scoped config composers bound to a domain's `endpoints` and `schemas` + * maps. A tool's `id` keys into both maps, so each tool declares its id, + * description, and semantics once — the schema and endpoint are looked up rather + * than repeated. The `id` is type-checked against the relevant map keys. + */ +export const createDomainTools = < + E extends Record, + S extends Record, +>({ + endpoints, + schemas, +}: { + endpoints: E; + schemas: S; +}) => { + type EndpointId = Extract; + type SchemaId = Extract; + + /** A tool that calls its endpoint directly with the parsed request. */ + const operation = ({ + id, + description, + destructive = false, + idempotent = false, + }: { + id: EndpointId; + description: string; + destructive?: boolean; + idempotent?: boolean; + }): OperationToolConfig => ({ + id, + description, + schema: schemas[id], + endpoint: endpoints[id], + destructive, + idempotent, + }); + + /** A preview tool that stages a pending billing write via its preview endpoint. */ + const billingPreview = ({ + id, + description, + writeToolName, + }: { + id: EndpointId; + description: string; + writeToolName: ConfirmedWriteToolName; + }): BillingPreviewToolConfig => ({ + id, + description, + schema: schemas[id], + previewEndpoint: endpoints[id], + writeToolName, + }); + + /** A destructive write applied only after the user confirms a preview. */ + const confirmedWrite = ({ + id, + description, + }: { + id: EndpointId; + description: string; + }): OperationToolConfig => ({ + id, + description, + schema: schemas[id], + endpoint: endpoints[id], + destructive: true, + }); + + /** A preview computed locally (no Autumn call) before a billing write. */ + const localPreview = ({ + id, + description, + writeToolName, + preview, + }: { + id: SchemaId; + description: string; + writeToolName: ConfirmedWriteToolName; + preview: (request: unknown) => unknown; + }): LocalPreviewToolConfig => ({ + id, + description, + schema: schemas[id], + writeToolName, + preview, + }); + + return { operation, billingPreview, confirmedWrite, localPreview }; +}; diff --git a/packages/mcp/src/tools/utils/client.ts b/packages/mcp/src/tools/utils/client.ts new file mode 100644 index 000000000..a5033f9f3 --- /dev/null +++ b/packages/mcp/src/tools/utils/client.ts @@ -0,0 +1,74 @@ +import { + type AutumnMcpAuth, + createAutumnClient, +} from "../../server/auth/auth.js"; + +const parseBody = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return text; + } +}; + +/** POSTs a request to an Autumn endpoint using the caller's resolved auth. */ +export const callAutumn = async ({ + auth, + endpoint, + request, + signal, +}: { + auth: AutumnMcpAuth; + endpoint: string; + request: unknown; + signal?: AbortSignal | undefined; +}) => { + const client = createAutumnClient(auth); + const init: RequestInit = { + method: "POST", + headers: client.headers, + body: JSON.stringify(request), + }; + if (signal) init.signal = signal; + + const response = await fetch(new URL(endpoint, client.baseUrl), init); + const text = await response.text(); + const body = text ? parseBody(text) : null; + if (!response.ok) { + throw new Error( + `Autumn API request failed (${response.status}): ${ + typeof body === "string" ? body : JSON.stringify(body) + }`, + ); + } + return body; +}; + +export const callAutumnGet = async ({ + auth, + endpoint, + signal, +}: { + auth: AutumnMcpAuth; + endpoint: string; + signal?: AbortSignal | undefined; +}) => { + const client = createAutumnClient(auth); + const init: RequestInit = { + method: "GET", + headers: client.headers, + }; + if (signal) init.signal = signal; + + const response = await fetch(new URL(endpoint, client.baseUrl), init); + const text = await response.text(); + const body = text ? parseBody(text) : null; + if (!response.ok) { + throw new Error( + `Autumn API request failed (${response.status}): ${ + typeof body === "string" ? body : JSON.stringify(body) + }`, + ); + } + return body; +}; diff --git a/packages/mcp/src/tools/utils/dates.ts b/packages/mcp/src/tools/utils/dates.ts new file mode 100644 index 000000000..657eee13b --- /dev/null +++ b/packages/mcp/src/tools/utils/dates.ts @@ -0,0 +1,117 @@ +import { createTool } from "@mastra/core/tools"; +import { isValid, parseISO } from "date-fns"; +import * as z from "zod/v4"; + +/** + * Parses an ISO date/timestamp string to UTC epoch milliseconds. Date-only + * values (`YYYY-MM-DD`) and zone-less timestamps are treated as UTC. Returns + * `null` when the input is not a valid date. + */ +const parseToEpochMilliseconds = (value: string): number | null => { + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value) + ? `${value}T00:00:00.000` + : value; + const hasExplicitZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(normalized); + const parsed = parseISO(hasExplicitZone ? normalized : `${normalized}Z`); + return isValid(parsed) ? parsed.getTime() : null; +}; + +/** Accepts epoch milliseconds or an ISO date/timestamp string; outputs epoch ms. */ +export const epochMillisecondsSchema = z + .union([z.number(), z.string()]) + .transform((value, context) => { + if (typeof value === "number") { + if (Number.isFinite(value)) return value; + } else { + const epoch = parseToEpochMilliseconds(value); + if (epoch !== null) return epoch; + } + + context.addIssue({ + code: "custom", + message: "Expected epoch milliseconds or an ISO date/timestamp string.", + }); + return z.NEVER; + }); + +const toEpochMilliseconds = (date: string): number => { + const epoch = parseToEpochMilliseconds(date); + if (epoch === null) throw new Error(`Invalid date: ${date}`); + return epoch; +}; + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +] as const; + +const pad = (value: number) => String(value).padStart(2, "0"); + +const formatUtcDate = (date: Date) => + `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}, ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`; + +const parseEpochMilliseconds = (value: number | string): number => { + const epoch = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(epoch)) { + throw new Error(`Invalid epoch milliseconds: ${value}`); + } + return epoch; +}; + +const epochMillisecondsToDate = ( + epochMsByKey: Record, +) => + Object.fromEntries( + Object.entries(epochMsByKey).map(([key, value]) => { + const epochMs = parseEpochMilliseconds(value); + const date = new Date(epochMs); + if (!Number.isFinite(date.getTime())) { + throw new Error(`Invalid epoch milliseconds: ${value}`); + } + return [ + key, + { + epoch_ms: epochMs, + iso: date.toISOString(), + utc: formatUtcDate(date), + }, + ]; + }), + ); + +export const dateToEpochMillisecondsTool = createTool({ + id: "dateToEpochMilliseconds", + description: + "Convert a calendar date or ISO timestamp to UTC epoch milliseconds for API timestamp fields. Date-only values default to midnight UTC; include an explicit offset in the date string when timezone matters.", + inputSchema: z + .object({ + date: z.string(), + }) + .strict(), + execute: async ({ date }) => toEpochMilliseconds(date), +}); + +export const epochMillisecondsToDateTool = createTool({ + id: "epochMillisecondsToDate", + description: + "Convert one or more epoch millisecond timestamps from Autumn responses into UTC date formats. Use this before explaining starts_at, expires_at, next_reset_at, or other millisecond timestamp fields to users.", + inputSchema: z + .object({ + timestamps: z.record(z.string(), z.union([z.number(), z.string()])).meta({ + description: + "Object keyed by semantic timestamp names, with epoch millisecond values.", + }), + }) + .strict(), + execute: async ({ timestamps }) => epochMillisecondsToDate(timestamps), +}); diff --git a/packages/mcp/src/tools/utils/debug.ts b/packages/mcp/src/tools/utils/debug.ts new file mode 100644 index 000000000..9fb358454 --- /dev/null +++ b/packages/mcp/src/tools/utils/debug.ts @@ -0,0 +1,5 @@ +/** Opt-in tracing for the pending-action flow (set MCP_DEBUG_PENDING_ACTIONS=1). */ +export const logTool = (event: string, data: Record) => { + if (process.env.MCP_DEBUG_PENDING_ACTIONS !== "1") return; + console.log(`[mcp:agent-tools] ${event} ${JSON.stringify(data)}`); +}; diff --git a/packages/mcp/src/tools/utils/factories.ts b/packages/mcp/src/tools/utils/factories.ts new file mode 100644 index 000000000..2af613aff --- /dev/null +++ b/packages/mcp/src/tools/utils/factories.ts @@ -0,0 +1,164 @@ +import { createTool } from "@mastra/core/tools"; +import * as z from "zod/v4"; +import { createPendingAction } from "../../agent/pending-actions.js"; +import { getAutumnAuth } from "../../server/auth/auth.js"; +import { mcpAnnotations } from "./annotations.js"; +import { callAutumn } from "./client.js"; +import { logTool } from "./debug.js"; +import { + type BillingPreviewToolConfig, + isConfirmedWriteToolName, + type LocalPreviewToolConfig, + type OperationToolConfig, +} from "./types.js"; + +const PENDING_MESSAGE = + "Preview ready. Ask the user to explicitly apply or approve this exact change."; + +/** Reads the `request` payload out of a tool input without casting. */ +const getRequest = (input: unknown): unknown => + input && typeof input === "object" && "request" in input + ? input.request + : undefined; + +const signalOf = (context: { mcp?: { extra?: { signal?: AbortSignal } } }) => + context?.mcp?.extra?.signal; + +/** Builds a `{ id: tool }` record from a list of configs. */ +export const toTools = ( + configs: Config[], + create: (config: Config) => ReturnType, +) => Object.fromEntries(configs.map((config) => [config.id, create(config)])); + +/** Calls an Autumn endpoint directly with the parsed request. */ +export const operationTool = ({ + id, + description, + schema, + endpoint, + destructive = false, + idempotent = false, +}: OperationToolConfig) => + createTool({ + id, + description, + inputSchema: z.object({ request: schema }).strict(), + mcp: { annotations: mcpAnnotations({ destructive, idempotent }) }, + execute: (input, context) => + callAutumn({ + auth: getAutumnAuth(context), + endpoint, + request: schema.parse(getRequest(input)), + signal: signalOf(context), + }), + }); + +/** Agent variant: previews via Autumn, then stages a pending billing write. */ +export const agentBillingPreviewTool = ({ + id, + description, + schema, + previewEndpoint, + writeToolName, +}: BillingPreviewToolConfig) => + createTool({ + id, + description: `${description} Store the exact pending billing action for later confirmation.`, + inputSchema: z.object({ request: schema }).strict(), + mcp: { annotations: mcpAnnotations() }, + execute: async (input, context) => { + const parsedRequest = schema.parse(getRequest(input)); + const auth = getAutumnAuth(context); + logTool("preview-start", { previewTool: id, writeToolName }); + const preview = await callAutumn({ + auth, + endpoint: previewEndpoint, + request: parsedRequest, + signal: signalOf(context), + }); + await createPendingAction({ + auth, + toolName: writeToolName, + request: parsedRequest, + preview: JSON.stringify(preview), + }); + logTool("preview-stored", { previewTool: id, writeToolName }); + return { preview, pending: true, message: PENDING_MESSAGE }; + }, + }); + +/** Raw variant of a local preview: just returns the computed preview. */ +export const rawLocalPreviewTool = ({ + id, + description, + schema, + preview, +}: LocalPreviewToolConfig) => + createTool({ + id, + description, + inputSchema: z.object({ request: schema }).strict(), + mcp: { annotations: mcpAnnotations() }, + execute: async (input) => preview(schema.parse(getRequest(input))), + }); + +/** Agent variant of a local preview: stages a pending billing write. */ +export const agentLocalPreviewTool = ({ + id, + description, + schema, + writeToolName, + preview, +}: LocalPreviewToolConfig) => + createTool({ + id, + description: `${description} Store the exact pending billing action for later confirmation.`, + inputSchema: z.object({ request: schema }).strict(), + mcp: { annotations: mcpAnnotations() }, + execute: async (input, context) => { + const parsedRequest = schema.parse(getRequest(input)); + const previewResult = preview(parsedRequest); + await createPendingAction({ + auth: getAutumnAuth(context), + toolName: writeToolName, + request: parsedRequest, + preview: JSON.stringify(previewResult), + }); + return { + preview: previewResult, + pending: true, + message: PENDING_MESSAGE, + }; + }, + }); + +/** Agent variant of a destructive operation: stages the request instead of applying it. */ +export const agentPendingWriteTool = ({ + id, + description, + schema, +}: OperationToolConfig) => + createTool({ + id, + description: `${description} This internal agent tool stores the exact request for later confirmation instead of applying it immediately.`, + inputSchema: z.object({ request: schema }).strict(), + mcp: { annotations: mcpAnnotations() }, + execute: async (input, context) => { + if (!isConfirmedWriteToolName(id)) { + throw new Error(`Cannot stage a pending write for tool: ${id}`); + } + const parsedRequest = schema.parse(getRequest(input)); + await createPendingAction({ + auth: getAutumnAuth(context), + toolName: id, + request: parsedRequest, + preview: JSON.stringify(parsedRequest), + }); + return { + pending: true, + request: parsedRequest, + message: + "Request ready. Ask the user to explicitly apply or approve this exact change.", + }; + }, + }); diff --git a/packages/mcp/src/tools/utils/intent.ts b/packages/mcp/src/tools/utils/intent.ts new file mode 100644 index 000000000..0b72b97a5 --- /dev/null +++ b/packages/mcp/src/tools/utils/intent.ts @@ -0,0 +1,47 @@ +import type { createTool } from "@mastra/core/tools"; +import * as z from "zod/v4"; + +type AnyTool = ReturnType; + +export const INTENT_DESCRIPTION = + "Required. One concise sentence, in plain language, describing what the user " + + "asked you (the agent) to do — their original request in their own terms, " + + "not a restatement of the arguments or the tool name. If this call is one " + + 'step toward a larger ask, state that larger ask. Example: "Find customers ' + + 'on the Pro plan so we can email them about the new add-on."'; + +/** Required single-sentence statement of what the caller is trying to do. */ +export const intentSchema = z.string().min(1).describe(INTENT_DESCRIPTION); + +/** Reads the `intent` string out of a tool input without casting. */ +export const getIntent = (input: unknown): string | undefined => + input && + typeof input === "object" && + "intent" in input && + typeof input.intent === "string" + ? input.intent + : undefined; + +/** + * Adds a required `intent` field to every tool's input schema, in place, so + * external MCP clients must declare their goal on every call. Call this once on + * a fully-built toolset (the intent is captured by the analytics layer). + * + * Tools whose input isn't a plain object are left untouched. + */ +export const requireIntentOnTools = >( + tools: T, +): T => { + for (const tool of Object.values(tools)) { + const schema = tool.inputSchema; + if (schema instanceof z.ZodObject) { + // Runtime value is a plain zod object, but Mastra types the field as its + // JSON-schema-augmented schema (incompatible at the type level only), so + // route the reassignment through `unknown`. + tool.inputSchema = schema.extend({ + intent: intentSchema, + }) as unknown as typeof tool.inputSchema; + } + } + return tools; +}; diff --git a/packages/mcp/src/tools/utils/types.ts b/packages/mcp/src/tools/utils/types.ts new file mode 100644 index 000000000..e943f6694 --- /dev/null +++ b/packages/mcp/src/tools/utils/types.ts @@ -0,0 +1,61 @@ +import type * as z from "zod/v4"; + +/** + * Tool names that mutate billing state. These are the only tools that can be + * staged as a pending action and later applied via `confirmBillingAction`. + * Declared as a tuple so the union type and runtime guard stay in sync. + */ +export const CONFIRMED_WRITE_TOOL_NAMES = [ + "attach", + "updateSubscription", + "createPlan", + "createSchedule", + "createBalance", +] as const; + +export type ConfirmedWriteToolName = + (typeof CONFIRMED_WRITE_TOOL_NAMES)[number]; + +export const isConfirmedWriteToolName = ( + id: string, +): id is ConfirmedWriteToolName => + CONFIRMED_WRITE_TOOL_NAMES.some((name) => name === id); + +/** A tool that calls a single Autumn endpoint with the parsed request. */ +export type OperationToolConfig = { + id: string; + description: string; + schema: z.ZodType; + endpoint: string; + destructive?: boolean; + idempotent?: boolean; +}; + +/** A preview tool whose result is staged as a pending billing write. */ +export type BillingPreviewToolConfig = { + id: string; + description: string; + schema: z.ZodType; + previewEndpoint: string; + writeToolName: ConfirmedWriteToolName; +}; + +/** A preview tool computed locally (no Autumn call) before a billing write. */ +export type LocalPreviewToolConfig = { + id: string; + description: string; + schema: z.ZodType; + writeToolName: ConfirmedWriteToolName; + preview: (request: unknown) => unknown; +}; + +/** + * One business domain's tool declarations, grouped by behaviour. The top-level + * `index.ts` composes these into the raw (MCP) and agent toolsets. + */ +export type ToolDomain = { + operations?: OperationToolConfig[]; + billingPreviews?: BillingPreviewToolConfig[]; + localPreviews?: LocalPreviewToolConfig[]; + confirmedWrites?: OperationToolConfig[]; +}; diff --git a/packages/mcp/tests/evals/create-balance-evals.test.ts b/packages/mcp/tests/evals/create-balance-evals.test.ts new file mode 100644 index 000000000..b3891321a --- /dev/null +++ b/packages/mcp/tests/evals/create-balance-evals.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { addMonths, parseISO } from "date-fns"; +import { + expectExactApiCall, + expectNoApiCall, + expectNoToolCall, + expectToolCall, + initMcpEval, + type ToolRequestInput, +} from "../utils/eval-test-utils.js"; + +const today = parseISO("2026-01-15T00:00:00.000Z"); +const expectedGrant = { + customer_id: "cus_687672c4c0d36fa5679f8c7a", + entity_id: "ent_689d243e2c03da31e0ac90d0", + feature_id: "credits", + included_grant: 50000, + expires_at: addMonths(today, 2).getTime(), +} satisfies ToolRequestInput<"createBalance">; + +test("previews and creates an entity-scoped expiring credit grant", async () => { + const { api, approve, generate, toolCalls } = initMcpEval({ + today, + fixtures: { + getCustomer: { + id: expectedGrant.customer_id, + entities: [ + { + id: expectedGrant.entity_id, + name: "Contract workspace", + }, + ], + balances: {}, + }, + listPlans: { + list: [ + { + id: "team", + name: "Team", + items: [{ feature_id: "credits", feature_type: "metered" }], + }, + ], + }, + createBalance: { success: true }, + }, + }); + + await generate( + [ + "Looking to give entity ent_689d243e2c03da31e0ac90d0 on customer cus_687672c4c0d36fa5679f8c7a 50k credits on the credits feature that expire in 2 months. Can you set that up in Autumn?", + "These should not be permanent credits.", + ], + 6, + ); + + expectToolCall(toolCalls, "previewCreateBalance", expectedGrant); + expectNoApiCall(api, "createBalance"); + + await approve("Looks good, apply that exact credit grant."); + + expectToolCall(toolCalls, "createBalance", expectedGrant); + expectExactApiCall(api, "createBalance", expectedGrant); + expectNoToolCall(toolCalls, "attach"); + expectNoToolCall(toolCalls, "updateSubscription"); + expectNoToolCall(toolCalls, "createSchedule"); + expect(api.call("createBalance")?.rawBody).not.toHaveProperty("reset"); + expect(api.call("createBalance")?.rawBody).not.toHaveProperty( + "granted_balance", + ); +}, 45000); diff --git a/packages/mcp/tests/evals/create-schedule-evals.test.ts b/packages/mcp/tests/evals/create-schedule-evals.test.ts new file mode 100644 index 000000000..d2e95bca4 --- /dev/null +++ b/packages/mcp/tests/evals/create-schedule-evals.test.ts @@ -0,0 +1,384 @@ +import { expect, test } from "bun:test"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval"; +import { ResetInterval } from "@models/productModels/intervals/resetInterval"; +import { parseISO } from "date-fns"; +import { + expectApiCall, + expectExactApiCall, + expectNoApiCall, + expectNoToolCall, + expectToolCall, + initMcpEval, + type ToolRequest, + type ToolRequestInput, +} from "../utils/eval-test-utils.js"; + +const time = (value: string) => parseISO(value).getTime(); +const expectCustomFeatures = ( + schedule: ToolRequestInput<"createSchedule">, + featureIds: string[], +) => { + const actualIds = schedule.phases.flatMap((phase) => + phase.plans.flatMap( + (plan) => plan.customize?.items?.map((item) => item.feature_id) ?? [], + ), + ); + for (const featureId of featureIds) { + expect(actualIds.filter((id) => id === featureId)).toHaveLength(1); + } +}; + +test("previews and confirms a plain-English create schedule request", async () => { + const { api, approve, generate, toolCalls } = initMcpEval({ + today: parseISO("2026-06-01T00:00:00.000Z"), + fixtures: { + listCustomers: { + list: [{ id: "cus_contract", name: "Contract Customer" }], + }, + getCustomer: { id: "cus_contract", name: "Contract Customer" }, + listPlans: { + list: [ + { id: "pro", name: "Pro" }, + { id: "addon", name: "Support Add-on" }, + { id: "enterprise", name: "Enterprise" }, + ], + }, + getPlan: (body: ToolRequest<"getPlan">) => ({ + id: body.plan_id, + name: body.plan_id, + }), + previewCreateSchedule: { + total: 40, + subtotal: 40, + line_items: [{ total: 20 }, { total: 20 }], + }, + createSchedule: { status: "created", schedule_id: "sched_eval" }, + }, + }); + + await generate([ + "Can you set up a schedule for cus_contract?", + "Start them on the pro plan with the addon on 2027-01-01.", + "Then move them to enterprise on 2027-02-01.", + ]); + + expectToolCall(toolCalls, "previewCreateSchedule", { + customer_id: "cus_contract", + }); + + expectApiCall(api, "previewCreateSchedule", { + customer_id: "cus_contract", + redirect_mode: "if_required", + phases: [ + { + starts_at: time("2027-01-01T00:00:00.000Z"), + plans: [{ plan_id: "pro" }, { plan_id: "addon" }], + }, + { + starts_at: time("2027-02-01T00:00:00.000Z"), + plans: [{ plan_id: "enterprise" }], + }, + ], + }); + expectNoApiCall(api, "createSchedule"); + + await approve("Looks good, go ahead."); + + expectToolCall(toolCalls, "createSchedule", { + customer_id: "cus_contract", + }); + expectApiCall(api, "createSchedule", { + customer_id: "cus_contract", + redirect_mode: "if_required", + phases: [ + { + starts_at: time("2027-01-01T00:00:00.000Z"), + plans: [{ plan_id: "pro" }, { plan_id: "addon" }], + }, + { + starts_at: time("2027-02-01T00:00:00.000Z"), + plans: [{ plan_id: "enterprise" }], + }, + ], + }); +}, 30000); + +test("asks for customer id before previewing future contract price changes", async () => { + const expectedSchedule = { + customer_id: "cus_fee_schedule", + redirect_mode: "if_required", + phases: [ + { + starts_at: time("2027-01-01T00:00:00.000Z"), + plans: [ + { + plan_id: "enterprise", + customize: { + price: { amount: 120000, interval: BillingInterval.Year }, + }, + }, + ], + }, + { + starts_at: time("2028-01-01T00:00:00.000Z"), + plans: [ + { + plan_id: "enterprise", + customize: { + price: { amount: 150000, interval: BillingInterval.Year }, + }, + }, + ], + }, + ], + } satisfies ToolRequestInput<"createSchedule">; + const { api, approve, generate, toolCalls } = initMcpEval({ + today: parseISO("2026-06-01T00:00:00.000Z"), + fixtures: { + getCustomer: { + id: "cus_fee_schedule", + name: "Fee Schedule Co", + subscriptions: [ + { + planId: "enterprise", + status: "active", + currentPeriodStart: time("2026-01-01T00:00:00.000Z"), + currentPeriodEnd: time("2027-01-01T00:00:00.000Z"), + }, + ], + }, + listPlans: { + list: [{ id: "enterprise", name: "Enterprise" }], + }, + getPlan: (body: ToolRequest<"getPlan">) => ({ + id: body.plan_id, + name: body.plan_id, + }), + previewCreateSchedule: { + total: 0, + subtotal: 0, + line_items: [], + }, + createSchedule: { + status: "created", + schedule_id: "sched_fee_schedule", + }, + }, + }); + + const missingIdOutput = await generate([ + "I have a customer with a three-year Enterprise contract. Year 1 is already paid, but years 2 and 3 need new annual prices.", + "Can you help set that up?", + ]); + + expect(missingIdOutput.text.toLowerCase()).toContain("customer"); + expectNoToolCall(toolCalls, "previewCreateSchedule"); + expectNoToolCall(toolCalls, "createSchedule"); + expectNoApiCall(api, "previewCreateSchedule"); + expectNoApiCall(api, "createSchedule"); + + await generate([ + "Customer id is cus_fee_schedule.", + "Use Enterprise. Contract year 1 runs January 1, 2026 through December 31, 2026 and is already paid, so don't bill or change anything in that year.", + "Set year 2 to $120,000/year starting January 1, 2027, and year 3 to $150,000/year starting January 1, 2028.", + "Can you check what the upcoming price changes would look like before we apply them?", + ]); + + expectToolCall(toolCalls, "getCustomer", { + customer_id: "cus_fee_schedule", + }); + expectToolCall(toolCalls, "previewCreateSchedule", { + customer_id: "cus_fee_schedule", + }); + expectExactApiCall(api, "previewCreateSchedule", expectedSchedule); + expectNoApiCall(api, "createSchedule"); + + await approve("Looks good, go ahead."); + + expectToolCall(toolCalls, "createSchedule", { + customer_id: "cus_fee_schedule", + }); + expectExactApiCall(api, "createSchedule", expectedSchedule); +}, 60000); + +test("turns extracted contract text into the expected schedule preview and create call", async () => { + const customFeatureIds = [ + "sso", + "audit_logs", + "data_residency", + "premium_onboarding", + "dedicated_success", + "security_review", + ]; + const expectedSchedule = { + customer_id: "cus_northstar_contract", + redirect_mode: "if_required", + phases: [ + { + starts_at: time("2027-04-01T00:00:00.000Z"), + plans: [ + { + plan_id: "growth", + customize: { + items: [ + { feature_id: "seats", included: 25 }, + { + feature_id: "api_calls", + included: 100000, + reset: { interval: ResetInterval.Month }, + }, + ], + }, + }, + { + plan_id: "implementation", + customize: { + price: { amount: 1500, interval: BillingInterval.OneOff }, + }, + }, + ], + }, + { + starts_at: time("2027-07-01T00:00:00.000Z"), + plans: [ + { + plan_id: "growth", + customize: { + items: [ + { feature_id: "seats", included: 40 }, + { + feature_id: "api_calls", + included: 250000, + reset: { interval: ResetInterval.Month }, + }, + ], + }, + }, + { plan_id: "priority_support" }, + ], + }, + { + starts_at: time("2028-01-01T00:00:00.000Z"), + plans: [ + { + plan_id: "enterprise", + customize: { + price: { amount: 2400, interval: BillingInterval.Month }, + items: [ + { feature_id: "seats", included: 75 }, + { + feature_id: "api_calls", + included: 1000000, + reset: { interval: ResetInterval.Month }, + }, + { feature_id: "sso", unlimited: true }, + { + feature_id: "audit_logs", + included: 365, + reset: { interval: ResetInterval.Month }, + }, + { feature_id: "data_residency", unlimited: true }, + { feature_id: "premium_onboarding", included: 1 }, + { + feature_id: "dedicated_success", + included: 10, + reset: { interval: ResetInterval.Month }, + }, + { + feature_id: "security_review", + included: 2, + reset: { interval: ResetInterval.Year }, + }, + ], + }, + }, + { plan_id: "priority_support" }, + ], + }, + ], + } satisfies ToolRequestInput<"createSchedule">; + const { api, approve, generate, toolCalls } = initMcpEval({ + today: parseISO("2026-06-01T00:00:00.000Z"), + fixtures: { + listCustomers: { + list: [ + { + id: "cus_northstar_contract", + name: "Northstar Labs", + email: "billing@northstar.example", + }, + ], + }, + getCustomer: { + id: "cus_northstar_contract", + name: "Northstar Labs", + email: "billing@northstar.example", + }, + listPlans: { + list: [ + { id: "growth", name: "Growth" }, + { id: "implementation", name: "Implementation" }, + { id: "priority_support", name: "Priority Support" }, + { id: "enterprise", name: "Enterprise" }, + ], + }, + getPlan: (body: ToolRequest<"getPlan">) => ({ + id: body.plan_id, + name: body.plan_id, + }), + previewCreateSchedule: { + total: 1500, + subtotal: 1500, + line_items: [{ description: "Implementation", total: 1500 }], + }, + createSchedule: { status: "created", schedule_id: "sched_northstar" }, + }, + }); + const extractedContractText = [ + "MASTER SERVICES AGREEMENT", + "Order Form OF-2027-041 | Prepared for Northstar Labs Ltd.", + "Effective date: March 12, 2027. Governing law: New York. Payment terms: Net 30. Notices should be sent to legal@northstar.example.", + "Extracted service dates: initial ramp starts 2027-04-01; expansion starts 2027-07-01; enterprise conversion starts 2028-01-01.", + "Billing contact: billing@northstar.example. Customer reference in Autumn should be resolved from this account name or billing contact before any schedule is prepared.", + "Section 2. Initial ramp. On April 1, 2027, start the Growth plan with 25 seats and 100,000 API calls per month. Add the one-time Implementation plan at $1,500 for onboarding work.", + "Section 3. Expansion. On July 1, 2027, keep Growth active, increase to 40 seats and 250,000 API calls per month, and add Priority Support.", + "Section 4. Enterprise conversion. On January 1, 2028, move to Enterprise at a custom $2,400/month base rate with 75 seats and 1,000,000 API calls per month. Keep Priority Support.", + "Enterprise conversion also includes contract-specific feature overrides that are not part of the standard Enterprise plan: unlimited sso, 365 audit_logs per month, unlimited data_residency, 1 premium_onboarding grant, 10 dedicated_success hours per month, and 2 security_review credits per year.", + "Section 8. Confidentiality. Neither party may disclose pricing or implementation details except to auditors, investors, or legal advisors under confidentiality obligations.", + "Section 11. Service levels. Support response targets are commercially reasonable and do not create service credits unless separately stated in an SLA exhibit.", + "Signature block: Northstar Labs Ltd. / Autumn Software Inc.", + ].join("\n"); + + await generate([ + "A PDF text extractor returned the contract text below.", + "Please handle this in Autumn using only the extracted text.", + "Make sure all feature limits and overrides from the contract are reflected in the schedule.", + "Apply the contract-specific feature overrides to the Enterprise phase.", + extractedContractText, + ]); + + expectToolCall(toolCalls, "listCustomers"); + expectToolCall(toolCalls, "listPlans"); + expectToolCall(toolCalls, "previewCreateSchedule", { + customer_id: "cus_northstar_contract", + }); + const previewCall = expectExactApiCall( + api, + "previewCreateSchedule", + expectedSchedule, + ); + expectCustomFeatures(previewCall?.rawBody, customFeatureIds); + expectNoApiCall(api, "createSchedule"); + + await approve("Looks good, go ahead."); + + expectToolCall(toolCalls, "createSchedule", { + customer_id: "cus_northstar_contract", + }); + const createCall = expectExactApiCall( + api, + "createSchedule", + expectedSchedule, + ); + expectCustomFeatures(createCall?.rawBody, customFeatureIds); +}, 60000); diff --git a/packages/mcp/tests/evals/list-customers-evals.test.ts b/packages/mcp/tests/evals/list-customers-evals.test.ts new file mode 100644 index 000000000..367991638 --- /dev/null +++ b/packages/mcp/tests/evals/list-customers-evals.test.ts @@ -0,0 +1,320 @@ +import { expect, test } from "bun:test"; +import { + expectNoApiCall, + expectNoToolCall, + expectToolCall, + initMcpEval, + type ToolRequest, +} from "../utils/eval-test-utils.js"; + +const expectVersions = (versions: number[] | undefined, expected: number[]) => { + expect(Array.from(new Set(versions ?? [])).sort((a, b) => a - b)).toEqual( + expected, + ); +}; + +test("lists all matching customers with compound filters and cursor pagination", async () => { + const cursors: string[] = []; + const { api, generate, toolCalls } = initMcpEval({ + fixtures: { + listCustomers: (body: ToolRequest<"listCustomers">) => { + cursors.push(body.start_cursor); + expect(body).toMatchObject({ + search: "acme", + subscription_status: "active", + processors: ["stripe"], + plans: [{ id: "pro", versions: [2, 3] }], + }); + + if (body.start_cursor === "") { + expect(body.start_cursor).toBe(""); + return { + list: [ + { + id: "cus_acme_us", + name: "Acme US", + email: "billing@acme.example", + processors: { stripe: { id: "cus_stripe_us" } }, + subscriptions: [ + { planId: "pro", version: 3, status: "active" }, + ], + }, + { + id: "cus_acme_eu", + name: "Acme EU", + email: "finance@acme.example", + processors: { stripe: { id: "cus_stripe_eu" } }, + subscriptions: [ + { planId: "pro", version: 2, status: "active" }, + ], + }, + ], + next_cursor: "cursor_acme_2", + }; + } + + expect(body.start_cursor).toBe("cursor_acme_2"); + return { + list: [ + { + id: "cus_acme_apac", + name: "Acme APAC", + email: "ops-apac@acme.example", + processors: { stripe: { id: "cus_stripe_apac" } }, + subscriptions: [{ planId: "pro", version: 3, status: "active" }], + }, + ], + next_cursor: null, + }; + }, + }, + }); + + const output = await generate( + [ + "Can you pull every active Acme customer on pro v2 or v3 that pays through Stripe?", + "There may be multiple pages, so don't stop after the first batch.", + "Just give me the customer ids.", + ], + 6, + ); + + expectToolCall(toolCalls, "listCustomers"); + expectNoToolCall(toolCalls, "getCustomer"); + expectNoApiCall(api, "getCustomer"); + const calls = api.callsFor("listCustomers"); + expect(calls.length).toBeGreaterThanOrEqual(2); + expect(calls.some((call) => call.body.start_cursor === "")).toBe(true); + expect(calls.some((call) => call.body.start_cursor === "cursor_acme_2")).toBe( + true, + ); + expect(output.text).toContain("cus_acme_us"); + expect(output.text).toContain("cus_acme_eu"); + expect(output.text).toContain("cus_acme_apac"); + expect(cursors).toContain(""); + expect(cursors).toContain("cursor_acme_2"); +}, 30000); + +test("resolves plan attributes before listing scheduled Vercel customers", async () => { + const { api, generate, toolCalls } = initMcpEval({ + fixtures: { + listPlans: { + list: [ + { + id: "startup", + name: "Startup", + version: 1, + is_default: true, + }, + { + id: "enterprise", + name: "Enterprise", + version: 4, + is_default: false, + }, + { + id: "enterprise", + name: "Enterprise", + version: 5, + is_default: false, + }, + { + id: "support_addon", + name: "Priority Support", + version: 2, + is_add_on: true, + }, + ], + }, + listCustomers: (body: ToolRequest<"listCustomers">) => { + const versions = body.plans + ?.filter((plan) => plan.id === "enterprise") + .flatMap((plan) => plan.versions ?? []); + expect(body).toMatchObject({ + subscription_status: "scheduled", + processors: ["vercel"], + plans: [{ id: "enterprise" }], + }); + expectVersions(versions, [4, 5]); + return { + list: [ + { + id: "cus_future_enterprise", + name: "Future Enterprise", + processors: { + vercel: { + installation_id: "icfg_future", + account_id: "acct_future", + }, + }, + subscriptions: [ + { + planId: "enterprise", + version: 5, + status: "scheduled", + }, + ], + }, + ], + next_cursor: null, + }; + }, + }, + }); + + const output = await generate( + [ + "Which Vercel customers are queued for non-default Enterprise plans?", + "Return the customer ids.", + ], + 5, + ); + + expectToolCall(toolCalls, "listPlans"); + expectToolCall(toolCalls, "listCustomers"); + const enterpriseCall = api.callsFor("listCustomers").find((call) => { + const versions = call.body.plans + ?.filter((plan) => plan.id === "enterprise") + .flatMap((plan) => plan.versions ?? []); + return ( + call.body.subscription_status === "scheduled" && + call.body.processors?.includes("vercel") && + Array.from(new Set(versions ?? [])) + .sort() + .join(",") === "4,5" + ); + }); + expect( + enterpriseCall, + `Expected a scheduled Enterprise Vercel query. Calls: ${JSON.stringify(api.callsFor("listCustomers"), null, 2)}`, + ).toBeDefined(); + expectNoToolCall(toolCalls, "getCustomer"); + expectNoApiCall(api, "getCustomer"); + expect(output.text).toContain("cus_future_enterprise"); +}, 30000); + +test("infers active Stripe customer filters from vague wording", async () => { + const { api, generate, toolCalls } = initMcpEval({ + fixtures: { + listCustomers: (body: ToolRequest<"listCustomers">) => { + expect(body).toMatchObject({ + subscription_status: "active", + processors: ["stripe"], + }); + expect(body.search?.toLowerCase()).toBe("acme"); + return { + list: [ + { + id: "cus_acme_live", + name: "Acme Live", + email: "billing@acme.example", + processors: { stripe: { id: "cus_stripe_live" } }, + subscriptions: [{ planId: "pro", status: "active" }], + }, + ], + next_cursor: null, + }; + }, + }, + }); + + const output = await generate([ + "Can you find live Acme customers that are paying through Stripe?", + "Return the customer ids.", + ]); + + expectToolCall(toolCalls, "listCustomers"); + expect(api.call("listCustomers")?.rawBody).toMatchObject({ + subscription_status: "active", + processors: ["stripe"], + }); + expect(api.call("listCustomers")?.rawBody.search?.toLowerCase()).toBe("acme"); + expectNoToolCall(toolCalls, "getCustomer"); + expectNoApiCall(api, "getCustomer"); + expect(api.callsFor("listCustomers").length).toBeGreaterThanOrEqual(1); + expect(output.text).toContain("cus_acme_live"); +}, 30000); + +test("infers upcoming plan filters from vague scheduled language", async () => { + const { api, generate, toolCalls } = initMcpEval({ + fixtures: { + listPlans: { + list: [ + { id: "starter", name: "Starter", version: 1 }, + { id: "growth", name: "Growth", version: 1 }, + { id: "growth", name: "Growth", version: 2 }, + { id: "growth", name: "Growth", version: 3 }, + ], + }, + listCustomers: (body: ToolRequest<"listCustomers">) => { + const growthVersions = body.plans + ?.filter((plan) => plan.id === "growth") + .flatMap((plan) => plan.versions ?? []); + const hasGrowthVersions = + body.subscription_status === "scheduled" && + JSON.stringify( + Array.from(new Set(growthVersions ?? [])).sort((a, b) => a - b), + ) === JSON.stringify([2, 3]); + + return { + list: hasGrowthVersions + ? [ + { + id: "cus_growth_next", + name: "Growth Next", + subscriptions: [ + { planId: "growth", version: 3, status: "scheduled" }, + ], + }, + ] + : [ + { + id: "cus_unrelated_scheduled", + name: "Unrelated Scheduled", + subscriptions: [ + { planId: "starter", version: 1, status: "scheduled" }, + ], + }, + ], + next_cursor: null, + }; + }, + }, + }); + + const output = await generate([ + "Who is queued up to move onto any Growth version soon?", + "Return the customer ids.", + ]); + + expectToolCall(toolCalls, "listPlans"); + expectToolCall(toolCalls, "listCustomers"); + const customerCall = api.callsFor("listCustomers").find((call) => { + const versions = call.body.plans + ?.filter((plan) => plan.id === "growth") + .flatMap((plan) => plan.versions ?? []); + return ( + call.body.subscription_status === "scheduled" && + Array.from(new Set(versions ?? [])) + .sort((a, b) => a - b) + .join(",") === "2,3" + ); + }); + expect( + customerCall, + `Expected a scheduled Growth customer query. Calls: ${JSON.stringify(api.callsFor("listCustomers"), null, 2)}`, + ).toBeDefined(); + expect(customerCall?.body.subscription_status).toBe("scheduled"); + expect(customerCall?.body.plans?.some((plan) => plan.id === "growth")).toBe( + true, + ); + expectVersions( + customerCall?.body.plans + ?.filter((plan) => plan.id === "growth") + .flatMap((plan) => plan.versions ?? []), + [2, 3], + ); + expectNoToolCall(toolCalls, "getCustomer"); + expectNoApiCall(api, "getCustomer"); + expect(output.text).toContain("cus_growth_next"); +}, 30000); diff --git a/packages/mcp/src/mcp-server/agent/axiom.test.ts b/packages/mcp/tests/unit/mcp-server/agent/axiom.test.ts similarity index 93% rename from packages/mcp/src/mcp-server/agent/axiom.test.ts rename to packages/mcp/tests/unit/mcp-server/agent/axiom.test.ts index 935fd0c6a..e94bbed88 100644 --- a/packages/mcp/src/mcp-server/agent/axiom.test.ts +++ b/packages/mcp/tests/unit/mcp-server/agent/axiom.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import { Scopes } from "@autumn/shared/scopeDefinitions"; -import type { AutumnMcpAuth } from "./auth.js"; -import { prepareAxiomQuery, resolveAutumnOrgId } from "./axiom.js"; +import { + prepareAxiomQuery, + resolveAutumnOrgId, +} from "../../../../src/agent/axiom.js"; +import type { AutumnMcpAuth } from "../../../../src/server/auth/auth.js"; const auth: AutumnMcpAuth & { orgId: string } = { apiKey: "sk_test", diff --git a/packages/mcp/src/mcp-server/agent/pending-actions.test.ts b/packages/mcp/tests/unit/mcp-server/agent/pending-actions.test.ts similarity index 84% rename from packages/mcp/src/mcp-server/agent/pending-actions.test.ts rename to packages/mcp/tests/unit/mcp-server/agent/pending-actions.test.ts index 308d5c764..b872a0263 100644 --- a/packages/mcp/src/mcp-server/agent/pending-actions.test.ts +++ b/packages/mcp/tests/unit/mcp-server/agent/pending-actions.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test"; -import type { AutumnMcpAuth } from "./auth.js"; import { claimLatestPendingAction, clearPendingActions, createPendingAction, setPendingActionsRedis, -} from "./pending-actions.js"; -import { createTestRedis } from "./test-redis.js"; +} from "../../../../src/agent/pending-actions.js"; +import type { AutumnMcpAuth } from "../../../../src/server/auth/auth.js"; +import { createTestRedis } from "../../../utils/test-redis.js"; setPendingActionsRedis(createTestRedis()); @@ -38,7 +38,9 @@ describe("pending billing actions", () => { plan_id: "pro", }, }); - await expect(claimLatestPendingAction(auth())).rejects.toThrow("No pending"); + await expect(claimLatestPendingAction(auth())).rejects.toThrow( + "No pending", + ); }); test("claims the latest matching action without exposing tokens", async () => { @@ -75,11 +77,11 @@ describe("pending billing actions", () => { claimLatestPendingAction(auth()), ]); - expect(results.filter((result) => result.status === "fulfilled")).toHaveLength( - 1, - ); - expect(results.filter((result) => result.status === "rejected")).toHaveLength( - 1, - ); + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); }); }); diff --git a/packages/mcp/tests/unit/mcp-server/agent/server.test.ts b/packages/mcp/tests/unit/mcp-server/agent/server.test.ts new file mode 100644 index 000000000..9c810cbe6 --- /dev/null +++ b/packages/mcp/tests/unit/mcp-server/agent/server.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; +import { parseResourceMarkdown } from "../../../../src/resources/compileResources.js"; +import { autumnMcpResourceUris } from "../../../../src/resources/index.js"; +import { createAutumnOperationsMCPServer } from "../../../../src/server/server.js"; + +describe("Autumn MCP server", () => { + const logResourceUris = [ + "autumn://docs/request-logs", + "autumn://docs/request-log-customers", + "autumn://docs/request-log-balances", + "autumn://docs/request-log-billing", + "autumn://docs/request-log-stripe-webhooks", + "autumn://docs/request-log-analytics", + ] as const; + + test("public server advertises raw operation tools", async () => { + const tools = await createAutumnOperationsMCPServer().getToolListInfo(); + + expect(tools.tools.map((tool) => tool.name)).toEqual([ + "getAgentRules", + "updateAgentRules", + "listCustomers", + "getOrCreateCustomer", + "updateCustomer", + "getCustomer", + "listFeatures", + "listPlans", + "createPlan", + "getPlan", + "createBalance", + "searchRequestLogs", + "queryRequestLogs", + "previewAttach", + "previewUpdateSubscription", + "previewCreateSchedule", + "previewCreateBalance", + "attach", + "updateSubscription", + "createSchedule", + "getCurrentOrganization", + "dateToEpochMilliseconds", + "epochMillisecondsToDate", + ]); + expect(tools.tools.map((tool) => tool.name)).not.toContain("ask_autumn"); + expect(tools.tools.map((tool) => tool.name)).not.toContain( + "confirmBillingAction", + ); + }); + + test("billing tool schemas avoid legacy JSON Schema ids", async () => { + const tools = await createAutumnOperationsMCPServer().getToolListInfo(); + + for (const name of [ + "previewAttach", + "attach", + "previewUpdateSubscription", + "updateSubscription", + ]) { + const tool = tools.tools.find((tool) => tool.name === name); + expect(JSON.stringify(tool?.inputSchema)).not.toContain('"id":'); + } + }); + + test("public server exposes Autumn composition docs", async () => { + const server = createAutumnOperationsMCPServer(); + const resources = await server.listResources(); + + expect(resources.resources.map((resource) => resource.uri)).toEqual( + autumnMcpResourceUris, + ); + + for (const uri of autumnMcpResourceUris) { + const resource = await server.readResource(uri); + expect(resource.contents[0]?.text).toContain("# "); + } + + const requestLogs = await server.readResource("autumn://docs/request-logs"); + expect(requestLogs.contents[0]?.text).toContain("searchRequestLogs"); + expect(requestLogs.contents[0]?.text).toContain("queryRequestLogs"); + + const featureCatalog = await server.readResource( + "autumn://docs/feature-catalog", + ); + expect(featureCatalog.contents[0]?.text).toContain("listFeatures"); + + const billingSafety = await server.readResource( + "autumn://docs/billing-safety", + ); + expect(billingSafety.contents[0]?.text).toContain( + "invoice_mode requires customer email", + ); + expect(billingSafety.contents[0]?.text).toContain("finalize false"); + expect(billingSafety.contents[0]?.text).toContain("updateCustomer"); + + const schedules = await server.readResource("autumn://docs/schedules"); + expect(schedules.contents[0]?.text).toContain( + "invoice_mode requires customer email", + ); + expect(schedules.contents[0]?.text).toContain("finalize false"); + expect(schedules.contents[0]?.text).toContain("updateCustomer"); + + for (const uri of logResourceUris) { + expect(autumnMcpResourceUris).toContain(uri); + } + }); + + test("log resources stay external-safe", async () => { + const server = createAutumnOperationsMCPServer(); + const bannedTerms = [ + "Axiom", + "extras", + "workflow", + "req.id", + "msg", + "level", + "server/src", + "implementation files", + "database state", + "stack traces", + ]; + + for (const uri of logResourceUris) { + const resource = await server.readResource(uri); + const text = String(resource.contents[0]?.text ?? ""); + for (const term of bannedTerms) { + expect(text).not.toContain(term); + } + } + }); + + test("unknown resources are rejected", async () => { + const server = createAutumnOperationsMCPServer(); + + await expect(server.readResource("autumn://docs/missing")).rejects.toThrow( + "Unknown Autumn MCP resource", + ); + await expect(server.readResource("__proto__")).rejects.toThrow( + "Unknown Autumn MCP resource", + ); + }); + + test("resource markdown parser validates frontmatter", () => { + expect( + parseResourceMarkdown({ + path: "logs/request-logs.md", + text: [ + "---", + "name: request-logs", + "title: Request Logs", + "description: Log docs", + "---", + "# Request Logs", + ].join("\n"), + }), + ).toMatchObject({ + name: "request-logs", + title: "Request Logs", + description: "Log docs", + priority: 0.8, + audience: ["assistant"], + body: "# Request Logs", + }); + + expect(() => + parseResourceMarkdown({ + path: "bad.md", + text: "---\ntitle: Missing Name\ndescription: Bad\n---\n# Bad", + }), + ).toThrow("missing name"); + }); +}); diff --git a/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts b/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts new file mode 100644 index 000000000..d1aabf737 --- /dev/null +++ b/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts @@ -0,0 +1,948 @@ +import { describe, expect, test } from "bun:test"; +import { + claimLatestPendingAction, + clearPendingActions, + createPendingAction, + setPendingActionsRedis, +} from "../../../../src/agent/pending-actions.js"; +import type { AutumnMcpAuth } from "../../../../src/server/auth/auth.js"; +import { + createAgentAutumnOperationTools, + createRawAutumnOperationTools, + dateToEpochMillisecondsTool, + endpointByTool, + epochMillisecondsToDateTool, + schemaByTool, +} from "../../../../src/tools/index.js"; +import { createTestRedis } from "../../../utils/test-redis.js"; + +setPendingActionsRedis(createTestRedis()); + +type ExecutableTool = { + execute?: (input: unknown, context: unknown) => Promise; +}; + +const auth: AutumnMcpAuth = { + apiKey: "sk_test", + env: "sandbox", + principalId: "user_1", + resource: "http://localhost:2718/mcp", + scopes: ["billing:read", "billing:write", "balances:write"], + serverURL: "http://localhost:8080", +}; + +describe("Autumn operation tools", () => { + test("read tool descriptions include composition guidance", () => { + const tools = createRawAutumnOperationTools(); + + expect(tools.listPlans.description).toContain("cheap full scan"); + expect(tools.listPlans.description).toContain( + "filter returned plans locally", + ); + expect(tools.listFeatures.description).toContain("List Autumn features"); + expect(tools.listCustomers.description).toContain("plans"); + expect(tools.listCustomers.description).toContain("paginate"); + expect(tools.updateCustomer.description).toContain("invoice_mode"); + expect(tools.updateCustomer.description).toContain("Stripe"); + expect(tools.createPlan.description).toContain("confirmation"); + expect(tools.createBalance.description).toContain("entity-scoped credits"); + expect(tools.searchRequestLogs.description).toContain("request logs"); + expect(tools.queryRequestLogs.description).toContain("aggregate"); + expect(tools.getAgentRules.description).toContain("agent rules"); + expect(tools.getAgentRules.description).toContain("Use before customer"); + expect(tools.updateAgentRules.description).toContain("agent rules"); + expect(tools.previewCreateBalance.description).toContain("Does not mutate"); + expect(tools.createSchedule.description).toContain("starts_at"); + expect(tools.previewCreateSchedule.description).toContain("billing impact"); + expect(tools.previewAttach.description).toContain( + "enable_plan_immediately", + ); + expect(tools.previewAttach.description).toContain("finalize false"); + expect(tools.previewAttach.description).toContain( + "invoice_mode requires customer email", + ); + expect(tools.attach.description).toContain("enable_plan_immediately"); + expect(tools.attach.description).toContain("finalize false"); + expect(tools.attach.description).toContain( + "invoice_mode requires customer email", + ); + expect(tools.previewCreateSchedule.description).toContain( + "enable_plan_immediately", + ); + expect(tools.previewCreateSchedule.description).toContain("finalize false"); + expect(tools.previewCreateSchedule.description).toContain( + "invoice_mode requires customer email", + ); + expect(tools.createSchedule.description).toContain( + "enable_plan_immediately", + ); + expect(tools.createSchedule.description).toContain("finalize false"); + expect(tools.createSchedule.description).toContain( + "invoice_mode requires customer email", + ); + expect(tools.getCurrentOrganization.description).toContain("organization"); + }); + + test("write tools are annotated as destructive", () => { + const tools = createRawAutumnOperationTools(); + + for (const name of [ + "createPlan", + "createBalance", + "attach", + "updateSubscription", + "createSchedule", + ] as const) { + expect(tools[name].mcp?.annotations?.destructiveHint).toBe(true); + } + + for (const name of [ + "listCustomers", + "updateCustomer", + "getCustomer", + "listFeatures", + "listPlans", + "getPlan", + "searchRequestLogs", + "queryRequestLogs", + "previewAttach", + "previewUpdateSubscription", + "previewCreateSchedule", + "previewCreateBalance", + "getCurrentOrganization", + "getAgentRules", + "updateAgentRules", + ] as const) { + expect(tools[name].mcp?.annotations?.destructiveHint).toBe(false); + } + }); + + test("listFeatures uses a strict empty request schema", () => { + expect(endpointByTool.listFeatures).toBe("/v1/features.list"); + expect(schemaByTool.listFeatures.parse({})).toEqual({}); + expect(() => + schemaByTool.listFeatures.parse({ archived: false }), + ).toThrow(); + + expect(createAgentAutumnOperationTools().listFeatures).toBeDefined(); + }); + + test("getAgentRules uses a strict empty request schema", () => { + expect(endpointByTool.getAgentRules).toBe("/v1/agent.get_rules"); + expect(schemaByTool.getAgentRules.parse({})).toEqual({}); + expect(() => + schemaByTool.getAgentRules.parse({ include_metadata: true }), + ).toThrow(); + + expect(createAgentAutumnOperationTools().getAgentRules).toBeDefined(); + }); + + test("updateAgentRules accepts partial rules and rejects unknown fields", () => { + expect(endpointByTool.updateAgentRules).toBe("/v1/agent.update_rules"); + expect( + schemaByTool.updateAgentRules.parse({ + entity_rules: { + attach_to_entities: true, + entity_feature_id: "deployments", + }, + credit_rules: { credit_feature_id: "credits" }, + notes: "Attach add-ons at customer level.", + }), + ).toEqual({ + entity_rules: { + attach_to_entities: true, + entity_feature_id: "deployments", + }, + credit_rules: { credit_feature_id: "credits" }, + notes: "Attach add-ons at customer level.", + }); + expect(() => + schemaByTool.updateAgentRules.parse({ unexpected: true }), + ).toThrow(); + + expect(createAgentAutumnOperationTools().updateAgentRules).toBeDefined(); + }); + + test("dateToEpochMilliseconds converts UTC dates and offsets", async () => { + const tool = dateToEpochMillisecondsTool as ExecutableTool; + if (!tool.execute) + throw new Error("dateToEpochMilliseconds is not executable"); + + await expect(tool.execute({ date: "2027-01-01" }, {})).resolves.toBe( + Date.UTC(2027, 0, 1), + ); + await expect( + tool.execute({ date: "2027-01-01T00:00:00-08:00" }, {}), + ).resolves.toBe(Date.UTC(2027, 0, 1, 8)); + }); + + test("epochMillisecondsToDate converts keyed epoch milliseconds", async () => { + const tool = epochMillisecondsToDateTool as ExecutableTool; + if (!tool.execute) + throw new Error("epochMillisecondsToDate is not executable"); + + await expect( + tool.execute( + { + timestamps: { + starts_at: Date.UTC(2026, 0, 1), + expires_at: String(Date.UTC(2026, 5, 6, 12, 30, 45)), + }, + }, + {}, + ), + ).resolves.toEqual({ + starts_at: { + epoch_ms: Date.UTC(2026, 0, 1), + iso: "2026-01-01T00:00:00.000Z", + utc: "January 1, 2026, 00:00:00 UTC", + }, + expires_at: { + epoch_ms: Date.UTC(2026, 5, 6, 12, 30, 45), + iso: "2026-06-06T12:30:45.000Z", + utc: "June 6, 2026, 12:30:45 UTC", + }, + }); + }); + + test("raw getOrCreateCustomer calls the get-or-create endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe( + "http://localhost:8080/v1/customers.get_or_create", + ); + expect(JSON.parse(init?.body as string)).toMatchObject({ + customer_id: "cus_1", + email: "charlie@example.com", + }); + return Response.json({ id: "cus_1" }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().getOrCreateCustomer; + if (!tool.execute) + throw new Error("getOrCreateCustomer is not executable"); + + await expect( + tool.execute( + { + intent: "create a customer", + request: { customer_id: "cus_1", email: "charlie@example.com" }, + }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toEqual({ id: "cus_1" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw updateCustomer calls the update endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/customers.update"); + expect(JSON.parse(init?.body as string)).toMatchObject({ + customer_id: "mintlify", + email: "johnyeocx@gmail.com", + }); + return Response.json({ + id: "mintlify", + email: "johnyeocx@gmail.com", + }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().updateCustomer; + if (!tool.execute) throw new Error("updateCustomer is not executable"); + + await expect( + tool.execute( + { + intent: "set customer email", + request: { + customer_id: "mintlify", + email: "johnyeocx@gmail.com", + }, + }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toEqual({ + id: "mintlify", + email: "johnyeocx@gmail.com", + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw updateAgentRules calls the update rules endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/agent.update_rules"); + expect(JSON.parse(init?.body as string)).toEqual({ + entity_rules: { + attach_to_entities: true, + entity_feature_id: "deployments", + }, + notes: "Attach add-ons at the customer level.", + }); + return Response.json({ + entity_rules: { + attach_to_entities: true, + entity_feature_id: "deployments", + }, + credit_rules: { credit_feature_id: "" }, + notes: "Attach add-ons at the customer level.", + }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().updateAgentRules; + if (!tool.execute) throw new Error("updateAgentRules is not executable"); + + await expect( + tool.execute( + { + intent: "set org agent rules", + request: { + entity_rules: { + attach_to_entities: true, + entity_feature_id: "deployments", + }, + notes: "Attach add-ons at the customer level.", + }, + }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toMatchObject({ + entity_rules: { + attach_to_entities: true, + entity_feature_id: "deployments", + }, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw createPlan calls the create plan endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/plans.create"); + expect(JSON.parse(init?.body as string)).toMatchObject({ + plan_id: "pro", + name: "Pro", + }); + return Response.json({ id: "pro" }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().createPlan; + if (!tool.execute) throw new Error("createPlan is not executable"); + + await expect( + tool.execute( + { intent: "create a plan", request: { plan_id: "pro", name: "Pro" } }, + { + mcp: { extra: { authInfo: auth } }, + } as never, + ), + ).resolves.toEqual({ id: "pro" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw createSchedule calls the create schedule endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe( + "http://localhost:8080/v1/billing.create_schedule", + ); + expect(JSON.parse(init?.body as string)).toMatchObject({ + customer_id: "cus_1", + }); + return Response.json({ schedule_id: "sch_1" }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().createSchedule; + if (!tool.execute) throw new Error("createSchedule is not executable"); + + await expect( + tool.execute( + { + intent: "create a schedule", + request: { + customer_id: "cus_1", + phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }], + }, + }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toEqual({ schedule_id: "sch_1" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw previewCreateBalance returns a local non-mutating preview", async () => { + const request = { + customer_id: "cus_1", + entity_id: "workspace_1", + feature_id: "credits", + included_grant: 50000, + expires_at: 1785542400000, + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("previewCreateBalance should not call Autumn"); + }) as unknown as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().previewCreateBalance; + if (!tool.execute) + throw new Error("previewCreateBalance is not executable"); + + await expect( + tool.execute({ intent: "preview a balance grant", request }, { + mcp: { extra: { authInfo: auth } }, + } as never), + ).resolves.toMatchObject({ + action: "createBalance", + request, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw createBalance calls the create balance endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/balances.create"); + expect(JSON.parse(init?.body as string)).toEqual({ + customer_id: "cus_1", + entity_id: "workspace_1", + feature_id: "credits", + included_grant: 50000, + expires_at: 1785542400000, + }); + return Response.json({ success: true }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().createBalance; + if (!tool.execute) throw new Error("createBalance is not executable"); + + await expect( + tool.execute( + { + intent: "grant a balance", + request: { + customer_id: "cus_1", + entity_id: "workspace_1", + feature_id: "credits", + included_grant: 50000, + expires_at: 1785542400000, + }, + }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toEqual({ success: true }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw previewCreateSchedule calls the preview create schedule endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe( + "http://localhost:8080/v1/billing.preview_create_schedule", + ); + expect(JSON.parse(init?.body as string)).toMatchObject({ + customer_id: "cus_1", + }); + return Response.json({ total: 50 }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().previewCreateSchedule; + if (!tool.execute) { + throw new Error("previewCreateSchedule is not executable"); + } + + await expect( + tool.execute( + { + intent: "preview a schedule", + request: { + customer_id: "cus_1", + phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }], + }, + }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toEqual({ total: 50 }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw listCustomers calls the list endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/customers.list"); + expect(JSON.parse(init?.body as string)).toMatchObject({ + limit: 1000, + search: "charlie", + }); + return Response.json({ customers: [] }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().listCustomers; + if (!tool.execute) throw new Error("listCustomers is not executable"); + + await expect( + tool.execute( + { + intent: "list customers", + request: { limit: 5000, search: "charlie" }, + }, + { + mcp: { extra: { authInfo: auth } }, + } as never, + ), + ).resolves.toEqual({ customers: [] }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw getCurrentOrganization calls the organization me endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/organization/me"); + expect(init?.method).toBe("GET"); + expect(init?.body).toBeUndefined(); + return Response.json({ + name: "Unit Tests", + slug: "unit-tests", + env: "sandbox", + }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().getCurrentOrganization; + if (!tool.execute) { + throw new Error("getCurrentOrganization is not executable"); + } + + await expect( + tool.execute( + { intent: "check which Autumn organization is connected" }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toEqual({ + name: "Unit Tests", + slug: "unit-tests", + env: "sandbox", + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw listFeatures calls the feature list endpoint", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/features.list"); + expect(JSON.parse(init?.body as string)).toEqual({}); + return Response.json({ list: [] }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().listFeatures; + if (!tool.execute) throw new Error("listFeatures is not executable"); + + await expect( + tool.execute( + { + intent: "find available product features", + request: {}, + }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toEqual({ list: [] }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw request-log tools call the logs endpoints", async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; body: unknown }> = []; + globalThis.fetch = (async (url, init) => { + calls.push({ + url: String(url), + body: JSON.parse(init?.body as string), + }); + return Response.json({ list: [] }); + }) as typeof fetch; + + try { + const tools = createRawAutumnOperationTools(); + if (!tools.searchRequestLogs.execute) { + throw new Error("searchRequestLogs is not executable"); + } + if (!tools.queryRequestLogs.execute) { + throw new Error("queryRequestLogs is not executable"); + } + + await expect( + tools.searchRequestLogs.execute( + { + intent: "find recent failed requests", + request: { + query: "where status_code >= 400 | limit 10", + limit: 10, + }, + }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toEqual({ list: [] }); + + await expect( + tools.queryRequestLogs.execute( + { + intent: "count errors by path", + request: { + query: + "where status_code >= 400 | summarize errors = count() by request_path", + }, + }, + { mcp: { extra: { authInfo: auth } } } as never, + ), + ).resolves.toEqual({ list: [] }); + + expect(calls).toEqual([ + { + url: "http://localhost:8080/v1/logs.search", + body: { + query: "where status_code >= 400 | limit 10", + limit: 10, + }, + }, + { + url: "http://localhost:8080/v1/logs.query", + body: { + query: + "where status_code >= 400 | summarize errors = count() by request_path", + }, + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw previewAttach does not create a pending action", async () => { + await clearPendingActions(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe( + "http://localhost:8080/v1/billing.preview_attach", + ); + expect(JSON.parse(init?.body as string)).toEqual({ + customer_id: "cus_1", + plan_id: "pro", + redirect_mode: "if_required", + }); + return Response.json({ total: 50 }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().previewAttach; + if (!tool.execute) throw new Error("previewAttach is not executable"); + + await expect( + tool.execute( + { + intent: "preview an attach", + request: { customer_id: "cus_1", plan_id: "pro" }, + }, + { + mcp: { extra: { authInfo: auth } }, + } as never, + ), + ).resolves.toEqual({ total: 50 }); + await expect(claimLatestPendingAction(auth)).rejects.toThrow( + "No pending", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("raw attach calls the write endpoint directly", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/billing.attach"); + expect(JSON.parse(init?.body as string)).toEqual({ + customer_id: "cus_1", + plan_id: "pro", + redirect_mode: "if_required", + }); + return Response.json({ ok: true }); + }) as typeof fetch; + + try { + const tool = createRawAutumnOperationTools().attach; + if (!tool.execute) throw new Error("attach is not executable"); + + await expect( + tool.execute( + { + intent: "attach a plan", + request: { customer_id: "cus_1", plan_id: "pro" }, + }, + { + mcp: { extra: { authInfo: auth } }, + } as never, + ), + ).resolves.toEqual({ ok: true }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("previewAttach stores the exact pending attach action", async () => { + await clearPendingActions(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe( + "http://localhost:8080/v1/billing.preview_attach", + ); + expect(JSON.parse(init?.body as string)).toEqual({ + customer_id: "cus_1", + plan_id: "pro", + redirect_mode: "if_required", + }); + return Response.json({ total: 50 }); + }) as typeof fetch; + + try { + const tool = ( + createAgentAutumnOperationTools() as unknown as { + previewAttach: { + execute?: (input: unknown, context: unknown) => Promise; + }; + } + ).previewAttach; + if (!tool.execute) throw new Error("previewAttach is not executable"); + + await expect( + tool.execute({ request: { customer_id: "cus_1", plan_id: "pro" } }, { + mcp: { extra: { authInfo: auth } }, + } as never), + ).resolves.toMatchObject({ pending: true, preview: { total: 50 } }); + + await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({ + toolName: "attach", + request: { + customer_id: "cus_1", + plan_id: "pro", + redirect_mode: "if_required", + }, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("agent createPlan stores a pending write instead of calling Autumn", async () => { + await clearPendingActions(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("createPlan should not call Autumn before confirmation"); + }) as unknown as typeof fetch; + + try { + const tool = ( + createAgentAutumnOperationTools() as unknown as { + createPlan: ExecutableTool; + } + ).createPlan; + if (!tool.execute) throw new Error("createPlan is not executable"); + + await expect( + tool.execute({ request: { plan_id: "pro", name: "Pro" } }, { + mcp: { extra: { authInfo: auth } }, + } as never), + ).resolves.toMatchObject({ pending: true }); + + await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({ + toolName: "createPlan", + request: { plan_id: "pro", name: "Pro" }, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("agent previewCreateSchedule stores a pending write after preview", async () => { + await clearPendingActions(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url) => { + expect(String(url)).toBe( + "http://localhost:8080/v1/billing.preview_create_schedule", + ); + return Response.json({ total: 50 }); + }) as typeof fetch; + + try { + const request = { + customer_id: "cus_1", + phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }], + }; + const tool = ( + createAgentAutumnOperationTools() as unknown as { + previewCreateSchedule: ExecutableTool; + } + ).previewCreateSchedule; + if (!tool.execute) { + throw new Error("previewCreateSchedule is not executable"); + } + + await expect( + tool.execute({ request }, { + mcp: { extra: { authInfo: auth } }, + } as never), + ).resolves.toMatchObject({ pending: true }); + + await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({ + toolName: "createSchedule", + request, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("agent previewCreateBalance stores a pending write without calling Autumn", async () => { + await clearPendingActions(); + const request = { + customer_id: "cus_1", + entity_id: "workspace_1", + feature_id: "credits", + included_grant: 50000, + expires_at: 1785542400000, + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("previewCreateBalance should not call Autumn"); + }) as unknown as typeof fetch; + + try { + const tool = ( + createAgentAutumnOperationTools() as unknown as { + previewCreateBalance: ExecutableTool; + } + ).previewCreateBalance; + if (!tool.execute) { + throw new Error("previewCreateBalance is not executable"); + } + + await expect( + tool.execute({ request }, { + mcp: { extra: { authInfo: auth } }, + } as never), + ).resolves.toMatchObject({ pending: true }); + + await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({ + toolName: "createBalance", + request, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("confirmBillingAction executes only the stored pending billing action", async () => { + await clearPendingActions(); + await createPendingAction({ + auth, + toolName: "attach", + request: { customer_id: "cus_1", plan_id: "pro" }, + preview: "Attach pro", + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/billing.attach"); + expect(JSON.parse(init?.body as string)).toEqual({ + customer_id: "cus_1", + plan_id: "pro", + redirect_mode: "if_required", + }); + return Response.json({ ok: true }); + }) as typeof fetch; + + try { + const tool = createAgentAutumnOperationTools().confirmBillingAction; + if (!tool.execute) + throw new Error("confirmBillingAction is not executable"); + + await expect( + tool.execute({}, { mcp: { extra: { authInfo: auth } } } as never), + ).resolves.toMatchObject({ + message: "Confirmed and applied attach.", + result: { ok: true }, + }); + await expect(claimLatestPendingAction(auth)).rejects.toThrow( + "No pending", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("confirmBillingAction can execute a stored createBalance request", async () => { + await clearPendingActions(); + const request = { + customer_id: "cus_1", + entity_id: "workspace_1", + feature_id: "credits", + included_grant: 50000, + expires_at: 1785542400000, + }; + await createPendingAction({ + auth, + toolName: "createBalance", + request, + preview: "Create balance", + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("http://localhost:8080/v1/balances.create"); + expect(JSON.parse(init?.body as string)).toEqual(request); + return Response.json({ success: true }); + }) as typeof fetch; + + try { + const tool = createAgentAutumnOperationTools().confirmBillingAction; + if (!tool.execute) + throw new Error("confirmBillingAction is not executable"); + + await expect( + tool.execute({}, { mcp: { extra: { authInfo: auth } } } as never), + ).resolves.toMatchObject({ + message: "Confirmed and applied createBalance.", + result: { success: true }, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/mcp/tests/unit/mcp-server/analytics.test.ts b/packages/mcp/tests/unit/mcp-server/analytics.test.ts new file mode 100644 index 000000000..7b9d77666 --- /dev/null +++ b/packages/mcp/tests/unit/mcp-server/analytics.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { createTool } from "@mastra/core/tools"; +import * as z from "zod/v4"; +import { + instrumentToolsWithAnalytics, + type McpAnalyticsEvent, + setAnalyticsSink, +} from "../../../src/analytics/index.js"; +import type { AutumnMcpAuth } from "../../../src/server/auth/auth.js"; + +const auth: AutumnMcpAuth = { + apiKey: "sk_test", + env: "sandbox", + principalId: "user_1", + resource: "http://localhost:2718/mcp", + scopes: ["billing:read"], + serverURL: "http://localhost:8080", +}; + +const waitForEvent = async (events: McpAnalyticsEvent[]) => { + for (let i = 0; i < 20; i++) { + if (events.length > 0) return events[0]; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("Timed out waiting for analytics event"); +}; + +describe("MCP analytics instrumentation", () => { + test("emits successful tool calls", async () => { + const events: McpAnalyticsEvent[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + Response.json({ id: "org_1", slug: "acme" })) as unknown as typeof fetch; + setAnalyticsSink({ + emit: (event: McpAnalyticsEvent) => events.push(event), + flush: async () => {}, + }); + + try { + const tools = instrumentToolsWithAnalytics({ + surface: "mcp", + tools: { + echo: createTool({ + id: "echo", + description: "Echo input", + inputSchema: z.object({ intent: z.string(), request: z.unknown() }), + execute: async ({ request }) => ({ request }), + }), + }, + }); + + await expect( + tools.echo.execute?.({ intent: "echo input", request: { ok: true } }, { + mcp: { + extra: { + authInfo: auth, + requestInfo: { + headers: { + "mcp-session-id": "mcp_session_1", + "user-agent": "Claude Code", + }, + }, + }, + }, + } as never), + ).resolves.toEqual({ request: { ok: true } }); + + await expect(waitForEvent(events)).resolves.toMatchObject({ + event: "mcp.tool_call", + surface: "mcp", + tool: "echo", + intent: "echo input", + status: "ok", + principalId: "user_1", + client: "Claude Code", + sessionId: "mcp_session_1", + context: { + orgId: "org_1", + orgSlug: "acme", + env: "sandbox", + }, + input: { ok: true }, + output: { request: { ok: true } }, + }); + } finally { + setAnalyticsSink(undefined); + globalThis.fetch = originalFetch; + } + }); + + test("emits errors and rethrows", async () => { + const events: McpAnalyticsEvent[] = []; + setAnalyticsSink({ + emit: (event: McpAnalyticsEvent) => events.push(event), + flush: async () => {}, + }); + + try { + const tools = instrumentToolsWithAnalytics({ + surface: "agent", + tools: { + fail: createTool({ + id: "fail", + description: "Fail input", + inputSchema: z.object({ intent: z.string() }), + execute: async () => { + throw new Error("nope"); + }, + }), + }, + }); + + await expect( + tools.fail.execute?.({ intent: "fail intentionally" }, { + mcp: { extra: { authInfo: auth } }, + } as never), + ).rejects.toThrow("nope"); + + await expect(waitForEvent(events)).resolves.toMatchObject({ + surface: "agent", + tool: "fail", + intent: "fail intentionally", + status: "error", + error: "nope", + }); + } finally { + setAnalyticsSink(undefined); + } + }); +}); diff --git a/packages/mcp/tests/utils/eval-test-utils.ts b/packages/mcp/tests/utils/eval-test-utils.ts new file mode 100644 index 000000000..571c5f866 --- /dev/null +++ b/packages/mcp/tests/utils/eval-test-utils.ts @@ -0,0 +1,372 @@ +import { afterEach, expect } from "bun:test"; +import { createServer, type IncomingMessage, type Server } from "node:http"; +import { Agent } from "@mastra/core/agent"; +import type { MessageListItem } from "@mastra/core/agent/message-list"; +import { Mastra } from "@mastra/core/mastra"; +import { InMemoryStore } from "@mastra/core/storage"; +import { MCPClient } from "@mastra/mcp"; +import type * as z from "zod/v4"; +import { + type AutumnMcpAuth, + createRequestContext, +} from "../../src/server/auth/auth.js"; +import { createAutumnOperationsMCPServer } from "../../src/server/server.js"; +import { endpointByTool, schemaByTool } from "../../src/tools/index.js"; + +type ToolName = keyof typeof schemaByTool; +type EndpointToolName = keyof typeof endpointByTool; +export type ToolRequest = z.output< + (typeof schemaByTool)[Tool] +>; +export type ToolRequestInput = z.input< + (typeof schemaByTool)[Tool] +>; +type ToolCall = { name: string; args: Record }; +type PendingApproval = { + runId: string; + toolCallId?: string; +}; +type AutumnApiFixture = { + [Tool in EndpointToolName]?: unknown | ((body: ToolRequest) => unknown); +}; +type AutumnApiCall = { + toolName: Tool; + endpoint: string; + body: ToolRequest; + rawBody: ToolRequestInput; +}; +type UnknownAutumnApiCall = { + toolName: null; + endpoint: string; + body: unknown; + rawBody: unknown; +}; + +const serverURL = "http://localhost:8080"; +const cleanupFns: (() => void | Promise)[] = []; +const toolEntries = Object.entries(endpointByTool) as [ + EndpointToolName, + string, +][]; +const summarize = (value: unknown) => JSON.stringify(value, null, 2); + +afterEach(async () => { + for (const cleanup of cleanupFns.splice(0).reverse()) await cleanup(); +}); + +const defaultAuth: AutumnMcpAuth = { + apiKey: "sk_test", + env: "sandbox", + principalId: "eval-user", + resource: "http://localhost:2718/mcp", + scopes: [ + "customers:read", + "plans:read", + "billing:read", + "billing:write", + "balances:write", + ], + serverURL, +} satisfies AutumnMcpAuth; + +const closeServer = (server: Server) => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + +const startMcpServer = (auth: AutumnMcpAuth) => + new Promise<{ url: URL; close: () => Promise }>((resolve) => { + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/mcp", `http://${req.headers.host}`); + if (url.pathname !== "/mcp") { + res.writeHead(404).end(); + return; + } + + (req as IncomingMessage & { auth?: AutumnMcpAuth }).auth = auth; + await createAutumnOperationsMCPServer().startHTTP({ + url, + httpPath: "/mcp", + req, + res, + options: { serverless: true }, + }); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("MCP eval server did not bind to a TCP port."); + } + resolve({ + url: new URL(`http://127.0.0.1:${address.port}/mcp`), + close: () => closeServer(server), + }); + }); + }); + +const createMcpConsumerAgent = async (auth: AutumnMcpAuth) => { + const server = await startMcpServer(auth); + const mcpClient = new MCPClient({ + id: `mcp-eval-${crypto.randomUUID()}`, + servers: { + autumn: { + url: server.url, + requireToolApproval: ({ annotations }) => + annotations?.destructiveHint === true, + }, + }, + }); + cleanupFns.push(async () => { + await mcpClient.disconnect(); + await server.close(); + }); + + const { toolsets, errors } = await mcpClient.listToolsetsWithErrors(); + if (Object.keys(errors).length) { + throw new Error(`MCP tool discovery failed: ${summarize(errors)}`); + } + const tools = toolsets.autumn ?? {}; + for (const tool of Object.values(tools)) { + const requiresApproval = tool.mcp?.annotations?.destructiveHint === true; + tool.requireApproval = requiresApproval; + if (!requiresApproval) { + (tool as typeof tool & { needsApprovalFn?: unknown }).needsApprovalFn = + undefined; + } + } + + const agent = new Agent({ + id: "mcp-consumer-eval", + name: "MCP Consumer Eval", + description: "A generic agent using MCP tools.", + instructions: "You are a helpful assistant.", + model: "anthropic/claude-sonnet-4-6", + tools, + }); + const mastra = new Mastra({ + agents: { eval: agent }, + storage: new InMemoryStore({ id: `mcp-eval-${crypto.randomUUID()}` }), + logger: false, + }); + + return mastra.getAgent("eval"); +}; + +const mockAutumnApi = ({ + serverURL, + fixtures, +}: { + serverURL: string; + fixtures: AutumnApiFixture; +}) => { + const calls: (AutumnApiCall | UnknownAutumnApiCall)[] = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (url, init) => { + const requestUrl = new URL(String(url)); + if (requestUrl.origin !== serverURL) { + return originalFetch(url, init); + } + + const endpoint = requestUrl.pathname; + const body = JSON.parse(String(init?.body ?? "{}")); + const toolName = + toolEntries.find(([, path]) => endpoint.endsWith(path))?.[0] ?? null; + const fixture = toolName ? fixtures[toolName] : undefined; + const parsedBody = toolName ? schemaByTool[toolName].parse(body) : body; + calls.push({ + toolName, + endpoint, + body: parsedBody as never, + rawBody: body, + }); + + if (fixture === undefined) { + return Response.json( + { error: `No MCP eval fixture for ${toolName ?? endpoint}` }, + { status: 500 }, + ); + } + + return Response.json( + typeof fixture === "function" + ? fixture(parsedBody as never) + : (fixture ?? { ok: true }), + ); + }) as typeof fetch; + + return { + calls, + call: (toolName: Tool) => + calls.find( + (call): call is AutumnApiCall => call.toolName === toolName, + ), + callsFor: (toolName: Tool) => + calls.filter( + (call): call is AutumnApiCall => call.toolName === toolName, + ), + restore: () => { + globalThis.fetch = originalFetch; + }, + }; +}; +type MockAutumnApi = ReturnType; + +export const initMcpEval = ({ + auth = {}, + fixtures, + today, +}: { + auth?: Partial; + fixtures: AutumnApiFixture; + today?: Date; +}) => { + const resolvedAuth = { ...defaultAuth, ...auth }; + const api = mockAutumnApi({ + serverURL: resolvedAuth.serverURL ?? serverURL, + fixtures, + }); + let agent: Agent | null = null; + let messages: MessageListItem[] = []; + let pendingApproval: PendingApproval | null = null; + const toolCalls: { name: string; args: Record }[] = []; + cleanupFns.push(api.restore); + + const getAgent = async () => { + agent ??= await createMcpConsumerAgent(resolvedAuth); + return agent; + }; + const options = (maxSteps: number) => ({ + maxSteps, + requestContext: createRequestContext(resolvedAuth), + context: today + ? [ + { + role: "system" as const, + content: `Current date: ${today.toISOString()}. Resolve relative dates using calendar time.`, + }, + ] + : undefined, + onIterationComplete: ({ toolCalls: calls }: { toolCalls: ToolCall[] }) => { + toolCalls.push(...calls); + }, + }); + const rememberApproval = (output: { + finishReason?: string; + runId?: string; + suspendPayload?: { toolCallId?: string }; + }) => { + pendingApproval = + output.finishReason === "suspended" && output.runId + ? { + runId: output.runId, + toolCallId: output.suspendPayload?.toolCallId, + } + : null; + }; + const generate = async (message: string | string[], maxSteps = 4) => { + messages.push({ + role: "user", + content: Array.isArray(message) ? message.join("\n") : message, + }); + const output = await (await getAgent()).generate( + messages, + options(maxSteps), + ); + messages = output.messages; + rememberApproval(output); + return output; + }; + + return { + api, + auth: resolvedAuth, + toolCalls, + generate, + approve: async (message: string, maxSteps = 4) => { + if (!pendingApproval) await generate(message, maxSteps); + if (!pendingApproval) { + throw new Error("No pending MCP tool approval to approve."); + } + + const output = await (await getAgent()).approveToolCallGenerate({ + ...options(maxSteps), + runId: pendingApproval.runId, + toolCallId: pendingApproval.toolCallId, + }); + messages = output.messages; + rememberApproval(output); + return output; + }, + }; +}; + +export const expectToolCall = ( + toolCalls: ToolCall[], + toolName: Tool, + request?: Partial>, +) => { + const call = toolCalls.find((call) => call.name === toolName); + expect( + call, + `${toolName} was not called. Called tools:\n${summarize(toolCalls)}`, + ).toBeDefined(); + if (request) { + const parsedRequest = schemaByTool[toolName].parse(call?.args.request); + expect(parsedRequest, `${toolName} args did not match`).toMatchObject( + request, + ); + } + return call; +}; + +export const expectNoToolCall = (toolCalls: ToolCall[], toolName: ToolName) => { + const call = toolCalls.find((call) => call.name === toolName); + expect( + call, + `${toolName} was called unexpectedly:\n${summarize(call)}`, + ).toBeUndefined(); +}; + +export const expectApiCall = ( + api: MockAutumnApi, + toolName: Tool, + body?: Partial>, +) => { + const call = api.call(toolName); + expect( + call, + `${toolName} did not call Autumn. Autumn calls:\n${summarize(api.calls)}`, + ).toBeDefined(); + if (body) { + expect(call?.rawBody, `${toolName} raw body did not match`).toMatchObject( + body, + ); + } + return call; +}; + +export const expectExactApiCall = ( + api: MockAutumnApi, + toolName: Tool, + body: ToolRequestInput, +) => { + const calls = api.callsFor(toolName); + expect( + calls, + `${toolName} should call Autumn exactly once. Autumn calls:\n${summarize(api.calls)}`, + ).toHaveLength(1); + expect(calls[0]?.rawBody, `${toolName} raw body was wrong`).toEqual(body); + return calls[0]; +}; + +export const expectNoApiCall = ( + api: MockAutumnApi, + toolName: EndpointToolName, +) => { + const call = api.call(toolName); + expect( + call, + `${toolName} called Autumn unexpectedly:\n${summarize(call)}`, + ).toBeUndefined(); +}; diff --git a/packages/mcp/src/mcp-server/agent/test-redis.ts b/packages/mcp/tests/utils/test-redis.ts similarity index 94% rename from packages/mcp/src/mcp-server/agent/test-redis.ts rename to packages/mcp/tests/utils/test-redis.ts index 6299bf447..a97f52c61 100644 --- a/packages/mcp/src/mcp-server/agent/test-redis.ts +++ b/packages/mcp/tests/utils/test-redis.ts @@ -1,7 +1,7 @@ import type { PendingActionRedis, PendingActionRedisMulti, -} from "./pending-actions.js"; +} from "../../src/agent/pending-actions.js"; export const createTestRedis = (): PendingActionRedis => { const store = new Map(); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index 1435df961..457afcd7c 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -28,14 +28,16 @@ "sourceMap": true, "strict": true, "target": "es2022", + "types": ["bun", "node"], "paths": { "@api/*": ["../../shared/api/*"], "@models/*": ["../../shared/models/*"], "@utils/*": ["../../shared/utils/*"], + "@autumn/logging": ["../logging/src/index.ts"], "@autumn/ksuid": ["../ksuid/src/index.ts"] }, "useUnknownInCatchVariables": true, }, "exclude": ["node_modules"], - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts"] } diff --git a/packages/openapi/openapi-stripped.yml b/packages/openapi/openapi-stripped.yml index 93ef150c6..02c3117f8 100644 --- a/packages/openapi/openapi-stripped.yml +++ b/packages/openapi/openapi-stripped.yml @@ -482,6 +482,13 @@ components: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -519,6 +526,13 @@ components: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -2209,6 +2223,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -2247,6 +2268,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -3070,6 +3098,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -3108,6 +3143,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -3909,6 +3951,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -3947,6 +3996,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -7950,6 +8006,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -8241,10 +8308,37 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. + discounts: + type: array + items: + type: object + properties: + reward_id: + type: string + description: The ID of the reward to apply as a discount. + promotion_code: + type: string + description: The promotion code to apply as a discount. + title: AttachDiscount + description: A discount to apply. Can be either a reward ID or a promotion code. + description: List of discounts to apply to the immediate phase. Each discount + can be an Autumn reward ID, Stripe coupon ID, or Stripe + promotion code. success_url: type: string description: URL to redirect to after successful checkout. @@ -9209,6 +9303,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -9925,6 +10030,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -10808,6 +10924,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -11849,6 +11976,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -12499,6 +12637,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -16322,6 +16471,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -16358,6 +16514,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -16785,6 +16948,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -16821,6 +16991,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -17301,6 +17478,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -17337,6 +17521,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -17853,6 +18044,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -17889,6 +18087,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -18416,6 +18621,301 @@ paths: x-speakeasy-name-override: redeemCode parameters: - *a5 + /v1/platform.link_revenuecat: + post: + operationId: linkRevenueCat + description: Generate a RevenueCat OAuth URL for linking a project to an organization. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - live + type: string + project_name: + type: string + minLength: 1 + maxLength: 255 + redirect_url: + type: string + format: uri + required: + - organization_slug + - env + - project_name + - redirect_url + title: LinkRevenueCatParams + examples: + - &a77 + organization_slug: acme + env: test + project_name: acme-mobile + redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat + example: *a77 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + oauth_url: + type: string + required: + - oauth_url + title: LinkRevenueCatResponse + examples: + - &a78 + oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write + example: *a78 + x-speakeasy-name-override: linkRevenueCat + parameters: + - *a5 + /v1/platform.sync_revenuecat: + post: + operationId: syncRevenueCat + description: Push an organization's plans into RevenueCat as products (creating + or renaming them across the project's apps) and set test-store prices + from each plan's price. Requires the org to have linked RevenueCat via + OAuth. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - sandbox + - live + type: string + description: '"test" and "sandbox" both target the sandbox environment' + product_ids: + type: array + items: + type: string + description: Plans to push. Omit to sync every plan in the org/env. + required: + - organization_slug + - env + title: SyncRevenueCatParams + examples: + - &a79 + organization_slug: acme + env: test + product_ids: + - pro + - premium + example: *a79 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + plan_id: + type: string + status: + enum: + - synced + - skipped + - error + type: string + store_identifier: + type: string + apps: + type: array + items: + type: object + properties: + app_id: + type: string + app_type: + type: string + product: + enum: + - created + - updated + - exists + type: string + store_push: + enum: + - pushed + - failed + - skipped + type: string + price: + enum: + - set + - skipped + - failed + type: string + message: + type: string + required: + - app_id + - app_type + - product + message: + type: string + required: + - plan_id + - status + required: + - results + title: SyncRevenueCatResponse + examples: + - &a80 + results: + - plan_id: pro + status: synced + store_identifier: autumn.sandbox.org_123.pro + apps: + - app_id: app_test + app_type: test_store + product: created + store_push: skipped + price: set + example: *a80 + x-speakeasy-name-override: syncRevenueCat + parameters: + - *a5 + /v1/platform.get_revenuecat_keys: + post: + operationId: getRevenueCatKeys + description: Retrieve a managed organization's RevenueCat public (SDK) API keys, + grouped by app — for the test store, App Store, and Google Play Store. + Use these to configure the RevenueCat SDK in the org's mobile app. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - sandbox + - live + type: string + description: '"test" and "sandbox" both target the sandbox environment' + required: + - organization_slug + - env + title: GetRevenueCatKeysParams + examples: + - &a81 + organization_slug: acme + env: test + example: *a81 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + apps: + type: array + items: + type: object + properties: + app_id: + type: string + app_type: + type: string + description: RevenueCat store type, e.g. test_store / app_store / play_store + name: + type: string + api_keys: + type: array + items: + type: object + properties: + id: + type: string + key: + type: string + description: The public SDK API key value + environment: + anyOf: + - type: string + - type: "null" + description: e.g. "production" / "sandbox" + app_id: + anyOf: + - type: string + - type: "null" + created_at: + type: number + required: + - id + - key + additionalProperties: {} + required: + - app_id + - app_type + - name + - api_keys + oauth_access_token: + anyOf: + - type: string + - type: "null" + description: Freshly-refreshed RevenueCat OAuth access token for the org (null + for api-key orgs). The refresh token is never exposed — + call this endpoint again for a new access token. + required: + - apps + - oauth_access_token + title: GetRevenueCatKeysResponse + examples: + - &a82 + apps: + - app_id: app1a2b3c4d + app_type: test_store + name: Acme (Test Store) + api_keys: + - id: apikey12345 + key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ + environment: production + app_id: app1a2b3c4 + oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ + example: *a82 + x-speakeasy-name-override: getRevenueCatKeys + parameters: + - *a5 security: - secretKey: [] x-speakeasy-globals: diff --git a/packages/openapi/openapi.yml b/packages/openapi/openapi.yml index 6bdf8aeb2..ab68de215 100644 --- a/packages/openapi/openapi.yml +++ b/packages/openapi/openapi.yml @@ -482,6 +482,13 @@ components: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -519,6 +526,13 @@ components: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -2278,6 +2292,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -2316,6 +2337,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -3135,6 +3163,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -3173,6 +3208,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -3970,6 +4012,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -4008,6 +4057,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -8425,6 +8481,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -8678,12 +8745,13 @@ paths: @example ```typescript // Schedule a transition from a trial plan to a paid plan - const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1779977746466,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781187346466,"plans":[{"planId":"pro_plan"}]}] }); + const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @param entityId - Optional entity ID for an entity-scoped schedule. (optional) @param invoiceMode - Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. (optional) + @param discounts - List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. (optional) @param successUrl - URL to redirect to after successful checkout. (optional) @param checkoutSessionParams - Additional parameters to pass into the creation of the Stripe checkout session. (optional) @param redirectMode - Controls when to return a checkout URL for the immediate phase. 'always' forces a confirmation or checkout flow, 'if_required' only redirects when needed, and 'never' disables redirects. (optional) @@ -8726,10 +8794,37 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. + discounts: + type: array + items: + type: object + properties: + reward_id: + type: string + description: The ID of the reward to apply as a discount. + promotion_code: + type: string + description: The promotion code to apply as a discount. + title: AttachDiscount + description: A discount to apply. Can be either a reward ID or a promotion code. + description: List of discounts to apply to the immediate phase. Each discount + can be an Autumn reward ID, Stripe coupon ID, or Stripe + promotion code. success_url: type: string description: URL to redirect to after successful checkout. @@ -9718,6 +9813,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -10538,6 +10644,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -11434,6 +11551,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -12564,6 +12692,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -13279,6 +13418,17 @@ paths: default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose + footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due + (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the @@ -17204,6 +17354,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -17240,6 +17397,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -17696,6 +17860,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -17732,6 +17903,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -18258,6 +18436,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -18294,6 +18479,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -18816,6 +19008,13 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or + entity level. required: - id - plan_id @@ -18852,6 +19051,13 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity + level. required: - plan_id - expires_at @@ -19380,6 +19586,289 @@ paths: x-speakeasy-name-override: redeemCode parameters: - *a1 + /v1/platform.link_revenuecat: + post: + operationId: linkRevenueCat + description: Generate a RevenueCat OAuth URL for linking a project to an organization. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - live + type: string + project_name: + type: string + minLength: 1 + maxLength: 255 + redirect_url: + type: string + format: uri + required: + - organization_slug + - env + - project_name + - redirect_url + title: LinkRevenueCatParams + examples: + - organization_slug: acme + env: test + project_name: acme-mobile + redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + oauth_url: + type: string + required: + - oauth_url + title: LinkRevenueCatResponse + examples: + - oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write + x-speakeasy-name-override: linkRevenueCat + parameters: + - *a1 + /v1/platform.sync_revenuecat: + post: + operationId: syncRevenueCat + description: Push an organization's plans into RevenueCat as products (creating + or renaming them across the project's apps) and set test-store prices + from each plan's price. Requires the org to have linked RevenueCat via + OAuth. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - sandbox + - live + type: string + description: '"test" and "sandbox" both target the sandbox environment' + product_ids: + type: array + items: + type: string + description: Plans to push. Omit to sync every plan in the org/env. + required: + - organization_slug + - env + title: SyncRevenueCatParams + examples: + - organization_slug: acme + env: test + product_ids: + - pro + - premium + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + plan_id: + type: string + status: + enum: + - synced + - skipped + - error + type: string + store_identifier: + type: string + apps: + type: array + items: + type: object + properties: + app_id: + type: string + app_type: + type: string + product: + enum: + - created + - updated + - exists + type: string + store_push: + enum: + - pushed + - failed + - skipped + type: string + price: + enum: + - set + - skipped + - failed + type: string + message: + type: string + required: + - app_id + - app_type + - product + message: + type: string + required: + - plan_id + - status + required: + - results + title: SyncRevenueCatResponse + examples: + - results: + - plan_id: pro + status: synced + store_identifier: autumn.sandbox.org_123.pro + apps: + - app_id: app_test + app_type: test_store + product: created + store_push: skipped + price: set + x-speakeasy-name-override: syncRevenueCat + parameters: + - *a1 + /v1/platform.get_revenuecat_keys: + post: + operationId: getRevenueCatKeys + description: Retrieve a managed organization's RevenueCat public (SDK) API keys, + grouped by app — for the test store, App Store, and Google Play Store. + Use these to configure the RevenueCat SDK in the org's mobile app. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - sandbox + - live + type: string + description: '"test" and "sandbox" both target the sandbox environment' + required: + - organization_slug + - env + title: GetRevenueCatKeysParams + examples: + - organization_slug: acme + env: test + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + apps: + type: array + items: + type: object + properties: + app_id: + type: string + app_type: + type: string + description: RevenueCat store type, e.g. test_store / app_store / play_store + name: + type: string + api_keys: + type: array + items: + type: object + properties: + id: + type: string + key: + type: string + description: The public SDK API key value + environment: + anyOf: + - type: string + - type: "null" + description: e.g. "production" / "sandbox" + app_id: + anyOf: + - type: string + - type: "null" + created_at: + type: number + required: + - id + - key + additionalProperties: {} + required: + - app_id + - app_type + - name + - api_keys + oauth_access_token: + anyOf: + - type: string + - type: "null" + description: Freshly-refreshed RevenueCat OAuth access token for the org (null + for api-key orgs). The refresh token is never exposed — + call this endpoint again for a new access token. + required: + - apps + - oauth_access_token + title: GetRevenueCatKeysResponse + examples: + - apps: + - app_id: app1a2b3c4d + app_type: test_store + name: Acme (Test Store) + api_keys: + - id: apikey12345 + key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ + environment: production + app_id: app1a2b3c4 + oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ + x-speakeasy-name-override: getRevenueCatKeys + parameters: + - *a1 security: - secretKey: [] x-speakeasy-globals: diff --git a/packages/openapi/tsconfig.json b/packages/openapi/tsconfig.json index 6d08724fc..fcbd4453d 100644 --- a/packages/openapi/tsconfig.json +++ b/packages/openapi/tsconfig.json @@ -7,6 +7,7 @@ "moduleResolution": "bundler", "target": "ES2020", "noEmit": true, + "types": ["node", "bun"], "paths": { "@autumn/shared": ["../../shared/index.ts"], "@api/*": ["../../shared/api/*"], @@ -15,6 +16,5 @@ } }, "include": ["./**/*"], - "types": ["node"], "exclude": ["node_modules", "dist"] } diff --git a/packages/openapi/v2.3/contracts/index.ts b/packages/openapi/v2.3/contracts/index.ts index f16141e83..78748b962 100644 --- a/packages/openapi/v2.3/contracts/index.ts +++ b/packages/openapi/v2.3/contracts/index.ts @@ -52,6 +52,11 @@ import { listPlansContract, updatePlanContract, } from "./plansContract.js"; +import { + platformGetRevenueCatKeysContract, + platformLinkRevenueCatContract, + platformSyncRevenueCatContract, +} from "./platformContract.js"; import { referralsCreateCodeContract, referralsRedeemCodeContract, @@ -116,4 +121,9 @@ balancesTrackTokens: balancesTrackTokensContract, referralsCreateCode: referralsCreateCodeContract, referralsRedeemCode: referralsRedeemCodeContract, rewardsRedeemCode: rewardsRedeemCodeContract, + + // Platform + platformLinkRevenueCat: platformLinkRevenueCatContract, + platformSyncRevenueCat: platformSyncRevenueCatContract, + platformGetRevenueCatKeys: platformGetRevenueCatKeysContract, }); diff --git a/packages/openapi/v2.3/contracts/platformContract.ts b/packages/openapi/v2.3/contracts/platformContract.ts new file mode 100644 index 000000000..2b1b942d7 --- /dev/null +++ b/packages/openapi/v2.3/contracts/platformContract.ts @@ -0,0 +1,143 @@ +import { + GetRevenueCatKeysResponseSchema, + GetRevenueCatKeysSchema, + LinkRevenueCatResponseSchema, + LinkRevenueCatSchema, + SyncRevenueCatResponseSchema, + SyncRevenueCatSchema, +} from "@autumn/shared"; +import { oc } from "@orpc/contract"; + +export const platformLinkRevenueCatContract = oc + .route({ + method: "POST", + path: "/v1/platform.link_revenuecat", + operationId: "linkRevenueCat", + tags: ["platform"], + description: + "Generate a RevenueCat OAuth URL for linking a project to an organization.", + spec: (spec) => ({ + ...spec, + "x-speakeasy-name-override": "linkRevenueCat", + }), + }) + .input( + LinkRevenueCatSchema.meta({ + title: "LinkRevenueCatParams", + examples: [ + { + organization_slug: "acme", + env: "test", + project_name: "acme-mobile", + redirect_url: "https://dashboard.useautumn.com/dev?tab=revenuecat", + }, + ], + }), + ) + .output( + LinkRevenueCatResponseSchema.meta({ + title: "LinkRevenueCatResponse", + examples: [ + { + oauth_url: + "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write", + }, + ], + }), + ); + +export const platformSyncRevenueCatContract = oc + .route({ + method: "POST", + path: "/v1/platform.sync_revenuecat", + operationId: "syncRevenueCat", + tags: ["platform"], + description: + "Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.", + spec: (spec) => ({ + ...spec, + "x-speakeasy-name-override": "syncRevenueCat", + }), + }) + .input( + SyncRevenueCatSchema.meta({ + title: "SyncRevenueCatParams", + examples: [ + { + organization_slug: "acme", + env: "test", + product_ids: ["pro", "premium"], + }, + ], + }), + ) + .output( + SyncRevenueCatResponseSchema.meta({ + title: "SyncRevenueCatResponse", + examples: [ + { + results: [ + { + plan_id: "pro", + status: "synced", + store_identifier: "autumn.sandbox.org_123.pro", + apps: [ + { + app_id: "app_test", + app_type: "test_store", + product: "created", + store_push: "skipped", + price: "set", + }, + ], + }, + ], + }, + ], + }), + ); + +export const platformGetRevenueCatKeysContract = oc + .route({ + method: "POST", + path: "/v1/platform.get_revenuecat_keys", + operationId: "getRevenueCatKeys", + tags: ["platform"], + description: + "Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.", + spec: (spec) => ({ + ...spec, + "x-speakeasy-name-override": "getRevenueCatKeys", + }), + }) + .input( + GetRevenueCatKeysSchema.meta({ + title: "GetRevenueCatKeysParams", + examples: [{ organization_slug: "acme", env: "test" }], + }), + ) + .output( + GetRevenueCatKeysResponseSchema.meta({ + title: "GetRevenueCatKeysResponse", + examples: [ + { + apps: [ + { + app_id: "app1a2b3c4d", + app_type: "test_store", + name: "Acme (Test Store)", + api_keys: [ + { + id: "apikey12345", + key: "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ", + environment: "production", + app_id: "app1a2b3c4", + }, + ], + }, + ], + oauth_access_token: "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ", + }, + ], + }), + ); diff --git a/packages/sdk/.speakeasy/code-samples.overlay.yaml b/packages/sdk/.speakeasy/code-samples.overlay.yaml index 06d3950ce..e839f89ef 100644 --- a/packages/sdk/.speakeasy/code-samples.overlay.yaml +++ b/packages/sdk/.speakeasy/code-samples.overlay.yaml @@ -962,6 +962,81 @@ actions: console.log(result); } + run(); + - target: $["paths"]["/v1/platform.get_revenuecat_keys"]["post"] + update: + x-codeSamples: + - lang: typescript + label: Typescript (SDK) + source: |- + import { Autumn } from "@useautumn/sdk"; + + const autumn = new Autumn({ + xApiVersion: "2.3.0", + secretKey: process.env["AUTUMN_SECRET_KEY"] ?? "", + }); + + async function run() { + const result = await autumn.platform.getRevenueCatKeys({ + organizationSlug: "acme", + env: "test", + }); + + console.log(result); + } + + run(); + - target: $["paths"]["/v1/platform.link_revenuecat"]["post"] + update: + x-codeSamples: + - lang: typescript + label: Typescript (SDK) + source: |- + import { Autumn } from "@useautumn/sdk"; + + const autumn = new Autumn({ + xApiVersion: "2.3.0", + secretKey: process.env["AUTUMN_SECRET_KEY"] ?? "", + }); + + async function run() { + const result = await autumn.platform.linkRevenueCat({ + organizationSlug: "acme", + env: "test", + projectName: "acme-mobile", + redirectUrl: "https://dashboard.useautumn.com/dev?tab=revenuecat", + }); + + console.log(result); + } + + run(); + - target: $["paths"]["/v1/platform.sync_revenuecat"]["post"] + update: + x-codeSamples: + - lang: typescript + label: Typescript (SDK) + source: |- + import { Autumn } from "@useautumn/sdk"; + + const autumn = new Autumn({ + xApiVersion: "2.3.0", + secretKey: process.env["AUTUMN_SECRET_KEY"] ?? "", + }); + + async function run() { + const result = await autumn.platform.syncRevenueCat({ + organizationSlug: "acme", + env: "test", + productIds: [ + "pro", + "premium", + ], + }); + + console.log(result); + } + run(); - target: $["paths"]["/v1/referrals.create_code"]["post"] update: diff --git a/packages/sdk/.speakeasy/gen.lock b/packages/sdk/.speakeasy/gen.lock index fa2d0dc84..7275c6adf 100644 --- a/packages/sdk/.speakeasy/gen.lock +++ b/packages/sdk/.speakeasy/gen.lock @@ -1,19 +1,20 @@ lockVersion: 2.0.0 id: 7b300647-cd76-49e9-bf77-7d1bf5446d66 management: - docChecksum: 364fa19149970fee6fcb163993e0f41f + docChecksum: 8871f01e9bcff4b338df44c213b1e355 docVersion: 2.3.0 speakeasyVersion: 1.762.0 generationVersion: 2.882.0 releaseVersion: 0.10.17 configChecksum: 4722f16a8dee67ebd4038caf3c345296 persistentEdits: - generation_id: 415422dc-a18e-4c5c-8688-24da80f6f2cb - pristine_commit_hash: aa99b58acd07c9871e8d62228e9440f86ed6baaa - pristine_tree_hash: 1fdf2cd6e37e1756e79a53d2833301f14da44293 + generation_id: f374e041-3680-4d4c-bb37-4ab0614209de + pristine_commit_hash: cb76a996f7b1f4a11fb853cf30564f860c859ffd + pristine_tree_hash: 00b78979361e0ebbbac1ef29531df0aeb71cba05 features: typescript: additionalDependencies: 0.1.0 + additionalProperties: 0.1.3 constsAndDefaults: 0.1.14 core: 3.26.50 defaultEnabledRetries: 0.1.0 @@ -90,6 +91,10 @@ trackedFiles: id: 147b886181ab last_write_checksum: sha1:745ae2d8cabb73e63fed45a1d83682e602daa81b pristine_git_object: 39e2e27b82c7b641f0a498550c99017a00321785 + docs/models/api-key.md: + id: 49935f80e61b + last_write_checksum: sha1:4fbedd42011cb6919ce65469d7cda5be719b5971 + pristine_git_object: 1690222ff6b428eff348de3b09fb103bee30e9b5 docs/models/attach-action.md: id: 8de20a26b9b9 last_write_checksum: sha1:44c1a79c9374746d732856aa68bf5844c148bdf8 @@ -196,8 +201,8 @@ trackedFiles: pristine_git_object: 0bccf8fe67e3ec5e109afab60e5e17f4b54cb2c8 docs/models/attach-invoice-mode.md: id: 06a085c65c2a - last_write_checksum: sha1:12f4e22bd2f6c3a8afea33451b73c36590e47788 - pristine_git_object: 5cc697e43e44133da8313f8aa11e9eb03e584d3f + last_write_checksum: sha1:dec3be9fb7e2358a6aa82e5a5f1c76ebcb6db9dd + pristine_git_object: 6fe2bd4689e2970e18777e28bebefb7fd0139fa8 docs/models/attach-invoice.md: id: f8e57e6d6a18 last_write_checksum: sha1:b8f16fc3db06bd20e23afa7c29c3442c909b4e34 @@ -460,8 +465,8 @@ trackedFiles: pristine_git_object: a5ca2395c55d099fdc8ca0eb2ca7d93121cc36a3 docs/models/billing-update-invoice-mode.md: id: 33eaf6fc88e5 - last_write_checksum: sha1:fbb808bc2b039ca460b4f69c9f32ef3967e12d1b - pristine_git_object: 2f182773091916f4903b274a2a4163a5e9e5bfb1 + last_write_checksum: sha1:c7d8a3d2d3dddf9afc4c529ae246f892558ec84c + pristine_git_object: f83c66022763f0e8d36670c12883760939ceecd0 docs/models/billing-update-invoice.md: id: af93d501891d last_write_checksum: sha1:3fee4f5648997757b5e17a55fa74baaee170e641 @@ -662,6 +667,14 @@ trackedFiles: id: 1f4a01957fbe last_write_checksum: sha1:c8c546b4aa4d1e459aef8131e522e6bf41ccc7c7 pristine_git_object: 5815e9a4d8b5921e63cce39209bb8172a65659d8 + docs/models/check-product1.md: + id: 9ba1f3f0f2e9 + last_write_checksum: sha1:98b546b9cebd5b773c0d82b0241cbc9e2df91692 + pristine_git_object: eea36f3d8674d15e62974845a13361705dee1eb1 + docs/models/check-product2.md: + id: 7d4ef4fdab22 + last_write_checksum: sha1:8aae0bb7098cda51316e5e6eb83497f73a740fcc + pristine_git_object: a5cf026e769ee5a7302238cbbcab7752bcf0de9e docs/models/check-response-body1.md: id: fe7fb45cea45 last_write_checksum: sha1:8844e03a5e1af097130d9578157d06c96ecec130 @@ -778,10 +791,14 @@ trackedFiles: id: 6607564588d5 last_write_checksum: sha1:1cc8a06d6103c189ccb0c90d9b92935dc4916b9f pristine_git_object: c41b9f77e9ccf935279b0d71b0719bc218065b09 + docs/models/create-entity-purchase-scope.md: + id: 9691e19134cf + last_write_checksum: sha1:70378ac5e9d81ee102dacf7df1bf368f600a651b + pristine_git_object: 92952266838d17ab86b460d913d7fb094b664631 docs/models/create-entity-purchase.md: id: 0ab83f1b1fde - last_write_checksum: sha1:0c1c8781d2d3e584e3e63c998a50d2de9d81ade0 - pristine_git_object: 2900587b566b4b460283024da4d4d2cdcdb31f5b + last_write_checksum: sha1:108c0ed771a5a0c7cb2ffe19e75b28ae4b959087 + pristine_git_object: 8ee450d8a5ae1d95146a2ac11326ed8b70c5efe2 docs/models/create-entity-response.md: id: b05b19776a8c last_write_checksum: sha1:0cb4dda8614ec55d6672d69c3bea953c20ef3761 @@ -798,10 +815,14 @@ trackedFiles: id: 658f1e6dd4d5 last_write_checksum: sha1:0aa5d631d7d9ba0d9a0a42870153e7242b5b2ced pristine_git_object: 788878d27119dc82e2877df317b425a1599a7f12 + docs/models/create-entity-subscription-scope.md: + id: 8c58223a908a + last_write_checksum: sha1:e430e2156c76e071afe660d3464b544381888f85 + pristine_git_object: a3ba427bca73cff97ef8b1502c0de44435716817 docs/models/create-entity-subscription.md: id: d07f3f230823 - last_write_checksum: sha1:52cca9d30e75b5a15325cde2003f67926dba0ab2 - pristine_git_object: d3e9d13f1321d7af478fee7983ab2af4e4a647c4 + last_write_checksum: sha1:b51a483eeeea2b5e5ab05e83a543c0de3e0dc657 + pristine_git_object: 70de1056070f760ea59cf35cc60e20aa9d2a9a98 docs/models/create-entity-threshold-type-request-body.md: id: e8502fdc1235 last_write_checksum: sha1:9831524032bd57cf993c49a125907590fc80aebd @@ -1058,6 +1079,10 @@ trackedFiles: id: 8d8c5e4e1502 last_write_checksum: sha1:94a43a0d34bb024010eb069d5d2e71b4d5f87d39 pristine_git_object: 4971dcd50e07c557f14ab1fe6c87ff430903d288 + docs/models/create-schedule-attach-discount.md: + id: dab607210d95 + last_write_checksum: sha1:d728c6486f5fa8bc666dbe65778f217ad1d054e1 + pristine_git_object: 1509adb1d6cae175888e8052ce9b3e90ab756c96 docs/models/create-schedule-base-price2.md: id: 56793b9353fd last_write_checksum: sha1:b2a0bce4529470dbec2cffe056442d3a54fb2d20 @@ -1088,8 +1113,8 @@ trackedFiles: pristine_git_object: 995336526a473eee9e0d8c01f3903b580baad3cf docs/models/create-schedule-invoice-mode.md: id: 31a296d3edf2 - last_write_checksum: sha1:a05cd82ce1253bc69c43a4d5769085b62bab36f9 - pristine_git_object: 67000674541be7b7b829bc4023e2d184a80f45ff + last_write_checksum: sha1:9abde7a6fdad79b4ac7e0f15a03e27cdbc9700b8 + pristine_git_object: 40404a4b9ae72eb494395d085820ff7969c319a7 docs/models/create-schedule-invoice.md: id: 8c5ca97f625b last_write_checksum: sha1:a8705e08f8a20efb4463ef8640ebd7742da9ebb0 @@ -1108,8 +1133,8 @@ trackedFiles: pristine_git_object: 7b0ac6d95b3598e6fbbce518ac22c4d9c7b8b6c4 docs/models/create-schedule-params.md: id: 9cfe8e156925 - last_write_checksum: sha1:bed994cf907397d6fc395cbe9974060a1a8f2c71 - pristine_git_object: 85b19127b1d4ece3e84b4de92af284f15f534f23 + last_write_checksum: sha1:d1678ebd53fefef44a41ce6a2cef2f58486afdcf + pristine_git_object: d393b27966e89cac70d86ee1f6cd99c0b943ed92 docs/models/create-schedule-plan-item2.md: id: 973e64524e71 last_write_checksum: sha1:b88dd8af325321c41f175594e1eee893b5cd29f6 @@ -1578,10 +1603,14 @@ trackedFiles: id: c29fdf3a4bf2 last_write_checksum: sha1:adf9348e0d656673303e6b1704f9c79f12fb453f pristine_git_object: e77ebcf491d664d2e54b9ce1bf57d53f6b66d256 + docs/models/get-customer-purchase-scope.md: + id: c849cf872990 + last_write_checksum: sha1:7d685bc6f76a2778351db483cb931ac833f80c53 + pristine_git_object: 7c009cdaf53677945dacf2271a93fbf6c494d212 docs/models/get-customer-purchase.md: id: 7841da62bd1d - last_write_checksum: sha1:ec75828910f8445ec92cdd997636be9de2918673 - pristine_git_object: 14d2c55884d8f81389793b1770731605624be87a + last_write_checksum: sha1:97acdf8e4012784db5d46b864c8094954959dd7b + pristine_git_object: 38be77bc9650c248cfc2f90cacbc16216b9d2b67 docs/models/get-customer-referral.md: id: ab44561c4fbf last_write_checksum: sha1:636d28d965414ced9085ecb4322d39bab149e999 @@ -1614,10 +1643,14 @@ trackedFiles: id: 00d031bb21a8 last_write_checksum: sha1:93f8cf1938038e9e796298a88f87aee4f0fab49e pristine_git_object: 8da278a5a7d8d2e15220036049c36fddfa713704 + docs/models/get-customer-subscription-scope.md: + id: 8bdb5ddadf4e + last_write_checksum: sha1:16c5ce804e6aa9a7d0dd83dc52c4aca790d86de6 + pristine_git_object: 3475e36cfac68e0420f51c8e7ffa8dece16bb4b4 docs/models/get-customer-subscription.md: id: 6242a121e898 - last_write_checksum: sha1:6c38c2fb933b52ab352c06058969e6577ee7240d - pristine_git_object: 40c4c23f08576aca492f65403105d53c7685bea0 + last_write_checksum: sha1:c3d5a78b9ed6534c68754351290a3293d170124f + pristine_git_object: 5fefd184f8c495557e0efda269a3ea50c9bfc02f docs/models/get-customer-threshold-type.md: id: 220d39637cce last_write_checksum: sha1:3dcb95fa681face04fdc7660cce7b379d96e6012 @@ -1678,10 +1711,14 @@ trackedFiles: id: 3921d2e48596 last_write_checksum: sha1:2a123a8e0ec49766affde3647ff4388790ce1015 pristine_git_object: 5da8805280446452234efa9e5e18408775714097 + docs/models/get-entity-purchase-scope.md: + id: fc36a3fa821d + last_write_checksum: sha1:4731bf8cf5be4e596228978593c7bc3d8d447d33 + pristine_git_object: 8823af6d3e98ecf09f8695eac49bd2d79eff5043 docs/models/get-entity-purchase.md: id: b53cb753f928 - last_write_checksum: sha1:39caf81d807d1d830aa04d305f3a7b1242075b68 - pristine_git_object: d5cabde956e74305128951c3c7d72f1a3543d5ff + last_write_checksum: sha1:004c2ad8870cfa2e76e71c728f4ccf0093cc6112 + pristine_git_object: 3f36ec441351f0098c78451204e7264c3c88e3d3 docs/models/get-entity-response.md: id: 125e7bd4f436 last_write_checksum: sha1:bcee4f061f72f7e79a136d211d0f3c4bab4becd4 @@ -1694,10 +1731,14 @@ trackedFiles: id: 06f4024e9973 last_write_checksum: sha1:c8b7df3b5f53540a93ad059be38d7f7f8abea440 pristine_git_object: 525aa382f4566aa0945589bb2f76bd30dc734080 + docs/models/get-entity-subscription-scope.md: + id: 16eb526e6ad3 + last_write_checksum: sha1:1e55d52a7565b20a375394e0d81e4e6ff394d2ca + pristine_git_object: 5b97cf116ceed5a9b9ae9d46e620a3bc44595a8b docs/models/get-entity-subscription.md: id: 3c768c24c069 - last_write_checksum: sha1:7c4324295c741949236c978bc968ae51e8d8f570 - pristine_git_object: 6dc404626b7beea1ba32c01269a9debd4533edee + last_write_checksum: sha1:3ed75a92e92ca5c81af8dca253aec7112f1bc7f6 + pristine_git_object: 4df3e7e4970598c84699e9c4ba26a83ad43e3561 docs/models/get-entity-threshold-type.md: id: aeffe83b54ad last_write_checksum: sha1:1307943ccedb89de553abd2659c2d76d52e1f0e2 @@ -1890,6 +1931,26 @@ trackedFiles: id: 5300ef539ed8 last_write_checksum: sha1:b334efb80b4e13e356c43248994d2f2640ffcf27 pristine_git_object: b6ff1ca2773ceb3a33da7acc3bf2144f2823c16a + docs/models/get-revenue-cat-keys-app.md: + id: 8ff879eb81c5 + last_write_checksum: sha1:23379e8e1717339e91ff62f63c9a5115c03a8645 + pristine_git_object: 72a6b23198b2ae6607ea5a1b6e837959b08d1bee + docs/models/get-revenue-cat-keys-env.md: + id: 8fac28e0850c + last_write_checksum: sha1:55e6cc463e3ec7dc30dc5f947498665dbb4b10a9 + pristine_git_object: 2970169afd4db5e7f45502de6d81acfd22c26e47 + docs/models/get-revenue-cat-keys-globals.md: + id: c16f86821cb5 + last_write_checksum: sha1:dc3c016b33a02bc5d989a84faeede70d292a26fc + pristine_git_object: 594305da769e390d0b500044f781edfa178838c2 + docs/models/get-revenue-cat-keys-params.md: + id: 1eccc6003eaa + last_write_checksum: sha1:e2f7cf31d2a3631b84cef36087e071bd80a7a58a + pristine_git_object: 53e5c4b12907bb9c35af3da1fc7cc5a92a7d34bf + docs/models/get-revenue-cat-keys-response.md: + id: 23fbb157aef1 + last_write_checksum: sha1:8b52d7474285395cdc4850ec3575c34a48bd6894 + pristine_git_object: e4188090653f6bb5d14d111effa8f5891a2874e1 docs/models/included-usage1.md: id: a4def1415784 last_write_checksum: sha1:83f97786c0a5ca88c4f689e318146531cdd84ba8 @@ -1910,6 +1971,22 @@ trackedFiles: id: 40dd7473ab87 last_write_checksum: sha1:5a9566a4c38c01c126b5024eb5178b00696eec1a pristine_git_object: c090aed3f7bd612068482258027339e35a59633e + docs/models/link-revenue-cat-env.md: + id: cb0ccb8f0c80 + last_write_checksum: sha1:1ac365d9491e8286e3127d81892998b66ea71410 + pristine_git_object: 73950de2835b7e83b726bc3c79c10cd3c17c6bd2 + docs/models/link-revenue-cat-globals.md: + id: f4756c9e50ec + last_write_checksum: sha1:8078995f5223a872d9fd096689b4e3ee681488c3 + pristine_git_object: f2b91703b4d9d47b169953e042a46791b3fbde11 + docs/models/link-revenue-cat-params.md: + id: ab17a5cb36c9 + last_write_checksum: sha1:b5f8698a52fc265067baa8917ab92a46a1789eb0 + pristine_git_object: 4c42fb2a6a88ff9ac5a9b78955e65852cd3d9498 + docs/models/link-revenue-cat-response.md: + id: 2aaf8d83ed87 + last_write_checksum: sha1:55f93a53e4775d761e24c52886c27d40068c1f95 + pristine_git_object: ec7ad309188875f2945342887e9e584b8a554ac5 docs/models/list-customers-auto-topup.md: id: 2ee1f1219e0a last_write_checksum: sha1:d052f3f37235db8af434e1d5f143bfb5f1baff39 @@ -1990,10 +2067,14 @@ trackedFiles: id: 3b390b5a9c85 last_write_checksum: sha1:af002b1e6c6803e161e92398d94063009189a78d pristine_git_object: 1a1c46b14c65388a1c681df8d2cdd7724f4e3400 + docs/models/list-customers-purchase-scope.md: + id: 33e1caeffe4f + last_write_checksum: sha1:04c81a5351f95fbaa69059a20dece303879a01a8 + pristine_git_object: 0700f158d3ae22dc2a2256fd1db6889c447f1e36 docs/models/list-customers-purchase.md: id: d650442c19db - last_write_checksum: sha1:ade85104c093739ade880fb8c6a12ae160e46e9d - pristine_git_object: fe03aa7b5c0cfbff67fb4d0885225349b46eaced + last_write_checksum: sha1:ec0b6071e7e152a5e54f011676156beface4b511 + pristine_git_object: 639555f3cae45cc60a4d7a417dc19360cecaaa40 docs/models/list-customers-response.md: id: a8afc03241c9 last_write_checksum: sha1:ef62f02095f81f3de105307007accb98ddc33e10 @@ -2014,14 +2095,18 @@ trackedFiles: id: 0fa8c9ecbeb2 last_write_checksum: sha1:4745367469b72b1c04ecb39f99bdc3d889e6fdc3 pristine_git_object: 3b72ceb1546d65c53809555eb63d99f194d58ae0 + docs/models/list-customers-subscription-scope.md: + id: c761b9e6a1e4 + last_write_checksum: sha1:3d2c6cced35d90964022d33abb92cefe4a2ef0e8 + pristine_git_object: 35f3aaa62a3ab0f1860b8a25b06dbb1c06e002e2 docs/models/list-customers-subscription-status.md: id: 3f8df2a5cf7f last_write_checksum: sha1:f22b0c76e21b457ddd7bb4f96ca7737067591d24 pristine_git_object: 0754adcfb5e753a4f454c308dae87c3a7d4dc238 docs/models/list-customers-subscription.md: id: e5c39bdff7de - last_write_checksum: sha1:2149bab9fbc402075c6e6fad9227da4d934f6354 - pristine_git_object: eebfb64ba72352c3bdd046c5d524791b545c2c2c + last_write_checksum: sha1:083c900e92d1fa5896698ba5de293b5813ead67a + pristine_git_object: 511d29cf16ac19f6446c2b30e44c1aa5bf7ef9bf docs/models/list-customers-threshold-type.md: id: b31853748775 last_write_checksum: sha1:dfc697b34aee577ce5520888d04d7614083e5edc @@ -2094,10 +2179,14 @@ trackedFiles: id: 8eed7e174881 last_write_checksum: sha1:9c9afb3d43be7f860d4d6d885523ed96c04cea23 pristine_git_object: 224d058e72082f0f48951b564acd3362331d63d1 + docs/models/list-entities-purchase-scope.md: + id: d5d5b051196e + last_write_checksum: sha1:eff4573688a4ee268c16d83b09399caea3119c20 + pristine_git_object: c7c1ab62283cf02f9e508d9e7f3ddd83ed5d570f docs/models/list-entities-purchase.md: id: 032f85765520 - last_write_checksum: sha1:3f04fca80f1bc853c47dc7bba64527645f362bd6 - pristine_git_object: 675f11020b5bc609520c004bf7d5d5e86cf76d31 + last_write_checksum: sha1:5a9f7258ce2c55d09870e9592d5a21d4961ad6d7 + pristine_git_object: 44622519073634404eb582650aa7f4e1809d0d6c docs/models/list-entities-response.md: id: bec7fb4bfa56 last_write_checksum: sha1:414c794572db71a8102d9cf6cdf3e36ffe15e6e4 @@ -2110,14 +2199,18 @@ trackedFiles: id: 0e71f886891a last_write_checksum: sha1:e725694ea7c6bfd2e5a9537bbbd82277870865c0 pristine_git_object: 7b1f7773116909a2dca69809da34861a2fe93ad0 + docs/models/list-entities-subscription-scope.md: + id: c3e1281bd134 + last_write_checksum: sha1:ecee6371a005ea170eea3b5dcf2f413aaaa21526 + pristine_git_object: e4c8f98b853170f5c70c10501a43cb7a92a3469d docs/models/list-entities-subscription-status.md: id: 561f59e2f37c last_write_checksum: sha1:d36e3f7e9de8b140456685b8f845cdafdf8b407a pristine_git_object: 2d0541f779037245dd3a7566858c0111eb30cf8b docs/models/list-entities-subscription.md: id: 4f46852f4f99 - last_write_checksum: sha1:e456ac303612ec501bfb245e04978ee7701276fc - pristine_git_object: bbadd66b828a1c627673c51202484464a73328c1 + last_write_checksum: sha1:136c2ad40a08c4570628023dc9a9d9e42cdc70ad + pristine_git_object: 6a812a83f03ca73df45dac3c9828d3ba7cdb0567 docs/models/list-entities-threshold-type.md: id: 8ce164bea01f last_write_checksum: sha1:7e7db738852d7a3f94313d9ae1d5b74ecf24b58d @@ -2356,8 +2449,8 @@ trackedFiles: pristine_git_object: 6f7b2e7685e02280d4ed4fb1e988b6101060d637 docs/models/multi-attach-invoice-mode.md: id: fbc780e49711 - last_write_checksum: sha1:4145730fd2c9b2f56de7b17d12bf0aee37eb21cf - pristine_git_object: a87ed88f13c58a4569d8e7b259b0fb458ffa9563 + last_write_checksum: sha1:06e1960f10a20e90ff3903d3cda7b8bfa8df0c13 + pristine_git_object: b00877616dcb8bfebf72be4e8822df7fb7b1882e docs/models/multi-attach-invoice.md: id: 2d7bd760c6e9 last_write_checksum: sha1:47226d6562b0fcdc5f8ec2dfd260e880d4e969e1 @@ -2680,8 +2773,8 @@ trackedFiles: pristine_git_object: a03f108145870d0d046d27b082ef6fdf0179283f docs/models/preview-attach-invoice-mode.md: id: 52be741aaa35 - last_write_checksum: sha1:aa2e16f83e6f12f781c200d6c0e39775d8c8184e - pristine_git_object: 97e52602f6ae11592f38d3949f781e685554d67c + last_write_checksum: sha1:2aae501d36d784fe6791b68f86b6899277afc4e2 + pristine_git_object: 7fc29f18c0baac67eff54b1cd1aa72198e75b476 docs/models/preview-attach-item-billing-method.md: id: 9be5f190af7e last_write_checksum: sha1:713c8b3118aef02b505bfb75ddf112ee7a83de18 @@ -2888,8 +2981,8 @@ trackedFiles: pristine_git_object: f4bff4a6bb97ca047203ecc11c939af6e1b5deef docs/models/preview-multi-attach-invoice-mode.md: id: 3b06c211aa91 - last_write_checksum: sha1:409db019762a74dba3c766d3b08e1a26f0914862 - pristine_git_object: 058f60c61f3b7819e4720964b61087d05a401642 + last_write_checksum: sha1:e4b6f294de6dc5d8607f1ce554d9d9b8f0fa1061 + pristine_git_object: 9a550a30180b9761f48f7dcf20aa5be05e1e5036 docs/models/preview-multi-attach-item-price-interval.md: id: 854f9244bd9c last_write_checksum: sha1:91d806f61f0ad8845e617b2fff87f84a5d39fa39 @@ -3136,8 +3229,8 @@ trackedFiles: pristine_git_object: 446a9fc9e46bf49b0189e24bedf47519ea35c91f docs/models/preview-update-invoice-mode.md: id: 50a22a368768 - last_write_checksum: sha1:2753fa2eb31780aac9b3daff7744a571c290e59b - pristine_git_object: 2cec59e4e1670db77a61e68ec7b243c8fcf66ae2 + last_write_checksum: sha1:a8426a3004bc126607edaa7cb78e27c03afe3fe7 + pristine_git_object: f5ef7e322ad82d52ed818a3bd5a14cd212247df9 docs/models/preview-update-item-billing-method.md: id: f81f7a872c9c last_write_checksum: sha1:67a5b21a22b1bb527fcd9955c93cc0423dddde4a @@ -3284,12 +3377,12 @@ trackedFiles: pristine_git_object: f62b42a1a7d41787d477f99ef1702a735196a400 docs/models/preview1.md: id: 203e34d3c393 - last_write_checksum: sha1:baa476c6f9eca778b764c559b11eb985d9396c46 - pristine_git_object: 6b9852fd043a11b17349ff52cbdafb537ac867e0 + last_write_checksum: sha1:4da79d0ac7f4d510c2455484f81cd167f5aae690 + pristine_git_object: 64e7b72a04137b9dcc655a0f9287cca57559d11f docs/models/preview2.md: id: 96d6fae57a72 - last_write_checksum: sha1:728c176a1f265ca95c54c05ec4b62bb429b26aa2 - pristine_git_object: 3abb2cf65f13570a222275a9b2c7c38be94d5f81 + last_write_checksum: sha1:25a6b7488d3f6054be8a4ac5e709f25172c113f7 + pristine_git_object: ab4ed7ae6c9b76470f8e19fc109c6fc3da27eb1f docs/models/processor-type.md: id: 4e1ee9632454 last_write_checksum: sha1:e5c091efb3ac5d256119b589918fb9e3fd91b92f @@ -3322,14 +3415,6 @@ trackedFiles: id: c75d4dab1916 last_write_checksum: sha1:0e5d9f554d0ab6ebd5073b00b14d5eddaf42c825 pristine_git_object: 5add6b6648df42f5dd60678e644fec5a3f2626f4 - docs/models/product1.md: - id: 880ca8ae9886 - last_write_checksum: sha1:9253388ef4ce974184b84ebeb012d18defed9e21 - pristine_git_object: 67ec68bcf882341e97e4575429c1656c5a5bbafa - docs/models/product2.md: - id: 6262b044d234 - last_write_checksum: sha1:eba731a0429ff4824f8e658bc87b6b9047ff4b3a - pristine_git_object: 0a1d5f6fda8ab96d8987a5617bb55fd3882ffbd4 docs/models/properties1.md: id: d1dd750f2ed3 last_write_checksum: sha1:2336ba133059be249159bdcd6a7981282ad69796 @@ -3338,10 +3423,14 @@ trackedFiles: id: 3ada091965bd last_write_checksum: sha1:aa9bfb0870b6f8161503b9fe62408dcba1e571cd pristine_git_object: e72588a57659e03f689537809123070a75c8c341 + docs/models/purchase-scope.md: + id: 9a43b9a0b720 + last_write_checksum: sha1:86681f95beabef4a8066264bb292249ab8847db1 + pristine_git_object: 6f995b0e57e70187e14773111047d90e901ac4ee docs/models/purchase.md: id: f872769b6939 - last_write_checksum: sha1:f478ae26fe728efde7d8e38ce46593dab6d5f9d0 - pristine_git_object: bb9d83c959fbb7198e943506a2fae66a0e0f45b8 + last_write_checksum: sha1:c20a135970209050439012fb286bbcf14a0004a2 + pristine_git_object: 43a04ebc4c8314a9914a2efb5bdab0263eae191e docs/models/range.md: id: 0cae0c76762e last_write_checksum: sha1:05ccea3be76092b640e73a465cdd61ca0b8f6606 @@ -3382,6 +3471,10 @@ trackedFiles: id: c8db17c466f9 last_write_checksum: sha1:2d2515519b8dc6b4ce1b5d663e73307c775732c3 pristine_git_object: 4f54fb31462275086e474968960b97b1f2ad877d + docs/models/result.md: + id: b850437752c3 + last_write_checksum: sha1:efb16a202bfd987b865e4f7dc312954a0d21a000 + pristine_git_object: d9d7b5b15d8221e0f6d95a7a6f434568fa8c2240 docs/models/revenuecat.md: id: 5418b6373a80 last_write_checksum: sha1:70e79f733d444a07f8d4e770469e87bdf4a3d946 @@ -3586,14 +3679,54 @@ trackedFiles: id: 9706acdb1f5d last_write_checksum: sha1:e11936ae1dd8e969bcbe07a7b69ec433da66041c pristine_git_object: 8167bcd124d1a988869aa0b277f3c2ce7d2a7784 + docs/models/store-push.md: + id: 368a0872dffb + last_write_checksum: sha1:46e3a5598b870c4dae584c3e9e02913cf2a41096 + pristine_git_object: d590d604b6395e0340a510a23fb177e98ab4f8cb docs/models/stripe.md: id: ef8fa4c7fedd last_write_checksum: sha1:344040d40ef640447f1762e50bb8b86c55b78087 pristine_git_object: d6c2444de9cd9b1c4d6a1c761e98b41bfc82716c + docs/models/subscription-scope.md: + id: abccf765d9bb + last_write_checksum: sha1:f19dfaacdf6985bf226c0bdda75d63db43055d96 + pristine_git_object: 54caaf2acead58c4bf48243c0e3b0b824af1812d docs/models/subscription.md: id: 4a200793e0f4 - last_write_checksum: sha1:5c480288249c61f09f81027e42fd453b15ed4f37 - pristine_git_object: a0cdb72c8c3c2f6dec8070c3a91f6ea496966d3f + last_write_checksum: sha1:6296413c9481ca2a0ce2e6cc3975292efe9c613b + pristine_git_object: ada66d89888fcfc5e0bd712ba37e271cd278fbd9 + docs/models/sync-revenue-cat-app.md: + id: af68876c4d0c + last_write_checksum: sha1:dbb71b010e8dc4d4ae83791d661d7c4742c65812 + pristine_git_object: 202f3f4146446b29250e6d5a2a8ac51dd82c06bc + docs/models/sync-revenue-cat-env.md: + id: 5b8b7785ab8e + last_write_checksum: sha1:fc268634d9cc547c8d7bb0b100c3178da3d02cfe + pristine_git_object: 7268b9b55c92cc680531015bd25c526f33b136d4 + docs/models/sync-revenue-cat-globals.md: + id: eacabe894f64 + last_write_checksum: sha1:7259d36665c402542d0fa4a69ea0b906c082d142 + pristine_git_object: 9020c46603d47d2e3c93bf79c78b4a2f61a7ed9d + docs/models/sync-revenue-cat-params.md: + id: b0fed933a28a + last_write_checksum: sha1:e3b6bec5943c7c8111993e7080db06c7af52090f + pristine_git_object: 6fead8cadbfa7f6af37480d1f8197d6c1fc5f0b2 + docs/models/sync-revenue-cat-price.md: + id: 9e6732387f49 + last_write_checksum: sha1:cca7e269586c0e1f28041acadff318664829a9ef + pristine_git_object: fafbf8c29d8c6ac7c7ddfaf9ee90c863da273026 + docs/models/sync-revenue-cat-product.md: + id: a722198b8468 + last_write_checksum: sha1:389a69f1b61d7179c63fb7e7f87269b9e15a3c0f + pristine_git_object: 1069042a078dea9e7e8ef32e564428a3cde0f41e + docs/models/sync-revenue-cat-response.md: + id: 033dce5b01bf + last_write_checksum: sha1:532a509b85652f8ad8564c713a801bcebec3b819 + pristine_git_object: 14c33612c79a8eb8d661bbdeaa473ed703ee8c69 + docs/models/sync-revenue-cat-status.md: + id: c940a544edd1 + last_write_checksum: sha1:aec948fabbb6eb7529cadd8df2680f875ed80af1 + pristine_git_object: 4440b0dca17123cb54413ecc5241ef0571c8219c docs/models/total.md: id: f4060c3b4657 last_write_checksum: sha1:db27b4c0beb158424465eff3298baec188ae6bee @@ -3758,10 +3891,14 @@ trackedFiles: id: fadfa8e475db last_write_checksum: sha1:d50abe7e6d1ec282b8a456ebdd6529e2a5dfa425 pristine_git_object: ee1cc2259d1a79c964de53230cffc1760ce61daf + docs/models/update-customer-purchase-scope.md: + id: 1ef16e622261 + last_write_checksum: sha1:92c7627f1ede9ac1d7419e256df09e19031d7d3c + pristine_git_object: bc33d86bec63834eb3335645132c653918da73f0 docs/models/update-customer-purchase.md: id: b4c8c530ba2c - last_write_checksum: sha1:54dd9c3527cc1cc4c6a2018ad2f82c8eb64c7f2a - pristine_git_object: d72d4217209d4bbe577bbc5be5b13f276268b19a + last_write_checksum: sha1:51eb9ea16fc3118d191fd04b314c2a2eb30e3d0a + pristine_git_object: 6836fcf4ce0aab167dcff478e84d780097f3474a docs/models/update-customer-response.md: id: 605a24a7d121 last_write_checksum: sha1:b609243348cefe16f3757a690c13e693f85dbcde @@ -3786,10 +3923,14 @@ trackedFiles: id: f4aad0f18c09 last_write_checksum: sha1:7915f7fde15fdf9ca4ff321710b4de6f573aadd3 pristine_git_object: c375e2054c07eb91679ea5178cfa759ceb6f2382 + docs/models/update-customer-subscription-scope.md: + id: b1b11407edf5 + last_write_checksum: sha1:26e0637f3d5335076c52babbefc001e1a3be73ef + pristine_git_object: 2eaaf31f269a8735b9cb9a8426ccea1bf9906b54 docs/models/update-customer-subscription.md: id: "412049526229" - last_write_checksum: sha1:14b670d3f09bd42b9288c24e8ddc440b5e2692ae - pristine_git_object: 61190d55755f7d4d25d433defd47bc3ed6a0b3a4 + last_write_checksum: sha1:7c63664945c84ddb2db57e6b6cc85bfdcfb82a85 + pristine_git_object: 717a3c96c66a4b56b3fdea6be3cde8ff0dbcf420 docs/models/update-customer-threshold-type-request-body.md: id: c38d53770bd0 last_write_checksum: sha1:e3b35234990f26fa0522a7b2f51c3a691453e5b7 @@ -3866,10 +4007,14 @@ trackedFiles: id: 4348a3cfe101 last_write_checksum: sha1:151501cfd35be4e0b1071f3a01e1f35e66fbbfda pristine_git_object: 243e8dddc125708fc53ed0ade38080b365a11381 + docs/models/update-entity-purchase-scope.md: + id: 6f100109f8de + last_write_checksum: sha1:d7185182020c302c0d37cddb07708e7d5c6d73c9 + pristine_git_object: a491734daf5bdc3079b4bda44b0d99049d99c49e docs/models/update-entity-purchase.md: id: 26bb5ab6230b - last_write_checksum: sha1:235f34b7e8e1661d4e8503606e171d98befb4c7d - pristine_git_object: 8b6bdff244a23c9d862a512c25a492aec455a1a4 + last_write_checksum: sha1:dbbd2f0931adba2ab488a78f2b3b0cde8d743ed7 + pristine_git_object: ac619630fde3eb403a536d94dbf76ba9bbf65263 docs/models/update-entity-response.md: id: 815ffce950af last_write_checksum: sha1:2a4497f89d82fab0e92c52b40d69e062a12f46df @@ -3886,10 +4031,14 @@ trackedFiles: id: a123286977c4 last_write_checksum: sha1:3b260455837c095923e54a862f811e23fc09e75e pristine_git_object: 0a022e6eb0c5caf8ec0020f2b8778c0ac5f41cbf + docs/models/update-entity-subscription-scope.md: + id: 6e8b6a69976c + last_write_checksum: sha1:142b90a0f4b5d1a6e5b7244f1c4104142a1cc0aa + pristine_git_object: 365dbabe759f339dad69d30290febb585ff7b730 docs/models/update-entity-subscription.md: id: 730dc25c840f - last_write_checksum: sha1:137d384d2d4751c301eb6c0c09a50f4dee9a4fc9 - pristine_git_object: cd975f24e8e7d76b803aeff4d46fc0c22ea780ee + last_write_checksum: sha1:3ba399657e71fcb08c765144777beb55384302b8 + pristine_git_object: 0d2fc13f76f39a6948fe8d5f925ff4877cc4793b docs/models/update-entity-threshold-type-request-body.md: id: 87b1d9fc5d5d last_write_checksum: sha1:a3443c89910d683be504f81c1b9b3fea64306973 @@ -4164,8 +4313,8 @@ trackedFiles: pristine_git_object: 0ebe5146cd24c153cb9b7655e4f502c09f7d4abd docs/sdks/billing/README.md: id: dc915331dd9d - last_write_checksum: sha1:5423d957ca1402f6a9d9f0097c6e3f3eb0b23259 - pristine_git_object: acafdb77810207e5d9ba9f8d1df82014e4c42435 + last_write_checksum: sha1:a29da461dcad1fea4fca629be432ee960ffa6cbf + pristine_git_object: c07f3e93c879598d44b58834323533c6f1897aef docs/sdks/customers/README.md: id: 9332759cffc2 last_write_checksum: sha1:74cd5f6cf800e1d86b2c332fed3c3cd53f3eeb6b @@ -4186,6 +4335,10 @@ trackedFiles: id: 2d8c741fff57 last_write_checksum: sha1:57e57bb309355ca9bd404327d90d5ec43e26c6da pristine_git_object: b74f515e33be0c7a50ffbdca8f70a015dde8bf06 + docs/sdks/platform/README.md: + id: b66219e9cd4d + last_write_checksum: sha1:2b78aeff4d3c1023b1c461eba75a15fe864c04f9 + pristine_git_object: a699333506973bbe1cf50a094e48ed30d5a52d9a docs/sdks/referrals/README.md: id: 50b71f597f20 last_write_checksum: sha1:b10fdf64bd7d821c93d721f1ef481f045e046438 @@ -4252,8 +4405,8 @@ trackedFiles: pristine_git_object: d1d2c39eb61de5da6dc66da31605995ee35edd8b src/funcs/billing-create-schedule.ts: id: fd662bfcdc10 - last_write_checksum: sha1:3d7f3310fcb097be88ffcffd6fd665a03de020eb - pristine_git_object: 2be231df771eeeda8cd81e80be7c08800ec8cc1b + last_write_checksum: sha1:a11b4f10543ae07e6dc84797e6ce0789ec0351ae + pristine_git_object: cb81459b84bdf23fd77e3fdb6b499e8258c4929c src/funcs/billing-multi-attach.ts: id: 67491e2d8249 last_write_checksum: sha1:00ba80c1f98e7a8be29db0cf5a6957433f687861 @@ -4374,6 +4527,18 @@ trackedFiles: id: 86e469e08973 last_write_checksum: sha1:3a7e13fcd2455ee3e6841b4829e50f5b9ea1fcb4 pristine_git_object: 4c6bbef3d3c4078f2df79d353d8071451f602ba0 + src/funcs/platform-get-revenue-cat-keys.ts: + id: 3f6df7d12152 + last_write_checksum: sha1:ea7b7e7d13cec4a7dc9dfb20431bc67e91f6efb6 + pristine_git_object: 7cbda750834f8f08674bec0a634512e0a8371319 + src/funcs/platform-link-revenue-cat.ts: + id: 8f8f215b8cf9 + last_write_checksum: sha1:4fda8ddda1ef317eb44ee94ec61ccc4686144450 + pristine_git_object: 831bad4585f7918ea27524a68152e3cec2a6c204 + src/funcs/platform-sync-revenue-cat.ts: + id: 6d5ff105f444 + last_write_checksum: sha1:d41d0c72ab12a834ce3384200513758c53dba517 + pristine_git_object: cbf471baac869b86393dced84b2e02f57bdf80ff src/funcs/referrals-create-code.ts: id: f2088dbf847d last_write_checksum: sha1:2c980d8a56a6b8b7a15023cea02b9899454807be @@ -4476,8 +4641,8 @@ trackedFiles: pristine_git_object: 56dab308d7f8e1694b20a8e0509ecfa0b4149743 src/models/attach-op.ts: id: 83ed65c26ab4 - last_write_checksum: sha1:2cea09fc75a985eb5b7b5cf01bf81b3587e7aa7e - pristine_git_object: 81daebe594e6391be3bc16cc3d87f0ae909a8adb + last_write_checksum: sha1:283141753e7fb5a3be8596ba32e402bde38c96ec + pristine_git_object: 8594c479a2e40f632a9add5894e921e63901b0c0 src/models/autumn-default-error.ts: id: 2528aa7886eb last_write_checksum: sha1:4cce18f91be3262ada7d11dcd6326544e2341b58 @@ -4496,20 +4661,20 @@ trackedFiles: pristine_git_object: e3fa7ed778c7829d115c8a51f03df4618c9bf8f5 src/models/billing-update-op.ts: id: e7371769c7ca - last_write_checksum: sha1:c441192ab14334dacdaade3f77dc20f1c4482241 - pristine_git_object: ff2ea202928d3208ad6c18d911b0d794b60a58af + last_write_checksum: sha1:191dd8f7961225374c4cbbd4628707f059a4ae36 + pristine_git_object: 53ddec09ce985548f2425cd7298b7f46a5d33249 src/models/check-op.ts: id: 42085bda016a - last_write_checksum: sha1:dc22bb11dc8320f6196490df90a7ec600279ae18 - pristine_git_object: 4826a7e94a4a1831b344634dd60ed50deaf2e8ed + last_write_checksum: sha1:89080a0266713ae7e46a401322f538874539e7e8 + pristine_git_object: b6c962e140cdc5f81795a593008095b4a2e3d002 src/models/create-balance-op.ts: id: 537b8ff86863 last_write_checksum: sha1:4d14f12804833140651eb101cef96b9305b45164 pristine_git_object: 2fec0816c38ab033517bba45ebf7caf679109bfd src/models/create-entity-op.ts: id: 9ad8367048a1 - last_write_checksum: sha1:bb2ccafb233e594406981233b52fe007d020ed77 - pristine_git_object: cc77be59f16193db3649864901d31ffd319c5a98 + last_write_checksum: sha1:051d8b268ec0d4a99675ba43802b52af553e4802 + pristine_git_object: 712cf74bd7ae783d74d149866970054b6674803a src/models/create-feature-op.ts: id: 06f0161d677b last_write_checksum: sha1:2343ab517f362b3eaaa65e9039cec647ac497111 @@ -4524,8 +4689,8 @@ trackedFiles: pristine_git_object: d979198ac227f8e5731e5ca5e2e34b55d88da348 src/models/create-schedule-op.ts: id: 68442c0abd75 - last_write_checksum: sha1:b328b3663568c81bbeca87132046a6480ca20ffa - pristine_git_object: 9539557fa8cd2518a6d10f27344439d67077055e + last_write_checksum: sha1:e1f55a32e6b44b7a49fce2b3597b88135a091b38 + pristine_git_object: 23921226d0eb02f47e774f680c89a94501184c78 src/models/customer-data.ts: id: 04dac7ee392e last_write_checksum: sha1:be3567d013982afb9add249f49c8743a28756c5c @@ -4536,8 +4701,8 @@ trackedFiles: pristine_git_object: be207b832d8666ef05a4085ca1cac7634c7ef43d src/models/customer.ts: id: 20be78c552a4 - last_write_checksum: sha1:f5f3bcf28131c29886b53814c23e8e7dba523de8 - pristine_git_object: 650544ad28c549e78eb401913e9d8dcbf0a249e2 + last_write_checksum: sha1:23e6124d81ad4231fc133746d23e7b1fbca1cb05 + pristine_git_object: 80b661b2e4e30e4415fbc09250c6a5d537e6ec0c src/models/delete-balance-op.ts: id: ea84d6bda9c3 last_write_checksum: sha1:0b32332d27f57283623ad915e603de4529f49c02 @@ -4564,12 +4729,12 @@ trackedFiles: pristine_git_object: 893139dc0b5a70fb74322e31c48a75b1fd095a77 src/models/get-customer-op.ts: id: fe8daa5a7d99 - last_write_checksum: sha1:73207acb2eaf266d4b6d7fb4c34ef8fd8d3547c3 - pristine_git_object: abe8158e356f579abca77a3ee309c44317459c64 + last_write_checksum: sha1:668b1e89e7b672c5f138914d211dbee446e48085 + pristine_git_object: 35f1dee9139f84add44aae4ee85c869c319381ca src/models/get-entity-op.ts: id: 7932a3cea5c1 - last_write_checksum: sha1:bd39f50a271e9b13724248a1e75a1643b77ad049 - pristine_git_object: 51e278c5ee0ce403b465c62a79ae38e2bb528113 + last_write_checksum: sha1:3c10cfac7edd8c310e4982d27c13e641a93d1e4b + pristine_git_object: 38052f3bf5ed8e67e3c231bad2cc5e6c888f7f56 src/models/get-feature-op.ts: id: a820efa3e08d last_write_checksum: sha1:e42d99dba37a2380e1e9ed4afe9b69460598f578 @@ -4582,22 +4747,30 @@ trackedFiles: id: 91c8f8dda7c8 last_write_checksum: sha1:bce81a8cef1f6bf579185174603c9c414a26b258 pristine_git_object: 43ea5f27ac16bea209d71a03ad517a798fd05c5e + src/models/get-revenue-cat-keys-op.ts: + id: 2d8e9e87f071 + last_write_checksum: sha1:e1c1f2eaf75a3db81b4800c63cbe2ea6e5b1fa56 + pristine_git_object: e52b5aaf5f34339dcfa8913a8471efb4c2e3ff7e src/models/http-client-errors.ts: id: 5f17dcf0d62b last_write_checksum: sha1:994ced121c54fecd0af038ccfb7855fbfd3868ec pristine_git_object: b34f612124c797c2a1106b9735708f679a90b74f src/models/index.ts: id: f93644b0f37e - last_write_checksum: sha1:99b491a94a7a8810c6916539ef699036680e132c - pristine_git_object: e63734a484c768b68cce9cc70f4886c577840340 + last_write_checksum: sha1:2c57b1fdb9734c9ccb0f127da60d510b6d4a164a + pristine_git_object: 9c3a0afcd755cc0dcc073565280651597367188f + src/models/link-revenue-cat-op.ts: + id: 6cc62c90b574 + last_write_checksum: sha1:a2f11a5efb037c6c640da26f07b2a407b7dfffad + pristine_git_object: 2f5338db9bdf89719dab6c323e46c6bde381246d src/models/list-customers-op.ts: id: b391692c8429 - last_write_checksum: sha1:0b5806bf37d88bbad002d82bf404d5f3b8762593 - pristine_git_object: 49c50334b82a7d58677307d3791be2ced19c8a06 + last_write_checksum: sha1:2b1ab0d5e34f41d91a1d92ac4abd87ec54596bd1 + pristine_git_object: 7f4377171a1cad0e1e90e46770381c5bec2afc56 src/models/list-entities-op.ts: id: 4cbb69f4a0cd - last_write_checksum: sha1:a48ac579bc008f7a3100664b4dfe0a4803a5650e - pristine_git_object: 298ba3d842c166c29339394e9b8173cefc500431 + last_write_checksum: sha1:b7c8086e33de50fcacf18501237486e12a8158a1 + pristine_git_object: eedb101e43e1f9c51e9b7c01efd66e62a4da9e80 src/models/list-events-op.ts: id: 82a9f364bb21 last_write_checksum: sha1:32c875df2a181a5aa651f4350a2a7114a8c92bd1 @@ -4612,8 +4785,8 @@ trackedFiles: pristine_git_object: bd00d1a7696f58fe1cb085d900bee000ed3bfdfd src/models/multi-attach-op.ts: id: 99a2b77c1afc - last_write_checksum: sha1:79ddc2b02f07e4ff6c548583648bc85f41bc82ee - pristine_git_object: ffadc4dc40f6a7630adff95b71f7139cbf058107 + last_write_checksum: sha1:51625deba18179175ff7d924920417c6c2fe4b8d + pristine_git_object: 38d6d34172a83ce01ffdd3f4d652c8e56c720f74 src/models/open-customer-portal-op.ts: id: a003eb4172a9 last_write_checksum: sha1:5e672fc975a0336c963181042a770c2a43cbc0e3 @@ -4624,16 +4797,16 @@ trackedFiles: pristine_git_object: edba9d825d88c26f3322be98e78d37ffd2699f99 src/models/preview-attach-op.ts: id: 3efc6e3443a7 - last_write_checksum: sha1:082165441c5b9cab621986bf421c2a3d896eec96 - pristine_git_object: 322a2a97e447cb996821f8460633c6ec73ceb8bd + last_write_checksum: sha1:2d267bd64f1069e310815cda47a224bd39832aa1 + pristine_git_object: 969faf47c1a15210c4c2bbf5817bd1a85e4c1647 src/models/preview-multi-attach-op.ts: id: e4847dc281a6 - last_write_checksum: sha1:9ed03201b7c46f70fcb595c034ca9c2fa572a948 - pristine_git_object: 14d6547dde8246cfe212177717cd91af13b8570b + last_write_checksum: sha1:0f9c185815e3de7f7705d5c498e351acd74bcebd + pristine_git_object: a0be451fab836ea3c2e6be68d8a0cef79a7a4d4e src/models/preview-update-op.ts: id: fcbbbf3b22ac - last_write_checksum: sha1:7b587a2c1fd03bd76dbc3e15b13d642894cc1d73 - pristine_git_object: d9157676429d2b3cc19a15a8ec8b7c8c0c3b8c83 + last_write_checksum: sha1:cb54804b85295a4d132869d9ba99cc84165ef761 + pristine_git_object: 279424e24e58bdeb5ae76380ea4b4c0d31c7f7b2 src/models/redeem-referral-code-op.ts: id: 511bf73dc4c6 last_write_checksum: sha1:9ab6622018c82175ea98d2b26eadb4abf08f441a @@ -4658,6 +4831,10 @@ trackedFiles: id: 0e97e999ff3c last_write_checksum: sha1:c09cf100a8eaedfc307aa7037081eece0db24d82 pristine_git_object: 888b6921e5d7f7526d33f3dfc3a5c4eceb93e55b + src/models/sync-revenue-cat-op.ts: + id: bf3c25067f7c + last_write_checksum: sha1:a5992af2f44badc20be2d55c8091ed4c22dfc903 + pristine_git_object: 922cf35b3f602cbd31665e4656ab11caedacafb8 src/models/track-op.ts: id: 5e6a750e8fec last_write_checksum: sha1:b4ccb3514075bcb1b67df61bdbe52154c46e21b2 @@ -4668,12 +4845,12 @@ trackedFiles: pristine_git_object: bfa2a0d29be530ccbf883a563399b35712623642 src/models/update-customer-op.ts: id: 5d226d30d8e4 - last_write_checksum: sha1:4e18a9f3dc4a555e900f11d7df787b6f792c733b - pristine_git_object: 0eb88bdc91a010ef92d2860fcb5a4ee5e3aa55cb + last_write_checksum: sha1:16831408ad752fb0adadeaecc1a720d38c5746dd + pristine_git_object: 7357d58694971374b4bf2554de091af5aef52f77 src/models/update-entity-op.ts: id: c3fdb6479f02 - last_write_checksum: sha1:f495b66c48eab7e6f020a62dc1f7ad67b7ffcafc - pristine_git_object: 701755fbecf443373987622ba5c897e29db39d3c + last_write_checksum: sha1:5807a9f00257b0882601f14bd6456c03bae9c88d + pristine_git_object: 29b3ae109315e2ea45621eccaf06c50193980b4f src/models/update-feature-op.ts: id: 7c27d245784e last_write_checksum: sha1:fca4d29e843ff678c6f85258ef40668a46da5e42 @@ -4688,8 +4865,8 @@ trackedFiles: pristine_git_object: 571de419ea3321d79acec4bddbb46b1580007115 src/sdk/billing.ts: id: 10905058c4ad - last_write_checksum: sha1:e688a0ee91f993a4e34f714ce6cd983194c94ee6 - pristine_git_object: 060ff391aa40a75d4c2f19b24ca4645d6e6fe140 + last_write_checksum: sha1:9e9b653cd84c96cbd9b80540a2800b113025d4d4 + pristine_git_object: 199d000c7899a69863745c75661b6137acfbf9fd src/sdk/customers.ts: id: d33e193e0c00 last_write_checksum: sha1:8d64f03efa17b4ef45a6d67a44d23e2943f1cd8b @@ -4714,6 +4891,10 @@ trackedFiles: id: c0cb8188cdc1 last_write_checksum: sha1:62ee50f030050dae85c77a2f877b2471970f29d6 pristine_git_object: 521774c3587bff53d05bf80accc45eb9ccb926ac + src/sdk/platform.ts: + id: 86d659f7229d + last_write_checksum: sha1:831299efdeefe09a98910994e31c0ff992f3915f + pristine_git_object: 43d8c9737a11477ceff5f0951fb3e5d60670d55f src/sdk/referrals.ts: id: bf164167845c last_write_checksum: sha1:b73c1db6a419f5c7f6643382d5bc399204e95150 @@ -4724,8 +4905,8 @@ trackedFiles: pristine_git_object: f6a3928ecbf5b6a9907bc7d405808d0839848a04 src/sdk/sdk.ts: id: 784571af2f69 - last_write_checksum: sha1:91b52ea99e9a7b641d6c66d525961a174bd258ed - pristine_git_object: 2b0197cb19c31124a3b1cad3a38dec70d3856159 + last_write_checksum: sha1:33274a26041219427b3477c52c8bd100f08ba630 + pristine_git_object: da01a652b7e0ec3221579c07c66bab56ff49016b src/types/async.ts: id: fac8da972f86 last_write_checksum: sha1:3ff07b3feaf390ec1aeb18ff938e139c6c4a9585 @@ -5638,4 +5819,34 @@ examples: responses: "202": application/json: {"success": true} + linkRevenueCat: + speakeasy-default-link-revenue-cat: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"organization_slug": "acme", "env": "test", "project_name": "acme-mobile", "redirect_url": "https://dashboard.useautumn.com/dev?tab=revenuecat"} + responses: + "200": + application/json: {"oauth_url": "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write"} + syncRevenueCat: + speakeasy-default-sync-revenue-cat: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"organization_slug": "acme", "env": "test", "product_ids": ["pro", "premium"]} + responses: + "200": + application/json: {"results": [{"plan_id": "pro", "status": "synced", "store_identifier": "autumn.sandbox.org_123.pro", "apps": [{"app_id": "app_test", "app_type": "test_store", "product": "created", "store_push": "skipped", "price": "set"}]}]} + getRevenueCatKeys: + speakeasy-default-get-revenue-cat-keys: + parameters: + header: + x-api-version: "2.3.0" + requestBody: + application/json: {"organization_slug": "acme", "env": "test"} + responses: + "200": + application/json: {"apps": [{"app_id": "app1a2b3c4d", "app_type": "test_store", "name": "Acme (Test Store)", "api_keys": [{"id": "apikey12345", "key": "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ", "environment": "production", "app_id": "app1a2b3c4"}]}], "oauth_access_token": "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ"} examplesVersion: 1.0.2 diff --git a/packages/sdk/.speakeasy/out.openapi.yaml b/packages/sdk/.speakeasy/out.openapi.yaml index c5d4172a4..fa093fefe 100644 --- a/packages/sdk/.speakeasy/out.openapi.yaml +++ b/packages/sdk/.speakeasy/out.openapi.yaml @@ -456,6 +456,12 @@ components: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or entity level. required: - id - plan_id @@ -493,6 +499,12 @@ components: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity level. required: - plan_id - expires_at @@ -2138,6 +2150,12 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or entity level. required: - id - plan_id @@ -2175,6 +2193,12 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity level. required: - plan_id - expires_at @@ -2951,6 +2975,12 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or entity level. required: - id - plan_id @@ -2988,6 +3018,12 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity level. required: - plan_id - expires_at @@ -3742,6 +3778,12 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or entity level. required: - id - plan_id @@ -3779,6 +3821,12 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity level. required: - plan_id - expires_at @@ -7798,6 +7846,15 @@ paths: type: boolean default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the customer, instead of charging their card immediately. This uses Stripe's send_invoice collection method. @@ -8017,12 +8074,13 @@ paths: @example ```typescript // Schedule a transition from a trial plan to a paid plan - const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1779977746466,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781187346466,"plans":[{"planId":"pro_plan"}]}] }); + const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @param entityId - Optional entity ID for an entity-scoped schedule. (optional) @param invoiceMode - Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. (optional) + @param discounts - List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. (optional) @param successUrl - URL to redirect to after successful checkout. (optional) @param checkoutSessionParams - Additional parameters to pass into the creation of the Stripe checkout session. (optional) @param redirectMode - Controls when to return a checkout URL for the immediate phase. 'always' forces a confirmation or checkout flow, 'if_required' only redirects when needed, and 'never' disables redirects. (optional) @@ -8061,9 +8119,32 @@ paths: type: boolean default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). required: - enabled description: Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. + discounts: + type: array + items: + type: object + properties: + reward_id: + type: string + description: The ID of the reward to apply as a discount. + promotion_code: + type: string + description: The promotion code to apply as a discount. + title: AttachDiscount + description: A discount to apply. Can be either a reward ID or a promotion code. + description: List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. success_url: type: string description: URL to redirect to after successful checkout. @@ -8978,6 +9059,15 @@ paths: type: boolean default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the customer, instead of charging their card immediately. @@ -9697,6 +9787,15 @@ paths: type: boolean default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the customer, instead of charging their card immediately. This uses Stripe's send_invoice collection method. @@ -10514,6 +10613,15 @@ paths: type: boolean default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the customer, instead of charging their card immediately. @@ -11536,6 +11644,15 @@ paths: type: boolean default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the customer, instead of charging their card immediately. This uses Stripe's send_invoice collection method. @@ -12166,6 +12283,15 @@ paths: type: boolean default: true description: If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. + invoice_template_id: + type: string + description: ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + net_terms_days: + type: integer + minimum: -9.007199254740991e+15 + maximum: 9.007199254740991e+15 + exclusiveMinimum: 0 + description: Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). required: - enabled description: Invoice mode creates a draft or open invoice and sends it to the customer, instead of charging their card immediately. This uses Stripe's send_invoice collection method. @@ -15741,6 +15867,12 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or entity level. required: - id - plan_id @@ -15777,6 +15909,12 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity level. required: - plan_id - expires_at @@ -16210,6 +16348,12 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or entity level. required: - id - plan_id @@ -16246,6 +16390,12 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity level. required: - plan_id - expires_at @@ -16733,6 +16883,12 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or entity level. required: - id - plan_id @@ -16769,6 +16925,12 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity level. required: - plan_id - expires_at @@ -17265,6 +17427,12 @@ paths: quantity: type: number description: Number of units of this subscription (for per-seat plans). + scope: + enum: + - customer + - entity + type: string + description: Whether this subscription is attached at the customer level or entity level. required: - id - plan_id @@ -17301,6 +17469,12 @@ paths: quantity: type: number description: Number of units purchased. + scope: + enum: + - customer + - entity + type: string + description: Whether this purchase is attached at the customer level or entity level. required: - plan_id - expires_at @@ -17813,6 +17987,282 @@ paths: x-speakeasy-name-override: redeemCode parameters: - *a1 + /v1/platform.link_revenuecat: + post: + operationId: linkRevenueCat + description: Generate a RevenueCat OAuth URL for linking a project to an organization. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - live + type: string + project_name: + type: string + minLength: 1 + maxLength: 255 + redirect_url: + type: string + format: uri + required: + - organization_slug + - env + - project_name + - redirect_url + title: LinkRevenueCatParams + examples: + - organization_slug: acme + env: test + project_name: acme-mobile + redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + oauth_url: + type: string + required: + - oauth_url + title: LinkRevenueCatResponse + examples: + - oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write + x-speakeasy-name-override: linkRevenueCat + parameters: + - *a1 + /v1/platform.sync_revenuecat: + post: + operationId: syncRevenueCat + description: Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - sandbox + - live + type: string + description: '"test" and "sandbox" both target the sandbox environment' + product_ids: + type: array + items: + type: string + description: Plans to push. Omit to sync every plan in the org/env. + required: + - organization_slug + - env + title: SyncRevenueCatParams + examples: + - organization_slug: acme + env: test + product_ids: + - pro + - premium + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + plan_id: + type: string + status: + enum: + - synced + - skipped + - error + type: string + store_identifier: + type: string + apps: + type: array + items: + type: object + properties: + app_id: + type: string + app_type: + type: string + product: + enum: + - created + - updated + - exists + type: string + store_push: + enum: + - pushed + - failed + - skipped + type: string + price: + enum: + - set + - skipped + - failed + type: string + message: + type: string + required: + - app_id + - app_type + - product + message: + type: string + required: + - plan_id + - status + required: + - results + title: SyncRevenueCatResponse + examples: + - results: + - plan_id: pro + status: synced + store_identifier: autumn.sandbox.org_123.pro + apps: + - app_id: app_test + app_type: test_store + product: created + store_push: skipped + price: set + x-speakeasy-name-override: syncRevenueCat + parameters: + - *a1 + /v1/platform.get_revenuecat_keys: + post: + operationId: getRevenueCatKeys + description: Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app. + tags: + - platform + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization_slug: + type: string + minLength: 1 + env: + enum: + - test + - sandbox + - live + type: string + description: '"test" and "sandbox" both target the sandbox environment' + required: + - organization_slug + - env + title: GetRevenueCatKeysParams + examples: + - organization_slug: acme + env: test + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + apps: + type: array + items: + type: object + properties: + app_id: + type: string + app_type: + type: string + description: RevenueCat store type, e.g. test_store / app_store / play_store + name: + type: string + api_keys: + type: array + items: + type: object + properties: + id: + type: string + key: + type: string + description: The public SDK API key value + environment: + anyOf: + - type: string + - type: "null" + description: e.g. "production" / "sandbox" + app_id: + anyOf: + - type: string + - type: "null" + created_at: + type: number + required: + - id + - key + additionalProperties: {} + required: + - app_id + - app_type + - name + - api_keys + oauth_access_token: + anyOf: + - type: string + - type: "null" + description: Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token. + required: + - apps + - oauth_access_token + title: GetRevenueCatKeysResponse + examples: + - apps: + - app_id: app1a2b3c4d + app_type: test_store + name: Acme (Test Store) + api_keys: + - id: apikey12345 + key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ + environment: production + app_id: app1a2b3c4 + oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ + x-speakeasy-name-override: getRevenueCatKeys + parameters: + - *a1 security: - secretKey: [] x-speakeasy-globals: diff --git a/packages/sdk/.speakeasy/workflow.lock b/packages/sdk/.speakeasy/workflow.lock index 9accf92c4..4206d4998 100644 --- a/packages/sdk/.speakeasy/workflow.lock +++ b/packages/sdk/.speakeasy/workflow.lock @@ -2,8 +2,8 @@ speakeasyVersion: 1.762.0 sources: Autumn API: sourceNamespace: autumn-api - sourceRevisionDigest: sha256:547dd234014ff5ad38782138c19b3d1da53613c9161dad092bf14a8c84132020 - sourceBlobDigest: sha256:c7017b9c4d86350a4183e14f7d26175e3f1481a0ed4c8f8b7224cd55177b39f5 + sourceRevisionDigest: sha256:97e641e755554cf90f838e13470542faccdbc836d9a8415a522246943a07819f + sourceBlobDigest: sha256:425a64f39bb30d691f765bddb95b22e9a3314de759414098a0a0541fd234cdf0 tags: - latest - 2.3.0 @@ -18,10 +18,10 @@ targets: autumn: source: Autumn API sourceNamespace: autumn-api - sourceRevisionDigest: sha256:547dd234014ff5ad38782138c19b3d1da53613c9161dad092bf14a8c84132020 - sourceBlobDigest: sha256:c7017b9c4d86350a4183e14f7d26175e3f1481a0ed4c8f8b7224cd55177b39f5 + sourceRevisionDigest: sha256:97e641e755554cf90f838e13470542faccdbc836d9a8415a522246943a07819f + sourceBlobDigest: sha256:425a64f39bb30d691f765bddb95b22e9a3314de759414098a0a0541fd234cdf0 codeSamplesNamespace: autumn-api-typescript-code-samples - codeSamplesRevisionDigest: sha256:390a84592b93615e744ea5718a1e1b2558bdcd7de01261a10be301c224edddc2 + codeSamplesRevisionDigest: sha256:22aa9c48459b05a5c1d38bae40917768c567817c0c9ea554c77786e7f6794427 autumn-python: source: Autumn API Stripped sourceNamespace: autumn-api-stripped diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ab6d96b41..08cb82530 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -274,12 +274,13 @@ Use this endpoint to schedule future plan changes (e.g. switch from a trial plan @example ```typescript // Schedule a transition from a trial plan to a paid plan -const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1779977746466,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781187346466,"plans":[{"planId":"pro_plan"}]}] }); +const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @param entityId - Optional entity ID for an entity-scoped schedule. (optional) @param invoiceMode - Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. (optional) +@param discounts - List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. (optional) @param successUrl - URL to redirect to after successful checkout. (optional) @param checkoutSessionParams - Additional parameters to pass into the creation of the Stripe checkout session. (optional) @param redirectMode - Controls when to return a checkout URL for the immediate phase. 'always' forces a confirmation or checkout flow, 'if_required' only redirects when needed, and 'never' disables redirects. (optional) @@ -704,6 +705,12 @@ const response = await client.features.delete({ featureId: "old-feature" }); * [update](docs/sdks/plans/README.md#update) - Update a plan * [delete](docs/sdks/plans/README.md#delete) - Delete a plan +### [Platform](docs/sdks/platform/README.md) + +* [linkRevenueCat](docs/sdks/platform/README.md#linkrevenuecat) - Generate a RevenueCat OAuth URL for linking a project to an organization. +* [syncRevenueCat](docs/sdks/platform/README.md#syncrevenuecat) - Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth. +* [getRevenueCatKeys](docs/sdks/platform/README.md#getrevenuecatkeys) - Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app. + ### [Referrals](docs/sdks/referrals/README.md) * [createCode](docs/sdks/referrals/README.md#createcode) - Create or fetch a referral code for a customer in a referral program. @@ -793,12 +800,13 @@ Use this endpoint to schedule future plan changes (e.g. switch from a trial plan @example ```typescript // Schedule a transition from a trial plan to a paid plan -const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1779977746466,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781187346466,"plans":[{"planId":"pro_plan"}]}] }); +const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] }); ``` @param customerId - The ID of the customer to create the schedule for. @param entityId - Optional entity ID for an entity-scoped schedule. (optional) @param invoiceMode - Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. (optional) +@param discounts - List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. (optional) @param successUrl - URL to redirect to after successful checkout. (optional) @param checkoutSessionParams - Additional parameters to pass into the creation of the Stripe checkout session. (optional) @param redirectMode - Controls when to return a checkout URL for the immediate phase. 'always' forces a confirmation or checkout flow, 'if_required' only redirects when needed, and 'never' disables redirects. (optional) @@ -1239,6 +1247,9 @@ const response = await client.features.update({ featureId: "deprecated-feature", - [`plansGet`](docs/sdks/plans/README.md#get) - Get a plan - [`plansList`](docs/sdks/plans/README.md#list) - List all plans - [`plansUpdate`](docs/sdks/plans/README.md#update) - Update a plan +- [`platformGetRevenueCatKeys`](docs/sdks/platform/README.md#getrevenuecatkeys) - Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app. +- [`platformLinkRevenueCat`](docs/sdks/platform/README.md#linkrevenuecat) - Generate a RevenueCat OAuth URL for linking a project to an organization. +- [`platformSyncRevenueCat`](docs/sdks/platform/README.md#syncrevenuecat) - Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth. - [`referralsCreateCode`](docs/sdks/referrals/README.md#createcode) - Create or fetch a referral code for a customer in a referral program. - [`referralsRedeemCode`](docs/sdks/referrals/README.md#redeemcode) - Redeem a referral code for a customer. - [`rewardsRedeemCode`](docs/sdks/rewards/README.md#redeemcode) - Redeem a reward promo code for a customer. diff --git a/packages/sdk/src/funcs/billing-create-schedule.ts b/packages/sdk/src/funcs/billing-create-schedule.ts index 2be231df7..828fbb7fd 100644 --- a/packages/sdk/src/funcs/billing-create-schedule.ts +++ b/packages/sdk/src/funcs/billing-create-schedule.ts @@ -34,12 +34,13 @@ import { Result } from "../types/fp.js"; * @example * ```typescript * // Schedule a transition from a trial plan to a paid plan - * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1779977746466,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781187346466,"plans":[{"planId":"pro_plan"}]}] }); + * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780512803523,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781722403523,"plans":[{"planId":"pro_plan"}]}] }); * ``` * * @param customerId - The ID of the customer to create the schedule for. * @param entityId - Optional entity ID for an entity-scoped schedule. (optional) * @param invoiceMode - Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. (optional) + * @param discounts - List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. (optional) * @param successUrl - URL to redirect to after successful checkout. (optional) * @param checkoutSessionParams - Additional parameters to pass into the creation of the Stripe checkout session. (optional) * @param redirectMode - Controls when to return a checkout URL for the immediate phase. 'always' forces a confirmation or checkout flow, 'if_required' only redirects when needed, and 'never' disables redirects. (optional) diff --git a/packages/sdk/src/funcs/platform-get-revenue-cat-keys.ts b/packages/sdk/src/funcs/platform-get-revenue-cat-keys.ts new file mode 100644 index 000000000..7cbda7508 --- /dev/null +++ b/packages/sdk/src/funcs/platform-get-revenue-cat-keys.ts @@ -0,0 +1,165 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AutumnCore } from "../core.js"; +import { encodeJSON, encodeSimple } from "../lib/encodings.js"; +import { matchStatusCode } from "../lib/http.js"; +import * as M from "../lib/matchers.js"; +import { compactMap } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { RequestOptions } from "../lib/sdks.js"; +import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js"; +import { pathToFunc } from "../lib/url.js"; +import { AutumnError } from "../models/autumn-error.js"; +import { + ConnectionError, + InvalidRequestError, + RequestAbortedError, + RequestTimeoutError, + UnexpectedClientError, +} from "../models/http-client-errors.js"; +import * as models from "../models/index.js"; +import { ResponseValidationError } from "../models/response-validation-error.js"; +import { SDKValidationError } from "../models/sdk-validation-error.js"; +import { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app. + */ +export function platformGetRevenueCatKeys( + client: AutumnCore, + request: models.GetRevenueCatKeysParams, + options?: RequestOptions, +): APIPromise< + Result< + models.GetRevenueCatKeysResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AutumnCore, + request: models.GetRevenueCatKeysParams, + options?: RequestOptions, +): Promise< + [ + Result< + models.GetRevenueCatKeysResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => z.parse(models.GetRevenueCatKeysParams$outboundSchema, value), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = encodeJSON("body", payload, { explode: true }); + + const path = pathToFunc("/v1/platform.get_revenuecat_keys")(); + + const headers = new Headers(compactMap({ + "Content-Type": "application/json", + Accept: "application/json", + "x-api-version": encodeSimple( + "x-api-version", + client._options.xApiVersion, + { explode: false, charEncoding: "none" }, + ), + })); + + const secConfig = await extractSecurity(client._options.secretKey); + const securityInput = secConfig == null ? {} : { secretKey: secConfig }; + const requestSecurity = resolveGlobalSecurity(securityInput); + + const context = { + options: client._options, + baseURL: options?.serverURL ?? client._baseURL ?? "", + operationID: "getRevenueCatKeys", + oAuth2Scopes: null, + + resolvedSecurity: requestSecurity, + + securitySource: client._options.secretKey, + retryConfig: options?.retries + || client._options.retryConfig + || { strategy: "none" }, + retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"], + }; + + const requestRes = client._createRequest(context, { + security: requestSecurity, + method: "POST", + baseURL: options?.serverURL, + path: path, + headers: headers, + body: body, + userAgent: client._options.userAgent, + timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1, + }, options); + if (!requestRes.ok) { + return [requestRes, { status: "invalid" }]; + } + const req = requestRes.value; + + const doResult = await client._do(req, { + context, + isErrorStatusCode: (statusCode: number) => + matchStatusCode({ status: statusCode } as Response, ["4XX", "5XX"]), + retryConfig: context.retryConfig, + retryCodes: context.retryCodes, + }); + if (!doResult.ok) { + return [doResult, { status: "request-error", request: req }]; + } + const response = doResult.value; + + const [result] = await M.match< + models.GetRevenueCatKeysResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.GetRevenueCatKeysResponse$inboundSchema), + M.fail("4XX"), + M.fail("5XX"), + )(response, req); + if (!result.ok) { + return [result, { status: "complete", request: req, response }]; + } + + return [result, { status: "complete", request: req, response }]; +} diff --git a/packages/sdk/src/funcs/platform-link-revenue-cat.ts b/packages/sdk/src/funcs/platform-link-revenue-cat.ts new file mode 100644 index 000000000..831bad458 --- /dev/null +++ b/packages/sdk/src/funcs/platform-link-revenue-cat.ts @@ -0,0 +1,165 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AutumnCore } from "../core.js"; +import { encodeJSON, encodeSimple } from "../lib/encodings.js"; +import { matchStatusCode } from "../lib/http.js"; +import * as M from "../lib/matchers.js"; +import { compactMap } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { RequestOptions } from "../lib/sdks.js"; +import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js"; +import { pathToFunc } from "../lib/url.js"; +import { AutumnError } from "../models/autumn-error.js"; +import { + ConnectionError, + InvalidRequestError, + RequestAbortedError, + RequestTimeoutError, + UnexpectedClientError, +} from "../models/http-client-errors.js"; +import * as models from "../models/index.js"; +import { ResponseValidationError } from "../models/response-validation-error.js"; +import { SDKValidationError } from "../models/sdk-validation-error.js"; +import { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * Generate a RevenueCat OAuth URL for linking a project to an organization. + */ +export function platformLinkRevenueCat( + client: AutumnCore, + request: models.LinkRevenueCatParams, + options?: RequestOptions, +): APIPromise< + Result< + models.LinkRevenueCatResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AutumnCore, + request: models.LinkRevenueCatParams, + options?: RequestOptions, +): Promise< + [ + Result< + models.LinkRevenueCatResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => z.parse(models.LinkRevenueCatParams$outboundSchema, value), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = encodeJSON("body", payload, { explode: true }); + + const path = pathToFunc("/v1/platform.link_revenuecat")(); + + const headers = new Headers(compactMap({ + "Content-Type": "application/json", + Accept: "application/json", + "x-api-version": encodeSimple( + "x-api-version", + client._options.xApiVersion, + { explode: false, charEncoding: "none" }, + ), + })); + + const secConfig = await extractSecurity(client._options.secretKey); + const securityInput = secConfig == null ? {} : { secretKey: secConfig }; + const requestSecurity = resolveGlobalSecurity(securityInput); + + const context = { + options: client._options, + baseURL: options?.serverURL ?? client._baseURL ?? "", + operationID: "linkRevenueCat", + oAuth2Scopes: null, + + resolvedSecurity: requestSecurity, + + securitySource: client._options.secretKey, + retryConfig: options?.retries + || client._options.retryConfig + || { strategy: "none" }, + retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"], + }; + + const requestRes = client._createRequest(context, { + security: requestSecurity, + method: "POST", + baseURL: options?.serverURL, + path: path, + headers: headers, + body: body, + userAgent: client._options.userAgent, + timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1, + }, options); + if (!requestRes.ok) { + return [requestRes, { status: "invalid" }]; + } + const req = requestRes.value; + + const doResult = await client._do(req, { + context, + isErrorStatusCode: (statusCode: number) => + matchStatusCode({ status: statusCode } as Response, ["4XX", "5XX"]), + retryConfig: context.retryConfig, + retryCodes: context.retryCodes, + }); + if (!doResult.ok) { + return [doResult, { status: "request-error", request: req }]; + } + const response = doResult.value; + + const [result] = await M.match< + models.LinkRevenueCatResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.LinkRevenueCatResponse$inboundSchema), + M.fail("4XX"), + M.fail("5XX"), + )(response, req); + if (!result.ok) { + return [result, { status: "complete", request: req, response }]; + } + + return [result, { status: "complete", request: req, response }]; +} diff --git a/packages/sdk/src/funcs/platform-sync-revenue-cat.ts b/packages/sdk/src/funcs/platform-sync-revenue-cat.ts new file mode 100644 index 000000000..cbf471baa --- /dev/null +++ b/packages/sdk/src/funcs/platform-sync-revenue-cat.ts @@ -0,0 +1,165 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { AutumnCore } from "../core.js"; +import { encodeJSON, encodeSimple } from "../lib/encodings.js"; +import { matchStatusCode } from "../lib/http.js"; +import * as M from "../lib/matchers.js"; +import { compactMap } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { RequestOptions } from "../lib/sdks.js"; +import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js"; +import { pathToFunc } from "../lib/url.js"; +import { AutumnError } from "../models/autumn-error.js"; +import { + ConnectionError, + InvalidRequestError, + RequestAbortedError, + RequestTimeoutError, + UnexpectedClientError, +} from "../models/http-client-errors.js"; +import * as models from "../models/index.js"; +import { ResponseValidationError } from "../models/response-validation-error.js"; +import { SDKValidationError } from "../models/sdk-validation-error.js"; +import { APICall, APIPromise } from "../types/async.js"; +import { Result } from "../types/fp.js"; + +/** + * Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth. + */ +export function platformSyncRevenueCat( + client: AutumnCore, + request: models.SyncRevenueCatParams, + options?: RequestOptions, +): APIPromise< + Result< + models.SyncRevenueCatResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do( + client, + request, + options, + )); +} + +async function $do( + client: AutumnCore, + request: models.SyncRevenueCatParams, + options?: RequestOptions, +): Promise< + [ + Result< + models.SyncRevenueCatResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const parsed = safeParse( + request, + (value) => z.parse(models.SyncRevenueCatParams$outboundSchema, value), + "Input validation failed", + ); + if (!parsed.ok) { + return [parsed, { status: "invalid" }]; + } + const payload = parsed.value; + const body = encodeJSON("body", payload, { explode: true }); + + const path = pathToFunc("/v1/platform.sync_revenuecat")(); + + const headers = new Headers(compactMap({ + "Content-Type": "application/json", + Accept: "application/json", + "x-api-version": encodeSimple( + "x-api-version", + client._options.xApiVersion, + { explode: false, charEncoding: "none" }, + ), + })); + + const secConfig = await extractSecurity(client._options.secretKey); + const securityInput = secConfig == null ? {} : { secretKey: secConfig }; + const requestSecurity = resolveGlobalSecurity(securityInput); + + const context = { + options: client._options, + baseURL: options?.serverURL ?? client._baseURL ?? "", + operationID: "syncRevenueCat", + oAuth2Scopes: null, + + resolvedSecurity: requestSecurity, + + securitySource: client._options.secretKey, + retryConfig: options?.retries + || client._options.retryConfig + || { strategy: "none" }, + retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"], + }; + + const requestRes = client._createRequest(context, { + security: requestSecurity, + method: "POST", + baseURL: options?.serverURL, + path: path, + headers: headers, + body: body, + userAgent: client._options.userAgent, + timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1, + }, options); + if (!requestRes.ok) { + return [requestRes, { status: "invalid" }]; + } + const req = requestRes.value; + + const doResult = await client._do(req, { + context, + isErrorStatusCode: (statusCode: number) => + matchStatusCode({ status: statusCode } as Response, ["4XX", "5XX"]), + retryConfig: context.retryConfig, + retryCodes: context.retryCodes, + }); + if (!doResult.ok) { + return [doResult, { status: "request-error", request: req }]; + } + const response = doResult.value; + + const [result] = await M.match< + models.SyncRevenueCatResponse, + | AutumnError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, models.SyncRevenueCatResponse$inboundSchema), + M.fail("4XX"), + M.fail("5XX"), + )(response, req); + if (!result.ok) { + return [result, { status: "complete", request: req, response }]; + } + + return [result, { status: "complete", request: req, response }]; +} diff --git a/packages/sdk/src/models/attach-op.ts b/packages/sdk/src/models/attach-op.ts index 81daebe59..8594c479a 100644 --- a/packages/sdk/src/models/attach-op.ts +++ b/packages/sdk/src/models/attach-op.ts @@ -671,6 +671,14 @@ export type AttachInvoiceMode = { * If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. */ finalize?: boolean | undefined; + /** + * ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + */ + invoiceTemplateId?: string | undefined; + /** + * Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + */ + netTermsDays?: number | undefined; }; /** @@ -1674,6 +1682,8 @@ export type AttachInvoiceMode$Outbound = { enabled: boolean; enable_plan_immediately: boolean; finalize: boolean; + invoice_template_id?: string | undefined; + net_terms_days?: number | undefined; }; /** @internal */ @@ -1685,10 +1695,14 @@ export const AttachInvoiceMode$outboundSchema: z.ZodMiniType< enabled: z.boolean(), enablePlanImmediately: z._default(z.boolean(), false), finalize: z._default(z.boolean(), true), + invoiceTemplateId: z.optional(z.string()), + netTermsDays: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { enablePlanImmediately: "enable_plan_immediately", + invoiceTemplateId: "invoice_template_id", + netTermsDays: "net_terms_days", }); }), ); diff --git a/packages/sdk/src/models/billing-update-op.ts b/packages/sdk/src/models/billing-update-op.ts index ff2ea2029..53ddec09c 100644 --- a/packages/sdk/src/models/billing-update-op.ts +++ b/packages/sdk/src/models/billing-update-op.ts @@ -681,6 +681,14 @@ export type BillingUpdateInvoiceMode = { * If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. */ finalize?: boolean | undefined; + /** + * ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + */ + invoiceTemplateId?: string | undefined; + /** + * Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + */ + netTermsDays?: number | undefined; }; /** @@ -1666,6 +1674,8 @@ export type BillingUpdateInvoiceMode$Outbound = { enabled: boolean; enable_plan_immediately: boolean; finalize: boolean; + invoice_template_id?: string | undefined; + net_terms_days?: number | undefined; }; /** @internal */ @@ -1677,10 +1687,14 @@ export const BillingUpdateInvoiceMode$outboundSchema: z.ZodMiniType< enabled: z.boolean(), enablePlanImmediately: z._default(z.boolean(), false), finalize: z._default(z.boolean(), true), + invoiceTemplateId: z.optional(z.string()), + netTermsDays: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { enablePlanImmediately: "enable_plan_immediately", + invoiceTemplateId: "invoice_template_id", + netTermsDays: "net_terms_days", }); }), ); diff --git a/packages/sdk/src/models/check-op.ts b/packages/sdk/src/models/check-op.ts index 4826a7e94..b6c962e14 100644 --- a/packages/sdk/src/models/check-op.ts +++ b/packages/sdk/src/models/check-op.ts @@ -438,7 +438,7 @@ export type Properties2 = { updateable?: boolean | null | undefined; }; -export type Product2 = { +export type CheckProduct2 = { /** * The ID of the product you set when creating the product */ @@ -521,7 +521,7 @@ export type Preview2 = { /** * Products that would grant access to this feature. Use to display upgrade options. */ - products: Array; + products: Array; }; /** @@ -930,7 +930,7 @@ export type Properties1 = { updateable?: boolean | null | undefined; }; -export type Product1 = { +export type CheckProduct1 = { /** * The ID of the product you set when creating the product */ @@ -1013,7 +1013,7 @@ export type Preview1 = { /** * Products that would grant access to this feature. Use to display upgrade options. */ - products: Array; + products: Array; }; /** @@ -1520,7 +1520,10 @@ export function properties2FromJSON( } /** @internal */ -export const Product2$inboundSchema: z.ZodMiniType = z.pipe( +export const CheckProduct2$inboundSchema: z.ZodMiniType< + CheckProduct2, + unknown +> = z.pipe( z.object({ id: types.string(), name: types.string(), @@ -1548,13 +1551,13 @@ export const Product2$inboundSchema: z.ZodMiniType = z.pipe( }), ); -export function product2FromJSON( +export function checkProduct2FromJSON( jsonString: string, -): SafeParseResult { +): SafeParseResult { return safeParse( jsonString, - (x) => Product2$inboundSchema.parse(JSON.parse(x)), - `Failed to parse 'Product2' from JSON`, + (x) => CheckProduct2$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CheckProduct2' from JSON`, ); } @@ -1566,7 +1569,7 @@ export const Preview2$inboundSchema: z.ZodMiniType = z.pipe( message: types.string(), feature_id: types.string(), feature_name: types.string(), - products: z.array(z.lazy(() => Product2$inboundSchema)), + products: z.array(z.lazy(() => CheckProduct2$inboundSchema)), }), z.transform((v) => { return remap$(v, { @@ -2014,7 +2017,10 @@ export function properties1FromJSON( } /** @internal */ -export const Product1$inboundSchema: z.ZodMiniType = z.pipe( +export const CheckProduct1$inboundSchema: z.ZodMiniType< + CheckProduct1, + unknown +> = z.pipe( z.object({ id: types.string(), name: types.string(), @@ -2042,13 +2048,13 @@ export const Product1$inboundSchema: z.ZodMiniType = z.pipe( }), ); -export function product1FromJSON( +export function checkProduct1FromJSON( jsonString: string, -): SafeParseResult { +): SafeParseResult { return safeParse( jsonString, - (x) => Product1$inboundSchema.parse(JSON.parse(x)), - `Failed to parse 'Product1' from JSON`, + (x) => CheckProduct1$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'CheckProduct1' from JSON`, ); } @@ -2060,7 +2066,7 @@ export const Preview1$inboundSchema: z.ZodMiniType = z.pipe( message: types.string(), feature_id: types.string(), feature_name: types.string(), - products: z.array(z.lazy(() => Product1$inboundSchema)), + products: z.array(z.lazy(() => CheckProduct1$inboundSchema)), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/create-entity-op.ts b/packages/sdk/src/models/create-entity-op.ts index cc77be59f..712cf74bd 100644 --- a/packages/sdk/src/models/create-entity-op.ts +++ b/packages/sdk/src/models/create-entity-op.ts @@ -156,6 +156,20 @@ export const CreateEntityStatus = { */ export type CreateEntityStatus = OpenEnum; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export const CreateEntitySubscriptionScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export type CreateEntitySubscriptionScope = OpenEnum< + typeof CreateEntitySubscriptionScope +>; + export type CreateEntitySubscription = { /** * The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID. @@ -210,8 +224,26 @@ export type CreateEntitySubscription = { * Number of units of this subscription (for per-seat plans). */ quantity: number; + /** + * Whether this subscription is attached at the customer level or entity level. + */ + scope?: CreateEntitySubscriptionScope | undefined; }; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export const CreateEntityPurchaseScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export type CreateEntityPurchaseScope = OpenEnum< + typeof CreateEntityPurchaseScope +>; + export type CreateEntityPurchase = { plan?: Plan | undefined; /** @@ -230,6 +262,10 @@ export type CreateEntityPurchase = { * Number of units purchased. */ quantity: number; + /** + * Whether this purchase is attached at the customer level or entity level. + */ + scope?: CreateEntityPurchaseScope | undefined; }; /** @@ -716,6 +752,12 @@ export const CreateEntityStatus$inboundSchema: z.ZodMiniType< unknown > = openEnums.inboundSchema(CreateEntityStatus); +/** @internal */ +export const CreateEntitySubscriptionScope$inboundSchema: z.ZodMiniType< + CreateEntitySubscriptionScope, + unknown +> = openEnums.inboundSchema(CreateEntitySubscriptionScope); + /** @internal */ export const CreateEntitySubscription$inboundSchema: z.ZodMiniType< CreateEntitySubscription, @@ -736,6 +778,7 @@ export const CreateEntitySubscription$inboundSchema: z.ZodMiniType< current_period_start: types.nullable(types.number()), current_period_end: types.nullable(types.number()), quantity: types.number(), + scope: types.optional(CreateEntitySubscriptionScope$inboundSchema), }), z.transform((v) => { return remap$(v, { @@ -763,6 +806,12 @@ export function createEntitySubscriptionFromJSON( ); } +/** @internal */ +export const CreateEntityPurchaseScope$inboundSchema: z.ZodMiniType< + CreateEntityPurchaseScope, + unknown +> = openEnums.inboundSchema(CreateEntityPurchaseScope); + /** @internal */ export const CreateEntityPurchase$inboundSchema: z.ZodMiniType< CreateEntityPurchase, @@ -774,6 +823,7 @@ export const CreateEntityPurchase$inboundSchema: z.ZodMiniType< expires_at: types.nullable(types.number()), started_at: types.number(), quantity: types.number(), + scope: types.optional(CreateEntityPurchaseScope$inboundSchema), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/create-schedule-op.ts b/packages/sdk/src/models/create-schedule-op.ts index 9539557fa..23921226d 100644 --- a/packages/sdk/src/models/create-schedule-op.ts +++ b/packages/sdk/src/models/create-schedule-op.ts @@ -31,6 +31,28 @@ export type CreateScheduleInvoiceMode = { * If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. */ finalize?: boolean | undefined; + /** + * ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + */ + invoiceTemplateId?: string | undefined; + /** + * Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + */ + netTermsDays?: number | undefined; +}; + +/** + * A discount to apply. Can be either a reward ID or a promotion code. + */ +export type CreateScheduleAttachDiscount = { + /** + * The ID of the reward to apply as a discount. + */ + rewardId?: string | undefined; + /** + * The promotion code to apply as a discount. + */ + promotionCode?: string | undefined; }; /** @@ -410,6 +432,10 @@ export type CreateScheduleParams = { * Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. */ invoiceMode?: CreateScheduleInvoiceMode | undefined; + /** + * List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. + */ + discounts?: Array | undefined; /** * URL to redirect to after successful checkout. */ @@ -557,6 +583,8 @@ export type CreateScheduleInvoiceMode$Outbound = { enabled: boolean; enable_plan_immediately: boolean; finalize: boolean; + invoice_template_id?: string | undefined; + net_terms_days?: number | undefined; }; /** @internal */ @@ -568,10 +596,14 @@ export const CreateScheduleInvoiceMode$outboundSchema: z.ZodMiniType< enabled: z.boolean(), enablePlanImmediately: z._default(z.boolean(), false), finalize: z._default(z.boolean(), true), + invoiceTemplateId: z.optional(z.string()), + netTermsDays: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { enablePlanImmediately: "enable_plan_immediately", + invoiceTemplateId: "invoice_template_id", + netTermsDays: "net_terms_days", }); }), ); @@ -584,6 +616,39 @@ export function createScheduleInvoiceModeToJSON( ); } +/** @internal */ +export type CreateScheduleAttachDiscount$Outbound = { + reward_id?: string | undefined; + promotion_code?: string | undefined; +}; + +/** @internal */ +export const CreateScheduleAttachDiscount$outboundSchema: z.ZodMiniType< + CreateScheduleAttachDiscount$Outbound, + CreateScheduleAttachDiscount +> = z.pipe( + z.object({ + rewardId: z.optional(z.string()), + promotionCode: z.optional(z.string()), + }), + z.transform((v) => { + return remap$(v, { + rewardId: "reward_id", + promotionCode: "promotion_code", + }); + }), +); + +export function createScheduleAttachDiscountToJSON( + createScheduleAttachDiscount: CreateScheduleAttachDiscount, +): string { + return JSON.stringify( + CreateScheduleAttachDiscount$outboundSchema.parse( + createScheduleAttachDiscount, + ), + ); +} + /** @internal */ export const CreateScheduleRedirectMode$outboundSchema: z.ZodMiniEnum< typeof CreateScheduleRedirectMode @@ -1032,6 +1097,7 @@ export type CreateScheduleParams$Outbound = { customer_id: string; entity_id?: string | undefined; invoice_mode?: CreateScheduleInvoiceMode$Outbound | undefined; + discounts?: Array | undefined; success_url?: string | undefined; checkout_session_params?: { [k: string]: any } | undefined; redirect_mode: string; @@ -1052,6 +1118,9 @@ export const CreateScheduleParams$outboundSchema: z.ZodMiniType< invoiceMode: z.optional( z.lazy(() => CreateScheduleInvoiceMode$outboundSchema), ), + discounts: z.optional( + z.array(z.lazy(() => CreateScheduleAttachDiscount$outboundSchema)), + ), successUrl: z.optional(z.string()), checkoutSessionParams: z.optional(z.record(z.string(), z.any())), redirectMode: z._default( diff --git a/packages/sdk/src/models/customer.ts b/packages/sdk/src/models/customer.ts index 650544ad2..80b661b2e 100644 --- a/packages/sdk/src/models/customer.ts +++ b/packages/sdk/src/models/customer.ts @@ -217,6 +217,18 @@ export const CustomerStatus = { */ export type CustomerStatus = OpenEnum; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export const SubscriptionScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export type SubscriptionScope = OpenEnum; + export type Subscription = { /** * The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID. @@ -271,8 +283,24 @@ export type Subscription = { * Number of units of this subscription (for per-seat plans). */ quantity: number; + /** + * Whether this subscription is attached at the customer level or entity level. + */ + scope?: SubscriptionScope | undefined; }; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export const PurchaseScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export type PurchaseScope = OpenEnum; + export type Purchase = { plan?: Plan | undefined; /** @@ -291,6 +319,10 @@ export type Purchase = { * Number of units purchased. */ quantity: number; + /** + * Whether this purchase is attached at the customer level or entity level. + */ + scope?: PurchaseScope | undefined; }; /** @@ -994,6 +1026,12 @@ export const CustomerStatus$inboundSchema: z.ZodMiniType< unknown > = openEnums.inboundSchema(CustomerStatus); +/** @internal */ +export const SubscriptionScope$inboundSchema: z.ZodMiniType< + SubscriptionScope, + unknown +> = openEnums.inboundSchema(SubscriptionScope); + /** @internal */ export const Subscription$inboundSchema: z.ZodMiniType = z.pipe( @@ -1012,6 +1050,7 @@ export const Subscription$inboundSchema: z.ZodMiniType = current_period_start: types.nullable(types.number()), current_period_end: types.nullable(types.number()), quantity: types.number(), + scope: types.optional(SubscriptionScope$inboundSchema), }), z.transform((v) => { return remap$(v, { @@ -1039,6 +1078,12 @@ export function subscriptionFromJSON( ); } +/** @internal */ +export const PurchaseScope$inboundSchema: z.ZodMiniType< + PurchaseScope, + unknown +> = openEnums.inboundSchema(PurchaseScope); + /** @internal */ export const Purchase$inboundSchema: z.ZodMiniType = z.pipe( z.object({ @@ -1047,6 +1092,7 @@ export const Purchase$inboundSchema: z.ZodMiniType = z.pipe( expires_at: types.nullable(types.number()), started_at: types.number(), quantity: types.number(), + scope: types.optional(PurchaseScope$inboundSchema), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/get-customer-op.ts b/packages/sdk/src/models/get-customer-op.ts index abe8158e3..35f1dee91 100644 --- a/packages/sdk/src/models/get-customer-op.ts +++ b/packages/sdk/src/models/get-customer-op.ts @@ -241,6 +241,20 @@ export const GetCustomerStatus = { */ export type GetCustomerStatus = OpenEnum; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export const GetCustomerSubscriptionScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export type GetCustomerSubscriptionScope = OpenEnum< + typeof GetCustomerSubscriptionScope +>; + export type GetCustomerSubscription = { /** * The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID. @@ -295,8 +309,26 @@ export type GetCustomerSubscription = { * Number of units of this subscription (for per-seat plans). */ quantity: number; + /** + * Whether this subscription is attached at the customer level or entity level. + */ + scope?: GetCustomerSubscriptionScope | undefined; }; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export const GetCustomerPurchaseScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export type GetCustomerPurchaseScope = OpenEnum< + typeof GetCustomerPurchaseScope +>; + export type GetCustomerPurchase = { plan?: Plan | undefined; /** @@ -315,6 +347,10 @@ export type GetCustomerPurchase = { * Number of units purchased. */ quantity: number; + /** + * Whether this purchase is attached at the customer level or entity level. + */ + scope?: GetCustomerPurchaseScope | undefined; }; /** @@ -1055,6 +1091,12 @@ export const GetCustomerStatus$inboundSchema: z.ZodMiniType< unknown > = openEnums.inboundSchema(GetCustomerStatus); +/** @internal */ +export const GetCustomerSubscriptionScope$inboundSchema: z.ZodMiniType< + GetCustomerSubscriptionScope, + unknown +> = openEnums.inboundSchema(GetCustomerSubscriptionScope); + /** @internal */ export const GetCustomerSubscription$inboundSchema: z.ZodMiniType< GetCustomerSubscription, @@ -1075,6 +1117,7 @@ export const GetCustomerSubscription$inboundSchema: z.ZodMiniType< current_period_start: types.nullable(types.number()), current_period_end: types.nullable(types.number()), quantity: types.number(), + scope: types.optional(GetCustomerSubscriptionScope$inboundSchema), }), z.transform((v) => { return remap$(v, { @@ -1102,6 +1145,12 @@ export function getCustomerSubscriptionFromJSON( ); } +/** @internal */ +export const GetCustomerPurchaseScope$inboundSchema: z.ZodMiniType< + GetCustomerPurchaseScope, + unknown +> = openEnums.inboundSchema(GetCustomerPurchaseScope); + /** @internal */ export const GetCustomerPurchase$inboundSchema: z.ZodMiniType< GetCustomerPurchase, @@ -1113,6 +1162,7 @@ export const GetCustomerPurchase$inboundSchema: z.ZodMiniType< expires_at: types.nullable(types.number()), started_at: types.number(), quantity: types.number(), + scope: types.optional(GetCustomerPurchaseScope$inboundSchema), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/get-entity-op.ts b/packages/sdk/src/models/get-entity-op.ts index 51e278c5e..38052f3bf 100644 --- a/packages/sdk/src/models/get-entity-op.ts +++ b/packages/sdk/src/models/get-entity-op.ts @@ -52,6 +52,20 @@ export const GetEntityStatus = { */ export type GetEntityStatus = OpenEnum; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export const GetEntitySubscriptionScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export type GetEntitySubscriptionScope = OpenEnum< + typeof GetEntitySubscriptionScope +>; + export type GetEntitySubscription = { /** * The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID. @@ -106,8 +120,24 @@ export type GetEntitySubscription = { * Number of units of this subscription (for per-seat plans). */ quantity: number; + /** + * Whether this subscription is attached at the customer level or entity level. + */ + scope?: GetEntitySubscriptionScope | undefined; }; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export const GetEntityPurchaseScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export type GetEntityPurchaseScope = OpenEnum; + export type GetEntityPurchase = { plan?: Plan | undefined; /** @@ -126,6 +156,10 @@ export type GetEntityPurchase = { * Number of units purchased. */ quantity: number; + /** + * Whether this purchase is attached at the customer level or entity level. + */ + scope?: GetEntityPurchaseScope | undefined; }; /** @@ -436,6 +470,12 @@ export const GetEntityStatus$inboundSchema: z.ZodMiniType< unknown > = openEnums.inboundSchema(GetEntityStatus); +/** @internal */ +export const GetEntitySubscriptionScope$inboundSchema: z.ZodMiniType< + GetEntitySubscriptionScope, + unknown +> = openEnums.inboundSchema(GetEntitySubscriptionScope); + /** @internal */ export const GetEntitySubscription$inboundSchema: z.ZodMiniType< GetEntitySubscription, @@ -456,6 +496,7 @@ export const GetEntitySubscription$inboundSchema: z.ZodMiniType< current_period_start: types.nullable(types.number()), current_period_end: types.nullable(types.number()), quantity: types.number(), + scope: types.optional(GetEntitySubscriptionScope$inboundSchema), }), z.transform((v) => { return remap$(v, { @@ -483,6 +524,12 @@ export function getEntitySubscriptionFromJSON( ); } +/** @internal */ +export const GetEntityPurchaseScope$inboundSchema: z.ZodMiniType< + GetEntityPurchaseScope, + unknown +> = openEnums.inboundSchema(GetEntityPurchaseScope); + /** @internal */ export const GetEntityPurchase$inboundSchema: z.ZodMiniType< GetEntityPurchase, @@ -494,6 +541,7 @@ export const GetEntityPurchase$inboundSchema: z.ZodMiniType< expires_at: types.nullable(types.number()), started_at: types.number(), quantity: types.number(), + scope: types.optional(GetEntityPurchaseScope$inboundSchema), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/get-revenue-cat-keys-op.ts b/packages/sdk/src/models/get-revenue-cat-keys-op.ts new file mode 100644 index 000000000..e52b5aaf5 --- /dev/null +++ b/packages/sdk/src/models/get-revenue-cat-keys-op.ts @@ -0,0 +1,193 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { remap as remap$ } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { ClosedEnum } from "../types/enums.js"; +import { Result as SafeParseResult } from "../types/fp.js"; +import * as types from "../types/primitives.js"; +import { SDKValidationError } from "./sdk-validation-error.js"; + +export type GetRevenueCatKeysGlobals = { + xApiVersion?: string | undefined; +}; + +/** + * "test" and "sandbox" both target the sandbox environment + */ +export const GetRevenueCatKeysEnv = { + Test: "test", + Sandbox: "sandbox", + Live: "live", +} as const; +/** + * "test" and "sandbox" both target the sandbox environment + */ +export type GetRevenueCatKeysEnv = ClosedEnum; + +export type GetRevenueCatKeysParams = { + organizationSlug: string; + /** + * "test" and "sandbox" both target the sandbox environment + */ + env: GetRevenueCatKeysEnv; +}; + +export type ApiKey = { + id: string; + /** + * The public SDK API key value + */ + key: string; + /** + * e.g. "production" / "sandbox" + */ + environment?: string | null | undefined; + appId?: string | null | undefined; + createdAt?: number | undefined; + [additionalProperties: string]: unknown; +}; + +export type GetRevenueCatKeysApp = { + appId: string; + /** + * RevenueCat store type, e.g. test_store / app_store / play_store + */ + appType: string; + name: string; + apiKeys: Array; +}; + +/** + * OK + */ +export type GetRevenueCatKeysResponse = { + apps: Array; + /** + * Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token. + */ + oauthAccessToken: string | null; +}; + +/** @internal */ +export const GetRevenueCatKeysEnv$outboundSchema: z.ZodMiniEnum< + typeof GetRevenueCatKeysEnv +> = z.enum(GetRevenueCatKeysEnv); + +/** @internal */ +export type GetRevenueCatKeysParams$Outbound = { + organization_slug: string; + env: string; +}; + +/** @internal */ +export const GetRevenueCatKeysParams$outboundSchema: z.ZodMiniType< + GetRevenueCatKeysParams$Outbound, + GetRevenueCatKeysParams +> = z.pipe( + z.object({ + organizationSlug: z.string(), + env: GetRevenueCatKeysEnv$outboundSchema, + }), + z.transform((v) => { + return remap$(v, { + organizationSlug: "organization_slug", + }); + }), +); + +export function getRevenueCatKeysParamsToJSON( + getRevenueCatKeysParams: GetRevenueCatKeysParams, +): string { + return JSON.stringify( + GetRevenueCatKeysParams$outboundSchema.parse(getRevenueCatKeysParams), + ); +} + +/** @internal */ +export const ApiKey$inboundSchema: z.ZodMiniType = z.pipe( + z.catchall( + z.object({ + id: types.string(), + key: types.string(), + environment: z.optional(z.nullable(types.string())), + app_id: z.optional(z.nullable(types.string())), + created_at: types.optional(types.number()), + }), + z.any(), + ), + z.transform((v) => { + return remap$(v, { + "app_id": "appId", + "created_at": "createdAt", + }); + }), +); + +export function apiKeyFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => ApiKey$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ApiKey' from JSON`, + ); +} + +/** @internal */ +export const GetRevenueCatKeysApp$inboundSchema: z.ZodMiniType< + GetRevenueCatKeysApp, + unknown +> = z.pipe( + z.object({ + app_id: types.string(), + app_type: types.string(), + name: types.string(), + api_keys: z.array(z.lazy(() => ApiKey$inboundSchema)), + }), + z.transform((v) => { + return remap$(v, { + "app_id": "appId", + "app_type": "appType", + "api_keys": "apiKeys", + }); + }), +); + +export function getRevenueCatKeysAppFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetRevenueCatKeysApp$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetRevenueCatKeysApp' from JSON`, + ); +} + +/** @internal */ +export const GetRevenueCatKeysResponse$inboundSchema: z.ZodMiniType< + GetRevenueCatKeysResponse, + unknown +> = z.pipe( + z.object({ + apps: z.array(z.lazy(() => GetRevenueCatKeysApp$inboundSchema)), + oauth_access_token: types.nullable(types.string()), + }), + z.transform((v) => { + return remap$(v, { + "oauth_access_token": "oauthAccessToken", + }); + }), +); + +export function getRevenueCatKeysResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => GetRevenueCatKeysResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'GetRevenueCatKeysResponse' from JSON`, + ); +} diff --git a/packages/sdk/src/models/index.ts b/packages/sdk/src/models/index.ts index e63734a48..9c3a0afcd 100644 --- a/packages/sdk/src/models/index.ts +++ b/packages/sdk/src/models/index.ts @@ -30,7 +30,9 @@ export * from "./get-entity-op.js"; export * from "./get-feature-op.js"; export * from "./get-or-create-customer-op.js"; export * from "./get-plan-op.js"; +export * from "./get-revenue-cat-keys-op.js"; export * from "./http-client-errors.js"; +export * from "./link-revenue-cat-op.js"; export * from "./list-customers-op.js"; export * from "./list-entities-op.js"; export * from "./list-events-op.js"; @@ -48,6 +50,7 @@ export * from "./response-validation-error.js"; export * from "./sdk-validation-error.js"; export * from "./security.js"; export * from "./setup-payment-op.js"; +export * from "./sync-revenue-cat-op.js"; export * from "./track-op.js"; export * from "./update-balance-op.js"; export * from "./update-customer-op.js"; diff --git a/packages/sdk/src/models/link-revenue-cat-op.ts b/packages/sdk/src/models/link-revenue-cat-op.ts new file mode 100644 index 000000000..2f5338db9 --- /dev/null +++ b/packages/sdk/src/models/link-revenue-cat-op.ts @@ -0,0 +1,101 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { remap as remap$ } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import { ClosedEnum } from "../types/enums.js"; +import { Result as SafeParseResult } from "../types/fp.js"; +import * as types from "../types/primitives.js"; +import { SDKValidationError } from "./sdk-validation-error.js"; + +export type LinkRevenueCatGlobals = { + xApiVersion?: string | undefined; +}; + +export const LinkRevenueCatEnv = { + Test: "test", + Live: "live", +} as const; +export type LinkRevenueCatEnv = ClosedEnum; + +export type LinkRevenueCatParams = { + organizationSlug: string; + env: LinkRevenueCatEnv; + projectName: string; + redirectUrl: string; +}; + +/** + * OK + */ +export type LinkRevenueCatResponse = { + oauthUrl: string; +}; + +/** @internal */ +export const LinkRevenueCatEnv$outboundSchema: z.ZodMiniEnum< + typeof LinkRevenueCatEnv +> = z.enum(LinkRevenueCatEnv); + +/** @internal */ +export type LinkRevenueCatParams$Outbound = { + organization_slug: string; + env: string; + project_name: string; + redirect_url: string; +}; + +/** @internal */ +export const LinkRevenueCatParams$outboundSchema: z.ZodMiniType< + LinkRevenueCatParams$Outbound, + LinkRevenueCatParams +> = z.pipe( + z.object({ + organizationSlug: z.string(), + env: LinkRevenueCatEnv$outboundSchema, + projectName: z.string(), + redirectUrl: z.string(), + }), + z.transform((v) => { + return remap$(v, { + organizationSlug: "organization_slug", + projectName: "project_name", + redirectUrl: "redirect_url", + }); + }), +); + +export function linkRevenueCatParamsToJSON( + linkRevenueCatParams: LinkRevenueCatParams, +): string { + return JSON.stringify( + LinkRevenueCatParams$outboundSchema.parse(linkRevenueCatParams), + ); +} + +/** @internal */ +export const LinkRevenueCatResponse$inboundSchema: z.ZodMiniType< + LinkRevenueCatResponse, + unknown +> = z.pipe( + z.object({ + oauth_url: types.string(), + }), + z.transform((v) => { + return remap$(v, { + "oauth_url": "oauthUrl", + }); + }), +); + +export function linkRevenueCatResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => LinkRevenueCatResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'LinkRevenueCatResponse' from JSON`, + ); +} diff --git a/packages/sdk/src/models/list-customers-op.ts b/packages/sdk/src/models/list-customers-op.ts index 49c50334b..7f4377171 100644 --- a/packages/sdk/src/models/list-customers-op.ts +++ b/packages/sdk/src/models/list-customers-op.ts @@ -279,6 +279,20 @@ export const ListCustomersStatus = { */ export type ListCustomersStatus = OpenEnum; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export const ListCustomersSubscriptionScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export type ListCustomersSubscriptionScope = OpenEnum< + typeof ListCustomersSubscriptionScope +>; + export type ListCustomersSubscription = { /** * The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID. @@ -333,8 +347,26 @@ export type ListCustomersSubscription = { * Number of units of this subscription (for per-seat plans). */ quantity: number; + /** + * Whether this subscription is attached at the customer level or entity level. + */ + scope?: ListCustomersSubscriptionScope | undefined; }; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export const ListCustomersPurchaseScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export type ListCustomersPurchaseScope = OpenEnum< + typeof ListCustomersPurchaseScope +>; + export type ListCustomersPurchase = { plan?: Plan | undefined; /** @@ -353,6 +385,10 @@ export type ListCustomersPurchase = { * Number of units purchased. */ quantity: number; + /** + * Whether this purchase is attached at the customer level or entity level. + */ + scope?: ListCustomersPurchaseScope | undefined; }; /** @@ -935,6 +971,12 @@ export const ListCustomersStatus$inboundSchema: z.ZodMiniType< unknown > = openEnums.inboundSchema(ListCustomersStatus); +/** @internal */ +export const ListCustomersSubscriptionScope$inboundSchema: z.ZodMiniType< + ListCustomersSubscriptionScope, + unknown +> = openEnums.inboundSchema(ListCustomersSubscriptionScope); + /** @internal */ export const ListCustomersSubscription$inboundSchema: z.ZodMiniType< ListCustomersSubscription, @@ -955,6 +997,7 @@ export const ListCustomersSubscription$inboundSchema: z.ZodMiniType< current_period_start: types.nullable(types.number()), current_period_end: types.nullable(types.number()), quantity: types.number(), + scope: types.optional(ListCustomersSubscriptionScope$inboundSchema), }), z.transform((v) => { return remap$(v, { @@ -982,6 +1025,12 @@ export function listCustomersSubscriptionFromJSON( ); } +/** @internal */ +export const ListCustomersPurchaseScope$inboundSchema: z.ZodMiniType< + ListCustomersPurchaseScope, + unknown +> = openEnums.inboundSchema(ListCustomersPurchaseScope); + /** @internal */ export const ListCustomersPurchase$inboundSchema: z.ZodMiniType< ListCustomersPurchase, @@ -993,6 +1042,7 @@ export const ListCustomersPurchase$inboundSchema: z.ZodMiniType< expires_at: types.nullable(types.number()), started_at: types.number(), quantity: types.number(), + scope: types.optional(ListCustomersPurchaseScope$inboundSchema), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/list-entities-op.ts b/packages/sdk/src/models/list-entities-op.ts index 298ba3d84..eedb101e4 100644 --- a/packages/sdk/src/models/list-entities-op.ts +++ b/packages/sdk/src/models/list-entities-op.ts @@ -98,6 +98,20 @@ export const ListEntitiesStatus = { */ export type ListEntitiesStatus = OpenEnum; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export const ListEntitiesSubscriptionScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export type ListEntitiesSubscriptionScope = OpenEnum< + typeof ListEntitiesSubscriptionScope +>; + export type ListEntitiesSubscription = { /** * The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID. @@ -152,8 +166,26 @@ export type ListEntitiesSubscription = { * Number of units of this subscription (for per-seat plans). */ quantity: number; + /** + * Whether this subscription is attached at the customer level or entity level. + */ + scope?: ListEntitiesSubscriptionScope | undefined; }; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export const ListEntitiesPurchaseScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export type ListEntitiesPurchaseScope = OpenEnum< + typeof ListEntitiesPurchaseScope +>; + export type ListEntitiesPurchase = { plan?: Plan | undefined; /** @@ -172,6 +204,10 @@ export type ListEntitiesPurchase = { * Number of units purchased. */ quantity: number; + /** + * Whether this purchase is attached at the customer level or entity level. + */ + scope?: ListEntitiesPurchaseScope | undefined; }; /** @@ -547,6 +583,12 @@ export const ListEntitiesStatus$inboundSchema: z.ZodMiniType< unknown > = openEnums.inboundSchema(ListEntitiesStatus); +/** @internal */ +export const ListEntitiesSubscriptionScope$inboundSchema: z.ZodMiniType< + ListEntitiesSubscriptionScope, + unknown +> = openEnums.inboundSchema(ListEntitiesSubscriptionScope); + /** @internal */ export const ListEntitiesSubscription$inboundSchema: z.ZodMiniType< ListEntitiesSubscription, @@ -567,6 +609,7 @@ export const ListEntitiesSubscription$inboundSchema: z.ZodMiniType< current_period_start: types.nullable(types.number()), current_period_end: types.nullable(types.number()), quantity: types.number(), + scope: types.optional(ListEntitiesSubscriptionScope$inboundSchema), }), z.transform((v) => { return remap$(v, { @@ -594,6 +637,12 @@ export function listEntitiesSubscriptionFromJSON( ); } +/** @internal */ +export const ListEntitiesPurchaseScope$inboundSchema: z.ZodMiniType< + ListEntitiesPurchaseScope, + unknown +> = openEnums.inboundSchema(ListEntitiesPurchaseScope); + /** @internal */ export const ListEntitiesPurchase$inboundSchema: z.ZodMiniType< ListEntitiesPurchase, @@ -605,6 +654,7 @@ export const ListEntitiesPurchase$inboundSchema: z.ZodMiniType< expires_at: types.nullable(types.number()), started_at: types.number(), quantity: types.number(), + scope: types.optional(ListEntitiesPurchaseScope$inboundSchema), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/multi-attach-op.ts b/packages/sdk/src/models/multi-attach-op.ts index ffadc4dc4..38d6d3417 100644 --- a/packages/sdk/src/models/multi-attach-op.ts +++ b/packages/sdk/src/models/multi-attach-op.ts @@ -408,6 +408,14 @@ export type MultiAttachInvoiceMode = { * If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. */ finalize?: boolean | undefined; + /** + * ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + */ + invoiceTemplateId?: string | undefined; + /** + * Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + */ + netTermsDays?: number | undefined; }; /** @@ -1113,6 +1121,8 @@ export type MultiAttachInvoiceMode$Outbound = { enabled: boolean; enable_plan_immediately: boolean; finalize: boolean; + invoice_template_id?: string | undefined; + net_terms_days?: number | undefined; }; /** @internal */ @@ -1124,10 +1134,14 @@ export const MultiAttachInvoiceMode$outboundSchema: z.ZodMiniType< enabled: z.boolean(), enablePlanImmediately: z._default(z.boolean(), false), finalize: z._default(z.boolean(), true), + invoiceTemplateId: z.optional(z.string()), + netTermsDays: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { enablePlanImmediately: "enable_plan_immediately", + invoiceTemplateId: "invoice_template_id", + netTermsDays: "net_terms_days", }); }), ); diff --git a/packages/sdk/src/models/preview-attach-op.ts b/packages/sdk/src/models/preview-attach-op.ts index 322a2a97e..969faf47c 100644 --- a/packages/sdk/src/models/preview-attach-op.ts +++ b/packages/sdk/src/models/preview-attach-op.ts @@ -682,6 +682,14 @@ export type PreviewAttachInvoiceMode = { * If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. */ finalize?: boolean | undefined; + /** + * ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + */ + invoiceTemplateId?: string | undefined; + /** + * Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + */ + netTermsDays?: number | undefined; }; /** @@ -2024,6 +2032,8 @@ export type PreviewAttachInvoiceMode$Outbound = { enabled: boolean; enable_plan_immediately: boolean; finalize: boolean; + invoice_template_id?: string | undefined; + net_terms_days?: number | undefined; }; /** @internal */ @@ -2035,10 +2045,14 @@ export const PreviewAttachInvoiceMode$outboundSchema: z.ZodMiniType< enabled: z.boolean(), enablePlanImmediately: z._default(z.boolean(), false), finalize: z._default(z.boolean(), true), + invoiceTemplateId: z.optional(z.string()), + netTermsDays: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { enablePlanImmediately: "enable_plan_immediately", + invoiceTemplateId: "invoice_template_id", + netTermsDays: "net_terms_days", }); }), ); diff --git a/packages/sdk/src/models/preview-multi-attach-op.ts b/packages/sdk/src/models/preview-multi-attach-op.ts index 14d6547dd..a0be451fa 100644 --- a/packages/sdk/src/models/preview-multi-attach-op.ts +++ b/packages/sdk/src/models/preview-multi-attach-op.ts @@ -415,6 +415,14 @@ export type PreviewMultiAttachInvoiceMode = { * If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. */ finalize?: boolean | undefined; + /** + * ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + */ + invoiceTemplateId?: string | undefined; + /** + * Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + */ + netTermsDays?: number | undefined; }; /** @@ -1443,6 +1451,8 @@ export type PreviewMultiAttachInvoiceMode$Outbound = { enabled: boolean; enable_plan_immediately: boolean; finalize: boolean; + invoice_template_id?: string | undefined; + net_terms_days?: number | undefined; }; /** @internal */ @@ -1454,10 +1464,14 @@ export const PreviewMultiAttachInvoiceMode$outboundSchema: z.ZodMiniType< enabled: z.boolean(), enablePlanImmediately: z._default(z.boolean(), false), finalize: z._default(z.boolean(), true), + invoiceTemplateId: z.optional(z.string()), + netTermsDays: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { enablePlanImmediately: "enable_plan_immediately", + invoiceTemplateId: "invoice_template_id", + netTermsDays: "net_terms_days", }); }), ); diff --git a/packages/sdk/src/models/preview-update-op.ts b/packages/sdk/src/models/preview-update-op.ts index d91576764..279424e24 100644 --- a/packages/sdk/src/models/preview-update-op.ts +++ b/packages/sdk/src/models/preview-update-op.ts @@ -682,6 +682,14 @@ export type PreviewUpdateInvoiceMode = { * If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review. */ finalize?: boolean | undefined; + /** + * ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice. + */ + invoiceTemplateId?: string | undefined; + /** + * Number of days the customer has to pay the invoice before it is due (Stripe days_until_due). + */ + netTermsDays?: number | undefined; }; /** @@ -1950,6 +1958,8 @@ export type PreviewUpdateInvoiceMode$Outbound = { enabled: boolean; enable_plan_immediately: boolean; finalize: boolean; + invoice_template_id?: string | undefined; + net_terms_days?: number | undefined; }; /** @internal */ @@ -1961,10 +1971,14 @@ export const PreviewUpdateInvoiceMode$outboundSchema: z.ZodMiniType< enabled: z.boolean(), enablePlanImmediately: z._default(z.boolean(), false), finalize: z._default(z.boolean(), true), + invoiceTemplateId: z.optional(z.string()), + netTermsDays: z.optional(z.int()), }), z.transform((v) => { return remap$(v, { enablePlanImmediately: "enable_plan_immediately", + invoiceTemplateId: "invoice_template_id", + netTermsDays: "net_terms_days", }); }), ); diff --git a/packages/sdk/src/models/sync-revenue-cat-op.ts b/packages/sdk/src/models/sync-revenue-cat-op.ts new file mode 100644 index 000000000..922cf35b3 --- /dev/null +++ b/packages/sdk/src/models/sync-revenue-cat-op.ts @@ -0,0 +1,232 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from "zod/v4-mini"; +import { remap as remap$ } from "../lib/primitives.js"; +import { safeParse } from "../lib/schemas.js"; +import * as openEnums from "../types/enums.js"; +import { ClosedEnum, OpenEnum } from "../types/enums.js"; +import { Result as SafeParseResult } from "../types/fp.js"; +import * as types from "../types/primitives.js"; +import { SDKValidationError } from "./sdk-validation-error.js"; + +export type SyncRevenueCatGlobals = { + xApiVersion?: string | undefined; +}; + +/** + * "test" and "sandbox" both target the sandbox environment + */ +export const SyncRevenueCatEnv = { + Test: "test", + Sandbox: "sandbox", + Live: "live", +} as const; +/** + * "test" and "sandbox" both target the sandbox environment + */ +export type SyncRevenueCatEnv = ClosedEnum; + +export type SyncRevenueCatParams = { + organizationSlug: string; + /** + * "test" and "sandbox" both target the sandbox environment + */ + env: SyncRevenueCatEnv; + /** + * Plans to push. Omit to sync every plan in the org/env. + */ + productIds?: Array | undefined; +}; + +export const SyncRevenueCatStatus = { + Synced: "synced", + Skipped: "skipped", + Error: "error", +} as const; +export type SyncRevenueCatStatus = OpenEnum; + +export const SyncRevenueCatProduct = { + Created: "created", + Updated: "updated", + Exists: "exists", +} as const; +export type SyncRevenueCatProduct = OpenEnum; + +export const StorePush = { + Pushed: "pushed", + Failed: "failed", + Skipped: "skipped", +} as const; +export type StorePush = OpenEnum; + +export const SyncRevenueCatPrice = { + Set: "set", + Skipped: "skipped", + Failed: "failed", +} as const; +export type SyncRevenueCatPrice = OpenEnum; + +export type SyncRevenueCatApp = { + appId: string; + appType: string; + product: SyncRevenueCatProduct; + storePush?: StorePush | undefined; + price?: SyncRevenueCatPrice | undefined; + message?: string | undefined; +}; + +export type Result = { + planId: string; + status: SyncRevenueCatStatus; + storeIdentifier?: string | undefined; + apps?: Array | undefined; + message?: string | undefined; +}; + +/** + * OK + */ +export type SyncRevenueCatResponse = { + results: Array; +}; + +/** @internal */ +export const SyncRevenueCatEnv$outboundSchema: z.ZodMiniEnum< + typeof SyncRevenueCatEnv +> = z.enum(SyncRevenueCatEnv); + +/** @internal */ +export type SyncRevenueCatParams$Outbound = { + organization_slug: string; + env: string; + product_ids?: Array | undefined; +}; + +/** @internal */ +export const SyncRevenueCatParams$outboundSchema: z.ZodMiniType< + SyncRevenueCatParams$Outbound, + SyncRevenueCatParams +> = z.pipe( + z.object({ + organizationSlug: z.string(), + env: SyncRevenueCatEnv$outboundSchema, + productIds: z.optional(z.array(z.string())), + }), + z.transform((v) => { + return remap$(v, { + organizationSlug: "organization_slug", + productIds: "product_ids", + }); + }), +); + +export function syncRevenueCatParamsToJSON( + syncRevenueCatParams: SyncRevenueCatParams, +): string { + return JSON.stringify( + SyncRevenueCatParams$outboundSchema.parse(syncRevenueCatParams), + ); +} + +/** @internal */ +export const SyncRevenueCatStatus$inboundSchema: z.ZodMiniType< + SyncRevenueCatStatus, + unknown +> = openEnums.inboundSchema(SyncRevenueCatStatus); + +/** @internal */ +export const SyncRevenueCatProduct$inboundSchema: z.ZodMiniType< + SyncRevenueCatProduct, + unknown +> = openEnums.inboundSchema(SyncRevenueCatProduct); + +/** @internal */ +export const StorePush$inboundSchema: z.ZodMiniType = + openEnums.inboundSchema(StorePush); + +/** @internal */ +export const SyncRevenueCatPrice$inboundSchema: z.ZodMiniType< + SyncRevenueCatPrice, + unknown +> = openEnums.inboundSchema(SyncRevenueCatPrice); + +/** @internal */ +export const SyncRevenueCatApp$inboundSchema: z.ZodMiniType< + SyncRevenueCatApp, + unknown +> = z.pipe( + z.object({ + app_id: types.string(), + app_type: types.string(), + product: SyncRevenueCatProduct$inboundSchema, + store_push: types.optional(StorePush$inboundSchema), + price: types.optional(SyncRevenueCatPrice$inboundSchema), + message: types.optional(types.string()), + }), + z.transform((v) => { + return remap$(v, { + "app_id": "appId", + "app_type": "appType", + "store_push": "storePush", + }); + }), +); + +export function syncRevenueCatAppFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => SyncRevenueCatApp$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'SyncRevenueCatApp' from JSON`, + ); +} + +/** @internal */ +export const Result$inboundSchema: z.ZodMiniType = z.pipe( + z.object({ + plan_id: types.string(), + status: SyncRevenueCatStatus$inboundSchema, + store_identifier: types.optional(types.string()), + apps: types.optional( + z.array(z.lazy(() => SyncRevenueCatApp$inboundSchema)), + ), + message: types.optional(types.string()), + }), + z.transform((v) => { + return remap$(v, { + "plan_id": "planId", + "store_identifier": "storeIdentifier", + }); + }), +); + +export function resultFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => Result$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'Result' from JSON`, + ); +} + +/** @internal */ +export const SyncRevenueCatResponse$inboundSchema: z.ZodMiniType< + SyncRevenueCatResponse, + unknown +> = z.object({ + results: z.array(z.lazy(() => Result$inboundSchema)), +}); + +export function syncRevenueCatResponseFromJSON( + jsonString: string, +): SafeParseResult { + return safeParse( + jsonString, + (x) => SyncRevenueCatResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'SyncRevenueCatResponse' from JSON`, + ); +} diff --git a/packages/sdk/src/models/update-customer-op.ts b/packages/sdk/src/models/update-customer-op.ts index 0eb88bdc9..7357d5869 100644 --- a/packages/sdk/src/models/update-customer-op.ts +++ b/packages/sdk/src/models/update-customer-op.ts @@ -431,6 +431,20 @@ export const UpdateCustomerStatus = { */ export type UpdateCustomerStatus = OpenEnum; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export const UpdateCustomerSubscriptionScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export type UpdateCustomerSubscriptionScope = OpenEnum< + typeof UpdateCustomerSubscriptionScope +>; + export type UpdateCustomerSubscription = { /** * The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID. @@ -485,8 +499,26 @@ export type UpdateCustomerSubscription = { * Number of units of this subscription (for per-seat plans). */ quantity: number; + /** + * Whether this subscription is attached at the customer level or entity level. + */ + scope?: UpdateCustomerSubscriptionScope | undefined; }; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export const UpdateCustomerPurchaseScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export type UpdateCustomerPurchaseScope = OpenEnum< + typeof UpdateCustomerPurchaseScope +>; + export type UpdateCustomerPurchase = { plan?: Plan | undefined; /** @@ -505,6 +537,10 @@ export type UpdateCustomerPurchase = { * Number of units purchased. */ quantity: number; + /** + * Whether this purchase is attached at the customer level or entity level. + */ + scope?: UpdateCustomerPurchaseScope | undefined; }; /** @@ -1336,6 +1372,12 @@ export const UpdateCustomerStatus$inboundSchema: z.ZodMiniType< unknown > = openEnums.inboundSchema(UpdateCustomerStatus); +/** @internal */ +export const UpdateCustomerSubscriptionScope$inboundSchema: z.ZodMiniType< + UpdateCustomerSubscriptionScope, + unknown +> = openEnums.inboundSchema(UpdateCustomerSubscriptionScope); + /** @internal */ export const UpdateCustomerSubscription$inboundSchema: z.ZodMiniType< UpdateCustomerSubscription, @@ -1356,6 +1398,7 @@ export const UpdateCustomerSubscription$inboundSchema: z.ZodMiniType< current_period_start: types.nullable(types.number()), current_period_end: types.nullable(types.number()), quantity: types.number(), + scope: types.optional(UpdateCustomerSubscriptionScope$inboundSchema), }), z.transform((v) => { return remap$(v, { @@ -1383,6 +1426,12 @@ export function updateCustomerSubscriptionFromJSON( ); } +/** @internal */ +export const UpdateCustomerPurchaseScope$inboundSchema: z.ZodMiniType< + UpdateCustomerPurchaseScope, + unknown +> = openEnums.inboundSchema(UpdateCustomerPurchaseScope); + /** @internal */ export const UpdateCustomerPurchase$inboundSchema: z.ZodMiniType< UpdateCustomerPurchase, @@ -1394,6 +1443,7 @@ export const UpdateCustomerPurchase$inboundSchema: z.ZodMiniType< expires_at: types.nullable(types.number()), started_at: types.number(), quantity: types.number(), + scope: types.optional(UpdateCustomerPurchaseScope$inboundSchema), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/models/update-entity-op.ts b/packages/sdk/src/models/update-entity-op.ts index 701755fbe..29b3ae109 100644 --- a/packages/sdk/src/models/update-entity-op.ts +++ b/packages/sdk/src/models/update-entity-op.ts @@ -139,6 +139,20 @@ export const UpdateEntityStatus = { */ export type UpdateEntityStatus = OpenEnum; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export const UpdateEntitySubscriptionScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this subscription is attached at the customer level or entity level. + */ +export type UpdateEntitySubscriptionScope = OpenEnum< + typeof UpdateEntitySubscriptionScope +>; + export type UpdateEntitySubscription = { /** * The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID. @@ -193,8 +207,26 @@ export type UpdateEntitySubscription = { * Number of units of this subscription (for per-seat plans). */ quantity: number; + /** + * Whether this subscription is attached at the customer level or entity level. + */ + scope?: UpdateEntitySubscriptionScope | undefined; }; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export const UpdateEntityPurchaseScope = { + Customer: "customer", + Entity: "entity", +} as const; +/** + * Whether this purchase is attached at the customer level or entity level. + */ +export type UpdateEntityPurchaseScope = OpenEnum< + typeof UpdateEntityPurchaseScope +>; + export type UpdateEntityPurchase = { plan?: Plan | undefined; /** @@ -213,6 +245,10 @@ export type UpdateEntityPurchase = { * Number of units purchased. */ quantity: number; + /** + * Whether this purchase is attached at the customer level or entity level. + */ + scope?: UpdateEntityPurchaseScope | undefined; }; /** @@ -691,6 +727,12 @@ export const UpdateEntityStatus$inboundSchema: z.ZodMiniType< unknown > = openEnums.inboundSchema(UpdateEntityStatus); +/** @internal */ +export const UpdateEntitySubscriptionScope$inboundSchema: z.ZodMiniType< + UpdateEntitySubscriptionScope, + unknown +> = openEnums.inboundSchema(UpdateEntitySubscriptionScope); + /** @internal */ export const UpdateEntitySubscription$inboundSchema: z.ZodMiniType< UpdateEntitySubscription, @@ -711,6 +753,7 @@ export const UpdateEntitySubscription$inboundSchema: z.ZodMiniType< current_period_start: types.nullable(types.number()), current_period_end: types.nullable(types.number()), quantity: types.number(), + scope: types.optional(UpdateEntitySubscriptionScope$inboundSchema), }), z.transform((v) => { return remap$(v, { @@ -738,6 +781,12 @@ export function updateEntitySubscriptionFromJSON( ); } +/** @internal */ +export const UpdateEntityPurchaseScope$inboundSchema: z.ZodMiniType< + UpdateEntityPurchaseScope, + unknown +> = openEnums.inboundSchema(UpdateEntityPurchaseScope); + /** @internal */ export const UpdateEntityPurchase$inboundSchema: z.ZodMiniType< UpdateEntityPurchase, @@ -749,6 +798,7 @@ export const UpdateEntityPurchase$inboundSchema: z.ZodMiniType< expires_at: types.nullable(types.number()), started_at: types.number(), quantity: types.number(), + scope: types.optional(UpdateEntityPurchaseScope$inboundSchema), }), z.transform((v) => { return remap$(v, { diff --git a/packages/sdk/src/sdk/billing.ts b/packages/sdk/src/sdk/billing.ts index 060ff391a..cd86ce3ce 100644 --- a/packages/sdk/src/sdk/billing.ts +++ b/packages/sdk/src/sdk/billing.ts @@ -87,12 +87,13 @@ export class Billing extends ClientSDK { * @example * ```typescript * // Schedule a transition from a trial plan to a paid plan - * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1779977746466,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781187346466,"plans":[{"planId":"pro_plan"}]}] }); + * const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780512803523,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781722403523,"plans":[{"planId":"pro_plan"}]}] }); * ``` * * @param customerId - The ID of the customer to create the schedule for. * @param entityId - Optional entity ID for an entity-scoped schedule. (optional) * @param invoiceMode - Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase. (optional) + * @param discounts - List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code. (optional) * @param successUrl - URL to redirect to after successful checkout. (optional) * @param checkoutSessionParams - Additional parameters to pass into the creation of the Stripe checkout session. (optional) * @param redirectMode - Controls when to return a checkout URL for the immediate phase. 'always' forces a confirmation or checkout flow, 'if_required' only redirects when needed, and 'never' disables redirects. (optional) diff --git a/packages/sdk/src/sdk/platform.ts b/packages/sdk/src/sdk/platform.ts new file mode 100644 index 000000000..43d8c9737 --- /dev/null +++ b/packages/sdk/src/sdk/platform.ts @@ -0,0 +1,54 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import { platformGetRevenueCatKeys } from "../funcs/platform-get-revenue-cat-keys.js"; +import { platformLinkRevenueCat } from "../funcs/platform-link-revenue-cat.js"; +import { platformSyncRevenueCat } from "../funcs/platform-sync-revenue-cat.js"; +import { ClientSDK, RequestOptions } from "../lib/sdks.js"; +import * as models from "../models/index.js"; +import { unwrapAsync } from "../types/fp.js"; + +export class Platform extends ClientSDK { + /** + * Generate a RevenueCat OAuth URL for linking a project to an organization. + */ + async linkRevenueCat( + request: models.LinkRevenueCatParams, + options?: RequestOptions, + ): Promise { + return unwrapAsync(platformLinkRevenueCat( + this, + request, + options, + )); + } + + /** + * Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth. + */ + async syncRevenueCat( + request: models.SyncRevenueCatParams, + options?: RequestOptions, + ): Promise { + return unwrapAsync(platformSyncRevenueCat( + this, + request, + options, + )); + } + + /** + * Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app. + */ + async getRevenueCatKeys( + request: models.GetRevenueCatKeysParams, + options?: RequestOptions, + ): Promise { + return unwrapAsync(platformGetRevenueCatKeys( + this, + request, + options, + )); + } +} diff --git a/packages/sdk/src/sdk/sdk.ts b/packages/sdk/src/sdk/sdk.ts index 2b0197cb1..da01a652b 100644 --- a/packages/sdk/src/sdk/sdk.ts +++ b/packages/sdk/src/sdk/sdk.ts @@ -15,6 +15,7 @@ import { Entities } from "./entities.js"; import { Events } from "./events.js"; import { Features } from "./features.js"; import { Plans } from "./plans.js"; +import { Platform } from "./platform.js"; import { Referrals } from "./referrals.js"; import { Rewards } from "./rewards.js"; @@ -64,6 +65,11 @@ export class Autumn extends ClientSDK { return (this._rewards ??= new Rewards(this._options)); } + private _platform?: Platform; + get platform(): Platform { + return (this._platform ??= new Platform(this._options)); + } + /** * Checks whether a customer currently has enough balance to use a feature. * diff --git a/run.sh b/run.sh index 066ead246..8bcf8971c 100755 --- a/run.sh +++ b/run.sh @@ -2,7 +2,7 @@ # Root dispatcher: routes file paths under server/ to server/run.sh. set -e -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)" filename="$1" if [[ -z "$filename" ]]; then @@ -10,16 +10,56 @@ if [[ -z "$filename" ]]; then exit 1 fi -# Resolve to an absolute path so the prefix check works for relative inputs too. -if [[ "$filename" = /* ]]; then - resolved="$filename" -else - resolved="$(cd "$(dirname "$filename")" 2>/dev/null && pwd)/$(basename "$filename")" -fi +resolved="$(cd -P "$(dirname "$filename")" 2>/dev/null && pwd)/$(basename "$filename")" + +run_leaf_eval() { + local file="$1" + shift + local rel="${file#$repo_root/apps/leaf/}" + local args=("$@") + local filter="" + + if [[ "${args[0]}" =~ ^[0-9]+$ ]]; then + filter="$(bun "$repo_root/scripts/testScripts/getDescribeAtCursor.ts" "$file" "${args[0]}")" + args=("${args[@]:1}") + elif [[ "${args[0]}" == "-t" || "${args[0]}" == "--test-name-pattern" ]]; then + filter="${args[1]}" + args=("${args[@]:2}") + fi + + cd "$repo_root/apps/leaf" + if [[ -n "$filter" && "$filter" != ".*" ]]; then + exec env ENV_FILE=.env infisical run --env=dev --recursive -- "$repo_root/node_modules/.bin/braintrust" eval "$rel" --external-packages @mastra/mcp @mastra/core --filter "evalName=$filter" "${args[@]}" + fi + exec env ENV_FILE=.env infisical run --env=dev --recursive -- "$repo_root/node_modules/.bin/braintrust" eval "$rel" --external-packages @mastra/mcp @mastra/core "${args[@]}" +} if [[ "$resolved" == "$repo_root/server/"* ]]; then exec "$repo_root/server/run.sh" "$resolved" "${@:2}" fi +if [[ "$resolved" == "$repo_root/apps/leaf/tests/evals/"* && "$resolved" == *".eval.ts" ]]; then + run_leaf_eval "$resolved" "${@:2}" +fi + +if [[ "$resolved" == "$repo_root/apps/leaf/tests/"* && "$resolved" == *".test.ts" ]]; then + cd "$repo_root/apps/leaf" + rel="${resolved#$repo_root/apps/leaf/}" + if [[ "${2:-}" =~ ^[0-9]+$ ]]; then + test_name="$(bun "$repo_root/scripts/testScripts/getDescribeAtCursor.ts" "$resolved" "$2")" + exec env ENV_FILE=.env infisical run --env=dev --recursive -- bun test --timeout 0 "$rel" -t "$test_name" + fi + exec env ENV_FILE=.env infisical run --env=dev --recursive -- bun test "$rel" "${@:2}" +fi + +if [[ "$resolved" == "$repo_root/packages/mcp/tests/"* && "$resolved" == *".test.ts" ]]; then + cd "$repo_root/packages/mcp" + rel="${resolved#$repo_root/packages/mcp/}" + if [[ "$resolved" == "$repo_root/packages/mcp/tests/evals/"* ]]; then + exec env ENV_FILE=.env infisical run --env=dev --recursive -- bun test "$rel" "${@:2}" + fi + exec bun test "$rel" "${@:2}" +fi + echo "no router for: $resolved" >&2 exit 1 diff --git a/scripts/axiom/cli.ts b/scripts/axiom/cli.ts new file mode 100644 index 000000000..4e274a1f0 --- /dev/null +++ b/scripts/axiom/cli.ts @@ -0,0 +1,45 @@ +/** + * Axiom provisioning CLI. Secrets (AXIOM_ADMIN_TOKEN) are injected by infisical + * via the package.json scripts: + * + * bun axiom # dev (infisical --env=dev) + * bun axiom:prod # prod (infisical --env=prod) + * + * Add a new action by registering it in the `actions` map below. + */ +import "dotenv/config"; +import { createLeafDataset } from "./createLeafDataset.js"; + +const actions = { + "create-leaf": createLeafDataset, +} satisfies Record Promise>; + +type Action = keyof typeof actions; + +const isAction = (value: string | undefined): value is Action => + value !== undefined && Object.hasOwn(actions, value); + +const usage = () => + [ + "Usage: bun axiom (or bun axiom:prod )", + "", + "Actions:", + ...Object.keys(actions).map((action) => ` - ${action}`), + ].join("\n"); + +const main = async () => { + const action = process.argv[2]; + if (!isAction(action)) { + console.error(usage()); + process.exit(1); + } + + try { + await actions[action](); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +}; + +await main(); diff --git a/scripts/axiom/createLeafDataset.ts b/scripts/axiom/createLeafDataset.ts new file mode 100644 index 000000000..c791c9523 --- /dev/null +++ b/scripts/axiom/createLeafDataset.ts @@ -0,0 +1,149 @@ +/** + * Idempotently provisions the Axiom `leaf` dataset used for Leaf runtime logs + * and MCP usage analytics, and configures its map fields. + * + * Map fields ("vacuum" the unpredictable nested payloads into a single column): + * Tool payloads, req/res bodies, and per-log details have open-ended shape. + * Every distinct top-level key would otherwise become its own mapped field and + * quickly blow Axiom's per-dataset field limit. These map fields keep nested + * keys inside one field each while staying queryable. + * + * Run via the Axiom CLI (resolves AXIOM_ADMIN_TOKEN from infisical): + * bun axiom create-leaf # dev + * bun axiom:prod create-leaf # prod + * + * Notes: + * - AXIOM_ADMIN_TOKEN must be a personal API token with dataset create/update + * scope, NOT the `xaat-` ingest token used at runtime. + * - Safe to re-run: dataset creation tolerates "already exists", and existing + * map fields are read before missing fields are created. + */ + +const AXIOM_BASE = "https://api.axiom.co/v2"; +const DATASET = "leaf"; +const DATASET_DESCRIPTION = "Leaf runtime logs and MCP usage analytics"; + +// Nested, open-ended payloads stored as map fields to stay under the field +// limit. Keep this list minimal — only genuinely high-cardinality objects. +const MAP_FIELDS = [ + "context", + "data", + "extras", + "input", + "output", + "req", + "res", +]; + +const authHeaders = (token: string) => ({ + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", +}); + +const createDataset = async (token: string) => { + const res = await fetch(`${AXIOM_BASE}/datasets`, { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ + name: DATASET, + description: DATASET_DESCRIPTION, + }), + }); + + if (res.ok) { + console.log(` + created dataset \`${DATASET}\``); + return; + } + + // 409 (or a 400 mentioning existence) means it's already there — fine. + const text = await res.text(); + if (res.status === 409 || /exist/i.test(text)) { + console.log(` = dataset \`${DATASET}\` already exists`); + return; + } + + throw new Error(`Failed to create dataset: ${res.status} ${text}`); +}; + +const getMapFields = async (token: string) => { + const res = await fetch( + `${AXIOM_BASE}/datasets/${encodeURIComponent(DATASET)}/mapfields`, + { + method: "GET", + headers: authHeaders(token), + }, + ); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`Failed to list map fields: ${res.status} ${text}`); + } + + const parsed = JSON.parse(text) as unknown; + if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string")) { + throw new Error(`Unexpected map fields response: ${text}`); + } + + return new Set(parsed); +}; + +const setMapField = async ({ + existing, + name, + token, +}: { + existing: Set; + name: string; + token: string; +}) => { + if (existing.has(name)) { + console.log(` = map field: ${name} (already set)`); + return; + } + + const res = await fetch( + `${AXIOM_BASE}/datasets/${encodeURIComponent(DATASET)}/mapfields`, + { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ name }), + }, + ); + + const text = await res.text(); + if (res.ok) { + existing.add(name); + console.log(` + map field: ${name}`); + return; + } + + if (/exist/i.test(text)) { + existing.add(name); + console.log(` = map field: ${name} (already set)`); + return; + } + + throw new Error(`Failed to set map field "${name}": ${res.status} ${text}`); +}; + +const setMapFields = async (token: string) => { + const existing = await getMapFields(token); + for (const name of MAP_FIELDS) { + await setMapField({ existing, name, token }); + } +}; + +/** Provisions the `leaf` dataset and its map fields. */ +export const createLeafDataset = async () => { + const token = process.env.AXIOM_ADMIN_TOKEN; + if (!token) { + throw new Error( + "AXIOM_ADMIN_TOKEN env var is required (personal API token, not xaat-* ingest token)", + ); + } + + console.log(`Provisioning Axiom dataset \`${DATASET}\`...`); + await createDataset(token); + await setMapFields(token); + console.log("\nDone."); +}; diff --git a/scripts/axiom/setOtelVirtualFields.ts b/scripts/axiom/setOtelVirtualFields.ts index 051a4ed5e..8c4334f7e 100644 --- a/scripts/axiom/setOtelVirtualFields.ts +++ b/scripts/axiom/setOtelVirtualFields.ts @@ -4,10 +4,10 @@ * (`req.url`, `context.org_slug`, `statusCode`, etc.). * * Usage: - * AXIOM_API_TOKEN= bun scripts/axiom/setOtelVirtualFields.ts + * AXIOM_ADMIN_TOKEN= bun scripts/axiom/setOtelVirtualFields.ts * * Notes: - * - AXIOM_API_TOKEN must be a personal API token with dataset-write scope, + * - AXIOM_ADMIN_TOKEN must be a personal API token with dataset-write scope, * NOT the `xaat-` ingest token used by the server. * - Safe to re-run; existing fields with matching names are updated in place. */ @@ -149,10 +149,10 @@ type ExistingVField = { dataset: string; }; -const token = process.env.AXIOM_API_TOKEN; +const token = process.env.AXIOM_ADMIN_TOKEN; if (!token) { console.error( - "AXIOM_API_TOKEN env var is required (personal API token, not xaat-* ingest token)", + "AXIOM_ADMIN_TOKEN env var is required (personal API token, not xaat-* ingest token)", ); process.exit(1); } diff --git a/scripts/db/README.md b/scripts/db/README.md index 167e2ae82..7c5f6a2be 100644 --- a/scripts/db/README.md +++ b/scripts/db/README.md @@ -13,7 +13,9 @@ bun db mark-applied [--env=dev|staging|prod] # seed drizzle.__drizzle_migration bun db rebase # auto-resolve a local migration that collided with origin/dev ``` -`migrate` and `migrate:dry` also run a safety check that **refuses to apply any pending migration containing `CREATE INDEX`, `DROP INDEX`, or `REINDEX` without `CONCURRENTLY`**. Those DDL statements take an ACCESS EXCLUSIVE lock and can block reads/writes on busy tables. To get through the check: either rewrite the SQL with `CONCURRENTLY` and apply manually + `mark-applied`, or add `.concurrently()` to the index in your schema and regenerate. +`migrate` applies pending migrations directly via `pg` (using drizzle's own `readMigrationFiles` for parsing + hashing), so the tracking table stays compatible with drizzle and `mark-applied`. Unlike drizzle's built-in `migrate()` — which wraps every migration in a single transaction — our executor runs any statement containing `CONCURRENTLY` in autocommit, so `CREATE INDEX CONCURRENTLY` migrations apply normally. Everything else still runs in a per-migration transaction. + +`migrate` and `migrate:dry` also run a safety check that **refuses to apply any pending migration containing `CREATE INDEX`, `DROP INDEX`, or `REINDEX` without `CONCURRENTLY`**. Those DDL statements take an ACCESS EXCLUSIVE lock and can block reads/writes on busy tables. To get through the check, make the index concurrent: rewrite the SQL with `CONCURRENTLY`, or add `.concurrently()` to the index in your schema and regenerate. Concurrent index migrations then apply through `bun db migrate` with no manual step. `--env` defaults to `dev`. `generate` and `rebase` never touch a DB and don't take `--env`. @@ -133,17 +135,20 @@ scripts/db/ ├── commands/ │ ├── help.ts │ ├── generate.ts # passthrough to `bun -F @autumn/shared db:generate` -│ ├── migrate.ts # passthrough to `bun -F @autumn/shared db:migrate` +│ ├── migrate.ts # applies pending migrations (CONCURRENTLY-aware executor) │ ├── markApplied.ts # seeds drizzle.__drizzle_migrations │ └── rebase.ts # auto-resolves duplicate-idx conflicts ├── helpers/ +│ ├── applyMigrations.ts # per-migration executor: autocommit for CONCURRENTLY, tx otherwise │ ├── env.ts # --env parsing + infisical wrap + DATABASE_URL host extraction +│ ├── pendingMigrations.ts # computes pending set from _journal.json vs tracking table +│ ├── safetyCheck.ts # flags non-CONCURRENTLY index DDL │ ├── paths.ts # canonical paths to shared/drizzle/ and meta/ │ └── spawn.ts # thin child_process.spawn wrapper └── pull.ts # unrelated — customer data pull (legacy) ``` -`shared/package.json` still owns the implementation of `db:generate` and `db:migrate` (which are what the CLI shells out to under the hood). The unified `bun db` interface lives at the repo root. +`shared/package.json` still owns `db:generate` (which `generate` shells out to). `migrate` no longer delegates to drizzle-kit — it reads the committed migrations with drizzle's `readMigrationFiles` and applies them itself so `CONCURRENTLY` works. The unified `bun db` interface lives at the repo root. --- diff --git a/scripts/db/commands/migrate.ts b/scripts/db/commands/migrate.ts index 5b70dff2e..a2e877bb7 100644 --- a/scripts/db/commands/migrate.ts +++ b/scripts/db/commands/migrate.ts @@ -1,7 +1,8 @@ +import { readMigrationFiles } from "drizzle-orm/migrator"; import pg from "pg"; -import { run } from "../helpers/spawn.ts"; -import { REPO_ROOT } from "../helpers/paths.ts"; +import { MIGRATIONS_DIR } from "../helpers/paths.ts"; import { type Env, targetHost, wrapInInfisical } from "../helpers/env.ts"; +import { applyMigration } from "../helpers/applyMigrations.ts"; import { getPendingMigrations, type PendingMigration, @@ -82,10 +83,41 @@ export async function cmdMigrate( process.exit(1); } - const { code } = await run("bun", ["-F", "@autumn/shared", "db:migrate"], { - cwd: REPO_ROOT, - }); - process.exit(code); + await applyPending(databaseUrl, pending); +} + +/** + * Applies pending migrations using drizzle's own readMigrationFiles (so hashes + * match the tracking table drizzle/mark-applied write) but our own executor, + * which — unlike drizzle's migrate() — can run CONCURRENTLY outside a transaction. + */ +async function applyPending( + databaseUrl: string, + pending: PendingMigration[], +): Promise { + const pendingByMillis = new Map(pending.map((m) => [m.when, m.tag])); + const toApply = readMigrationFiles({ migrationsFolder: MIGRATIONS_DIR }) + .filter((m) => pendingByMillis.has(m.folderMillis)) + .sort((a, b) => a.folderMillis - b.folderMillis); + + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + for (const migration of toApply) { + const tag = pendingByMillis.get(migration.folderMillis) ?? "migration"; + const { transactional } = await applyMigration(client, migration); + console.log(` applied ${tag}${transactional ? "" : " (concurrent)"}`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`\nmigration failed: ${message}`); + process.exitCode = 1; + return; + } finally { + await client.end(); + } + + console.log(`done — applied ${toApply.length} migration(s)`); } type FlaggedBlocker = { diff --git a/scripts/db/helpers/applyMigrations.ts b/scripts/db/helpers/applyMigrations.ts new file mode 100644 index 000000000..c9da075cd --- /dev/null +++ b/scripts/db/helpers/applyMigrations.ts @@ -0,0 +1,60 @@ +import type { MigrationMeta } from "drizzle-orm/migrator"; +import type pg from "pg"; + +// CONCURRENTLY (e.g. CREATE INDEX CONCURRENTLY) cannot run inside a transaction +// block. drizzle's own migrate() wraps everything in one transaction, so those +// statements are applied here in autocommit instead. +const NON_TRANSACTIONAL = /\bCONCURRENTLY\b/i; + +const TRACKING_TABLE = `"drizzle"."__drizzle_migrations"`; + +async function recordApplied( + client: pg.Client, + migration: MigrationMeta, +): Promise { + await client.query( + `INSERT INTO ${TRACKING_TABLE} ("hash", "created_at") VALUES ($1, $2)`, + [migration.hash, migration.folderMillis], + ); +} + +export type ApplyResult = { transactional: boolean }; + +/** + * Applies one migration's statements. If any statement is non-transactional + * (CONCURRENTLY), the whole migration runs in autocommit; otherwise it's wrapped + * in a single transaction so DDL + tracking row commit atomically — matching + * drizzle's own per-migration semantics. + */ +export async function applyMigration( + client: pg.Client, + migration: MigrationMeta, +): Promise { + const statements = migration.sql + .map((statement) => statement.trim()) + .filter(Boolean); + const nonTransactional = statements.some((statement) => + NON_TRANSACTIONAL.test(statement), + ); + + if (nonTransactional) { + for (const statement of statements) { + await client.query(statement); + } + await recordApplied(client, migration); + return { transactional: false }; + } + + await client.query("BEGIN"); + try { + for (const statement of statements) { + await client.query(statement); + } + await recordApplied(client, migration); + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } + return { transactional: true }; +} diff --git a/scripts/dev.ts b/scripts/dev.ts index 3c3b996c4..d19ac6ee8 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -18,11 +18,15 @@ const SERVER_PORT = process.env.SERVER_PORT const CHECKOUT_PORT = process.env.CHECKOUT_PORT ? Number.parseInt(process.env.CHECKOUT_PORT, 10) : 3001 + portOffset; -const MCP_PORT = process.env.MCP_PORT - ? Number.parseInt(process.env.MCP_PORT, 10) - : 2718 + portOffset; +const CHAT_PORT = process.env.CHAT_PORT + ? Number.parseInt(process.env.CHAT_PORT, 10) + : 3099 + portOffset; const LOCAL_CLIENT_URL = `http://localhost:${VITE_PORT}`; const LOCAL_SERVER_URL = `http://localhost:${SERVER_PORT}`; +const LOCAL_CHAT_URL = `http://localhost:${CHAT_PORT}`; +const publicTunnelUrl = process.env.NGROK_URL?.replace(/\/$/, ""); +const CHAT_URL = process.env.CHAT_URL ?? publicTunnelUrl ?? LOCAL_CHAT_URL; +const SLACK_BOT_URL = process.env.SLACK_BOT_URL ?? publicTunnelUrl ?? CHAT_URL; const skipWorkers = false; const isProductionMode = process.argv.includes("--production"); @@ -35,6 +39,12 @@ const viteAppEnv = envFile.includes(".env.prod") const useLocalAuthUrls = viteAppEnv === "dev" && !isProductionMode; const localUrl = (value: string | undefined, fallback: string) => value && !value.includes(".useautumn.com") ? value : fallback; +const SLACK_REDIRECT_URI = useLocalAuthUrls + ? localUrl( + process.env.SLACK_REDIRECT_URI, + `${SLACK_BOT_URL}/slack/oauth/callback`, + ) + : (process.env.SLACK_REDIRECT_URI ?? `${SLACK_BOT_URL}/slack/oauth/callback`); /** * Read environment variable from .env file @@ -57,30 +67,6 @@ function getEnvVariable(filePath: string, key: string): string | null { return null; } -function getPackageDependencyVersion({ - projectRoot, - packageName, -}: { - projectRoot: string; - packageName: string; -}): string { - const packageJson = JSON.parse( - readFileSync(join(projectRoot, "package.json"), "utf-8"), - ) as { - dependencies?: Record; - devDependencies?: Record; - }; - const version = - packageJson.dependencies?.[packageName] ?? - packageJson.devDependencies?.[packageName]; - - if (!version) { - throw new Error(`Missing ${packageName} in package.json`); - } - - return version; -} - function killPorts({ ports }: { ports: number[] }) { if (process.platform === "win32") { return; @@ -130,7 +116,7 @@ async function startDev() { } else { console.log("Cleaning up local dev ports...\n"); killPorts({ - ports: [VITE_PORT, SERVER_PORT, CHECKOUT_PORT, MCP_PORT], + ports: [VITE_PORT, SERVER_PORT, CHECKOUT_PORT, CHAT_PORT], }); } @@ -165,14 +151,11 @@ async function startDev() { console.log(` vite: http://localhost:${VITE_PORT}`); console.log(` server: http://localhost:${SERVER_PORT}`); console.log(` checkout: http://localhost:${CHECKOUT_PORT}`); - console.log(` mcp: http://localhost:${MCP_PORT}/mcp\n`); + console.log(` leaf: http://localhost:${CHAT_PORT}/health`); + console.log(` mcp: http://localhost:${CHAT_PORT}/mcp\n`); // Use cmd on Windows, sh on Unix const isWindows = process.platform === "win32"; - const triggerDevVersion = getPackageDependencyVersion({ - projectRoot, - packageName: "trigger.dev", - }); let shellArgs: string[]; if (serverOnly) { @@ -216,11 +199,10 @@ async function startDev() { if (worktreeNum === 1) { names.push("trigger"); colors.push("cyan"); - cmds.push( - isWindows - ? `"bunx trigger.dev@${triggerDevVersion} dev"` - : `"bunx trigger.dev@${triggerDevVersion} dev"`, - ); + // Use the locally-installed (pinned) trigger.dev CLI. Passing + // `@` makes bunx fetch a fresh copy into a temp dir, + // which can be broken/incomplete (ERR_MODULE_NOT_FOUND). + cmds.push(isWindows ? `"bunx trigger.dev dev"` : `"bunx trigger.dev dev"`); } names.push("vite", "checkout"); @@ -234,10 +216,13 @@ async function startDev() { : `"cd apps/checkout && VITE_PORT=${CHECKOUT_PORT} bun dev"`, ); - names.push("mcp"); - colors.push("white"); - const mcpServeCmd = "bun --watch apps/mcp-server/src/index.ts"; - cmds.push(isWindows ? `"${mcpServeCmd}"` : `"${mcpServeCmd}"`); + names.push("leaf"); + colors.push("gray"); + cmds.push( + isWindows + ? `"cd apps/leaf && set PORT=${CHAT_PORT} && bun dev"` + : `"cd apps/leaf && PORT=${CHAT_PORT} bun dev"`, + ); // Stripe CLI webhook tunnel — silently skip if CLI absent. // Forwards to the direct localhost port (not portless) so we avoid CA trust issues. @@ -281,13 +266,19 @@ async function startDev() { VITE_PORT: VITE_PORT.toString(), SERVER_PORT: SERVER_PORT.toString(), CHECKOUT_PORT: CHECKOUT_PORT.toString(), - MCP_PORT: MCP_PORT.toString(), - MCP_DEBUG_PENDING_ACTIONS: - process.env.MCP_DEBUG_PENDING_ACTIONS ?? "1", + CHAT_PORT: CHAT_PORT.toString(), + MCP_DEBUG_PENDING_ACTIONS: process.env.MCP_DEBUG_PENDING_ACTIONS ?? "1", MCP_SERVER_URL: - process.env.MCP_SERVER_URL ?? `http://localhost:${SERVER_PORT}`, + process.env.MCP_SERVER_URL ?? `http://localhost:${CHAT_PORT}`, + CHAT_SERVER_URL: + process.env.CHAT_SERVER_URL ?? `http://localhost:${CHAT_PORT}`, MCP_RESOURCE_URLS: - process.env.MCP_RESOURCE_URLS ?? `http://localhost:${MCP_PORT}/mcp`, + process.env.MCP_RESOURCE_URLS ?? `http://localhost:${CHAT_PORT}/mcp`, + AUTUMN_API_URL: process.env.AUTUMN_API_URL ?? LOCAL_SERVER_URL, + CHAT_URL, + SLACK_BOT_URL, + SLACK_REDIRECT_URI, + DISCORD_BOT_URL: process.env.DISCORD_BOT_URL ?? LOCAL_CHAT_URL, VITE_APP_ENV: viteAppEnv, ...(useLocalAuthUrls && { CLIENT_URL: localUrl(process.env.CLIENT_URL, LOCAL_CLIENT_URL), diff --git a/scripts/devServices/index.ts b/scripts/devServices/index.ts index 85492dd47..009b58a9d 100644 --- a/scripts/devServices/index.ts +++ b/scripts/devServices/index.ts @@ -1,16 +1,27 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { createConnection } from "node:net"; +import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import inquirer from "inquirer"; const rootDir = join(dirname(fileURLToPath(import.meta.url)), "../.."); const composeFile = join(rootDir, "docker", "dev-services.compose.yml"); const composeProject = "autumn-dev-services"; +const zshrcFile = join(homedir(), ".zshrc"); +const ngrokConfigFiles = [ + join(homedir(), "Library", "Application Support", "ngrok", "ngrok.yml"), + join(homedir(), ".config", "ngrok", "ngrok.yml"), + join(homedir(), ".ngrok2", "ngrok.yml"), +]; const localConfig = { postgresPort: 5432, + ngrokApiPort: 4040, redisStackPort: 6379, dragonflyPort: 6380, databaseUrl: "postgresql://postgres:postgres@localhost:5432/autumn", + chatStateDatabaseUrl: "postgresql://postgres:postgres@localhost:5432/chat", cacheUrl: "redis://localhost:6379", dragonflyUrl: "redis://localhost:6380", }; @@ -22,6 +33,103 @@ const log = (message: string) => console.log(`[dev:services] ${message}`); const composeEnv = { ...process.env }; +const readShellConfigEnvVar = ({ key }: { key: string }) => { + if (!existsSync(zshrcFile)) return; + + const match = readFileSync(zshrcFile, "utf-8").match( + new RegExp(`^\\s*(?:export\\s+)?${key}=(.+?)\\s*$`, "m"), + ); + return match?.[1]?.trim().replace(/^["']|["']$/g, ""); +}; + +const writeShellConfigEnvVar = ({ + key, + value, +}: { + key: string; + value: string; +}) => { + const current = existsSync(zshrcFile) + ? readFileSync(zshrcFile, "utf-8").split("\n") + : []; + let updated = false; + const lines = current.map((line) => { + if (new RegExp(`^\\s*(?:export\\s+)?${key}=`).test(line)) { + updated = true; + return `export ${key}=${value}`; + } + return line; + }); + if (!updated) lines.push(`export ${key}=${value}`); + + writeFileSync(zshrcFile, `${lines.join("\n").replace(/\n+$/, "")}\n`); +}; + +const readNgrokAuthtokenFromConfig = () => { + for (const configFile of ngrokConfigFiles) { + if (!existsSync(configFile)) continue; + + const match = readFileSync(configFile, "utf-8").match( + /^\s*authtoken:\s*(.+?)\s*$/m, + ); + const token = match?.[1]?.trim().replace(/^["']|["']$/g, ""); + if (token) return token; + } +}; + +const getDomainFromUrl = ({ url }: { url: string }) => { + const normalizedUrl = url.startsWith("http") ? url : `https://${url}`; + return new URL(normalizedUrl).host; +}; + +const configureNgrokUrl = () => { + const ngrokUrl = composeEnv.NGROK_URL; + if (!ngrokUrl) { + throw new Error( + "NGROK_URL is required for dev services. It should be injected from Infisical dev secrets.", + ); + } + + composeEnv.NGROK_DOMAIN = getDomainFromUrl({ url: ngrokUrl }); +}; + +const configureNgrokToken = async () => { + if (composeEnv.NGROK_AUTHTOKEN) return; + + const shellToken = readShellConfigEnvVar({ key: "NGROK_AUTHTOKEN" }); + if (shellToken) { + composeEnv.NGROK_AUTHTOKEN = shellToken; + return; + } + + const configuredToken = readNgrokAuthtokenFromConfig(); + if (configuredToken) { + composeEnv.NGROK_AUTHTOKEN = configuredToken; + writeShellConfigEnvVar({ + key: "NGROK_AUTHTOKEN", + value: configuredToken, + }); + log(`saved NGROK_AUTHTOKEN from local ngrok config to ${zshrcFile}`); + return; + } + + log(`NGROK_AUTHTOKEN will be saved to ${zshrcFile} after first entry`); + const { token } = await inquirer.prompt<{ token: string }>([ + { + type: "password", + name: "token", + message: "NGROK_AUTHTOKEN", + mask: "*", + validate: (value: string) => + Boolean(value.trim()) || "NGROK_AUTHTOKEN is required", + }, + ]); + + composeEnv.NGROK_AUTHTOKEN = token.trim(); + writeShellConfigEnvVar({ key: "NGROK_AUTHTOKEN", value: token.trim() }); + log(`saved NGROK_AUTHTOKEN to ${zshrcFile}`); +}; + const run = ({ cmd, args, @@ -53,6 +161,8 @@ const composeArgs = ({ args }: { args: string[] }) => [ composeProject, "-f", composeFile, + "--profile", + "ngrok", ...args, ]; @@ -166,17 +276,106 @@ const doctor = async () => { if (results.some((result) => !result)) process.exit(1); }; +const psql = ({ args, quiet = false }: { args: string[]; quiet?: boolean }) => + dockerCompose({ + args: ["exec", "-T", "postgres", "psql", "-U", "postgres", ...args], + quiet, + }); + +const ensureChatDatabase = () => { + const result = psql({ + args: [ + "-d", + "postgres", + "-tAc", + "SELECT 1 FROM pg_database WHERE datname = 'chat'", + ], + quiet: true, + }); + const exists = new TextDecoder().decode(result.stdout).trim() === "1"; + if (exists) { + log("chat database already exists"); + return; + } + + log("creating chat database"); + psql({ args: ["-d", "postgres", "-c", "CREATE DATABASE chat"] }); +}; + +const ensureNgrokRunning = () => { + const result = dockerCompose({ + args: ["ps", "--status", "running", "--services", "ngrok"], + quiet: true, + }); + const services = new TextDecoder().decode(result.stdout).trim().split("\n"); + if (!services.includes("ngrok")) { + const logs = dockerCompose({ + args: ["logs", "--tail", "40", "ngrok"], + quiet: true, + allowFailure: true, + }); + const stderr = new TextDecoder().decode(logs.stderr).trim(); + const stdout = new TextDecoder().decode(logs.stdout).trim(); + throw new Error( + [ + "ngrok container is not running", + stdout || stderr ? `${stdout}\n${stderr}`.trim() : undefined, + ] + .filter(Boolean) + .join("\n"), + ); + } +}; + +const getNgrokUrl = async () => { + for (let attempt = 0; attempt < 60; attempt++) { + try { + const response = await fetch( + `http://127.0.0.1:${localConfig.ngrokApiPort}/api/tunnels`, + ); + const data = (await response.json()) as { + tunnels?: Array<{ public_url?: string; proto?: string }>; + }; + const tunnel = data.tunnels?.find( + (tunnel) => tunnel.proto === "https" && tunnel.public_url, + ); + if (tunnel?.public_url) return tunnel.public_url.replace(/\/$/, ""); + } catch { + // ngrok's local API is not ready yet. + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error("ngrok did not expose a public URL on :4040"); +}; + const up = async () => { + configureNgrokUrl(); + await configureNgrokToken(); log("starting Docker services"); - dockerCompose({ args: ["up", "-d", "--remove-orphans"] }); + dockerCompose({ + args: ["rm", "-sf", "ngrok"], + allowFailure: true, + }); + dockerCompose({ + args: ["up", "-d", "--remove-orphans"], + }); await Promise.all([ waitForTcp({ port: localConfig.postgresPort, label: "Postgres" }), waitForTcp({ port: localConfig.redisStackPort, label: "Redis Stack" }), waitForTcp({ port: localConfig.dragonflyPort, label: "Dragonfly" }), + waitForTcp({ port: localConfig.ngrokApiPort, label: "ngrok" }), ]); + ensureChatDatabase(); await doctor(); + ensureNgrokRunning(); + + const ngrokUrl = await getNgrokUrl(); + log(`ngrok URL: ${ngrokUrl}`); + log(`export NGROK_URL=${ngrokUrl}`); }; const down = () => { @@ -209,7 +408,7 @@ const help = () => { console.log(`Usage: bun dev:services Commands: - up Start local Postgres, Redis Stack, and Dragonfly + up Start local Postgres, Redis Stack, Dragonfly, and ngrok down Stop local services and keep all data down --volumes Stop services and delete Redis/Dragonfly data down --postgres Stop services and delete Postgres data @@ -219,6 +418,8 @@ Commands: Local service values: DATABASE_URL=${localConfig.databaseUrl} + CHAT_STATE_DATABASE_URL=${localConfig.chatStateDatabaseUrl} + NGROK_URL= CACHE_URL=${localConfig.cacheUrl} CACHE_URL_US_EAST=${localConfig.cacheUrl} CACHE_V2_DRAGONFLY_URL=${localConfig.dragonflyUrl} diff --git a/scripts/dw/README.md b/scripts/dw/README.md index 9bd7a09f9..ab526ff7b 100644 --- a/scripts/dw/README.md +++ b/scripts/dw/README.md @@ -36,6 +36,7 @@ bun dw teardown # full cleanup of this worktree bun dw teardown --all # full cleanup of every agent worktree bun dw disable # rename .env.local -> .env.local.disabled (fall back to canonical env) bun dw enable # rename .env.local.disabled -> .env.local +bun dw admin # set the better-auth global role to 'admin' for every user in this worktree's DB ``` ## Subcommands @@ -129,6 +130,15 @@ Inverse of `disable` — restores each `.env.local.disabled` to `.env.local`. bun dw enable ``` +### `bun dw admin` +Sets the better-auth **global** user role to `admin` for every row in the `user` table of this worktree's DB, granting the superuser scope locally (the `/admin` routes + impersonation). Org membership roles (`member` table) are left untouched. + +```sh +bun dw admin +``` + +Targets `databaseUrl` from the registry entry for the current worktree, falling back to `DATABASE_URL`. **Refuses to run against production** via the shared `assertNotProductionDb` guard (connection strings containing `us-east-2`). + ### `bun dw teardown` Full cleanup of the current worktree: deletes Neon branch, unregisters portless aliases, kills tmux session, removes Docker compose stack, removes `.env.local` files, and deletes the registry entry. If no other agent worktrees remain, also stops emulate + portless daemons. diff --git a/scripts/dw/commands/admin.ts b/scripts/dw/commands/admin.ts new file mode 100644 index 000000000..7a4c0f794 --- /dev/null +++ b/scripts/dw/commands/admin.ts @@ -0,0 +1,39 @@ +import { assertNotProductionDb } from "../../../server/src/db/dbUtils.ts"; +import { getCurrentWorktree } from "../helpers/git.ts"; +import { loadRegistry } from "../helpers/registry.ts"; +import { fatal, log, sh } from "../helpers/shell.ts"; + +const UPDATE_SQL = `UPDATE "user" SET role = 'admin';`; + +function describeTarget(url: string): string { + try { + return new URL(url).host; + } catch { + return "(unparseable connection string)"; + } +} + +// Sets the better-auth global role to 'admin' for every user in the current +// worktree's DB, which grants the superuser scope locally. Org membership +// roles (the `member` table) are intentionally left untouched. +export function cmdAdmin(): void { + const cwd = getCurrentWorktree(); + const entry = loadRegistry()[cwd]; + const url = entry?.databaseUrl || process.env.DATABASE_URL || ""; + if (!url) { + fatal("no DATABASE_URL for this worktree — run 'bun dw setup' first"); + } + + try { + assertNotProductionDb(url); + } catch (err) { + fatal(err instanceof Error ? err.message : String(err)); + } + + log(`making all users admin on ${describeTarget(url)}`); + const res = sh("psql", [url, "-v", "ON_ERROR_STOP=1", "-c", UPDATE_SQL]); + if (res.code !== 0) { + fatal(`psql failed: ${res.stderr || res.stdout}`); + } + log(res.stdout || "done"); +} diff --git a/scripts/dw/index.ts b/scripts/dw/index.ts index e65323e95..00e9ada2d 100644 --- a/scripts/dw/index.ts +++ b/scripts/dw/index.ts @@ -1,15 +1,16 @@ -import { fatal } from "./helpers/shell.ts"; -import { cmdDefault } from "./commands/default.ts"; -import { cmdSetup } from "./commands/setup.ts"; -import { cmdRun } from "./commands/run.ts"; -import { cmdTeardown } from "./commands/teardown.ts"; -import { cmdList } from "./commands/list.ts"; -import { cmdReset } from "./commands/reset.ts"; -import { cmdLogs } from "./commands/logs.ts"; +import { cmdAdmin } from "./commands/admin.ts"; import { cmdAttach } from "./commands/attach.ts"; -import { cmdIdentify } from "./commands/identify.ts"; -import { cmdEnable } from "./commands/enable.ts"; +import { cmdDefault } from "./commands/default.ts"; import { cmdDisable } from "./commands/disable.ts"; +import { cmdEnable } from "./commands/enable.ts"; +import { cmdIdentify } from "./commands/identify.ts"; +import { cmdList } from "./commands/list.ts"; +import { cmdLogs } from "./commands/logs.ts"; +import { cmdReset } from "./commands/reset.ts"; +import { cmdRun } from "./commands/run.ts"; +import { cmdSetup } from "./commands/setup.ts"; +import { cmdTeardown } from "./commands/teardown.ts"; +import { fatal } from "./helpers/shell.ts"; async function main(): Promise { const sub = process.argv[2]; @@ -48,9 +49,12 @@ async function main(): Promise { case "disable": cmdDisable(); break; + case "admin": + cmdAdmin(); + break; default: fatal( - `unknown subcommand: ${sub} (use: setup | run | teardown | list | reset | logs | attach | identify | enable | disable)`, + `unknown subcommand: ${sub} (use: setup | run | teardown | list | reset | logs | attach | identify | enable | disable | admin)`, ); } } diff --git a/scripts/mcp.ts b/scripts/mcp.ts deleted file mode 100644 index ecfde7495..000000000 --- a/scripts/mcp.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { join } from "node:path"; - -const port = process.env.MCP_PORT ?? "2718"; -const serverUrl = process.env.MCP_SERVER_URL ?? "http://localhost:8080"; - -const child = Bun.spawn([ - "bun", - "--watch", - "apps/mcp-server/src/index.ts", -], { - cwd: join(import.meta.dir, ".."), - env: { - ...process.env, - MCP_DEBUG_PENDING_ACTIONS: process.env.MCP_DEBUG_PENDING_ACTIONS ?? "1", - MCP_PORT: port, - MCP_SERVER_URL: serverUrl, - }, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", -}); - -process.exit(await child.exited); diff --git a/scripts/mcp/addMcp.ts b/scripts/mcp/addMcp.ts new file mode 100644 index 000000000..3b76d3e41 --- /dev/null +++ b/scripts/mcp/addMcp.ts @@ -0,0 +1,91 @@ +/** + * Registers the Autumn MCP server with local AI CLIs (Claude Code + Codex). + * + * Usage: + * bun add-mcp # autumn-dev -> http://localhost:3099/mcp + * bun add-mcp # custom name / url + * + * Only CLIs that are actually installed are touched; the rest are skipped. + * The server uses OAuth, so you authenticate on first connect (Claude prompts + * automatically; for Codex run `codex mcp login `). + */ + +const DEFAULT_NAME = "autumn-dev"; +const DEFAULT_URL = "http://localhost:3099/mcp"; + +type Client = { + label: string; + bin: string; + /** Args to remove an existing server of this name (best-effort, ignored). */ + removeArgs: (name: string) => string[]; + /** Args to add the streamable-HTTP server. */ + addArgs: (name: string, url: string) => string[]; + /** Follow-up the user must run/do (e.g. OAuth login). */ + next: (name: string) => string; +}; + +const clients: Client[] = [ + { + label: "Claude Code", + bin: "claude", + removeArgs: (name) => ["mcp", "remove", name], + addArgs: (name, url) => ["mcp", "add", "--transport", "http", name, url], + next: () => "Claude prompts for OAuth automatically on first use.", + }, + { + label: "Codex", + bin: "codex", + removeArgs: (name) => ["mcp", "remove", name], + addArgs: (name, url) => ["mcp", "add", name, "--url", url], + next: (name) => + `Run \`codex mcp login ${name}\` to authenticate (OAuth). If the handshake fails, retry with \`-c experimental_use_rmcp_client=true\`.`, + }, +]; + +const run = (bin: string, args: string[]) => { + const proc = Bun.spawnSync([bin, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + const output = `${proc.stdout.toString()}${proc.stderr.toString()}`.trim(); + return { ok: proc.exitCode === 0, output }; +}; + +const addToClient = (client: Client, name: string, url: string) => { + if (!Bun.which(client.bin)) { + console.log(`- ${client.label}: skipped (\`${client.bin}\` not found)`); + return; + } + + // Remove any existing entry first so re-running converges cleanly. + run(client.bin, client.removeArgs(name)); + + const { ok, output } = run(client.bin, client.addArgs(name, url)); + if (ok) { + console.log(`+ ${client.label}: added \`${name}\` -> ${url}`); + console.log(` next: ${client.next(name)}`); + return; + } + + console.log(`! ${client.label}: failed to add \`${name}\``); + if (output) console.log(` ${output.replaceAll("\n", "\n ")}`); +}; + +const main = () => { + const [, , nameArg, urlArg] = process.argv; + if (nameArg === "--help" || nameArg === "-h") { + console.log("Usage: bun add-mcp [name] [url]"); + console.log(`Defaults: ${DEFAULT_NAME} ${DEFAULT_URL}`); + return; + } + + const name = nameArg ?? DEFAULT_NAME; + const url = urlArg ?? DEFAULT_URL; + + console.log(`Registering MCP server \`${name}\` (${url})\n`); + for (const client of clients) { + addToClient(client, name, url); + } +}; + +main(); diff --git a/scripts/slack/index.ts b/scripts/slack/index.ts new file mode 100644 index 000000000..eea8d7a95 --- /dev/null +++ b/scripts/slack/index.ts @@ -0,0 +1,890 @@ +import "dotenv/config"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import chalk from "chalk"; +import inquirer from "inquirer"; + +const defaultSlackScopes = [ + "app_mentions:read", + "assistant:write", + "channels:history", + "channels:read", + "chat:write", + "files:read", + "groups:history", + "groups:read", + "im:history", + "im:read", + "im:write", + "mpim:history", + "mpim:read", + "users:read", +]; + +const defaultBotEvents = [ + "app_mention", + "assistant_thread_started", + "assistant_thread_context_changed", + "message.channels", + "message.groups", + "message.im", + "message.mpim", +]; + +type Args = { + action?: string; + appId?: string; + appName?: string; + baseUrl?: string; + dryRun: boolean; + envFile?: string; + help: boolean; + printManifest: boolean; + provider?: SlackInstallProvider; + scopes: string[]; + target?: SlackManifestTarget; + teamId?: string; +}; + +type SlackInstallProvider = "slack" | "slack_admin"; +type SlackManifestTarget = "local" | "prod" | "admin" | "all"; + +type SlackManifest = { + display_information: { + name: string; + }; + features: { + app_home: { + home_tab_enabled: boolean; + messages_tab_enabled: boolean; + messages_tab_read_only_enabled: boolean; + }; + bot_user: { + display_name: string; + always_online: boolean; + }; + }; + oauth_config: { + redirect_urls: string[]; + scopes: { + bot: string[]; + }; + }; + settings: { + event_subscriptions: { + request_url: string; + bot_events: string[]; + }; + interactivity: { + is_enabled: boolean; + request_url: string; + }; + org_deploy_enabled: boolean; + socket_mode_enabled: boolean; + token_rotation_enabled: boolean; + }; +}; + +type SlackManifestCreateResponse = { + ok: boolean; + error?: string; + errors?: unknown[]; + app_id?: string; + credentials?: { + client_id?: string; + client_secret?: string; + signing_secret?: string; + verification_token?: string; + }; + oauth_authorize_url?: string; + [key: string]: unknown; +}; + +type SlackManifestUpdateResponse = SlackApiResponse & { + app_id?: string; +}; + +type SlackApiResponse = { + ok: boolean; + error?: string; + [key: string]: unknown; +}; + +const usage = () => + [ + "Usage:", + " bun slack [setup-bot] [options]", + " bun slack update-manifest --target [options]", + "", + "Options:", + " --app-id Existing Slack app id for manifest updates.", + " --base-url Public Leaf URL. Defaults to NGROK_URL, SLACK_BOT_URL, or CHAT_URL.", + " --name Slack app name. Defaults to Autumn Chat Local.", + " --env-file Write Slack env vars to this file.", + " --provider slack or slack_admin. Defaults to prompt for setup-bot.", + " --scopes Override bot scopes.", + " --target Manifest update target: local, prod, admin, or all.", + " --team-id Workspace team id for org-scoped Slack CLI auth.", + " --print-manifest Print generated Slack app manifest.", + " --dry-run Print manifest/env without calling Slack.", + " --help Show this help.", + "", + "Example:", + " bun slack", + " bun slack --provider slack_admin", + " bun slack update-manifest --target all --base-url https://j.dev.useautumn.com", + " bun slack --base-url https://j.dev.useautumn.com --env-file .env.slack-local", + ].join("\n"); + +const readOption = ({ + args, + name, +}: { + args: string[]; + name: string; +}): string | undefined => { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + + const index = args.indexOf(name); + if (index === -1) return undefined; + return args[index + 1]; +}; + +const parseArgs = ({ argv }: { argv: string[] }): Args => { + const action = argv[0]?.startsWith("--") + ? "setup-bot" + : (argv[0] ?? "setup-bot"); + const scopes = readOption({ args: argv, name: "--scopes" }); + const providerArg = readOption({ args: argv, name: "--provider" }); + const targetArg = readOption({ args: argv, name: "--target" }); + const provider = + providerArg === "slack" || providerArg === "slack_admin" + ? providerArg + : action === "setup-admin-bot" + ? "slack_admin" + : action === "setup-local-bot" || action === "setup-regular-bot" + ? "slack" + : undefined; + const defaultAppName = + provider === "slack_admin" + ? process.env.SLACK_ADMIN_APP_NAME + : process.env.SLACK_APP_NAME; + + return { + action, + appId: readOption({ args: argv, name: "--app-id" }), + appName: readOption({ args: argv, name: "--name" }) ?? defaultAppName, + baseUrl: + readOption({ args: argv, name: "--base-url" }) ?? + process.env.NGROK_URL ?? + process.env.SLACK_BOT_URL ?? + process.env.CHAT_URL, + dryRun: argv.includes("--dry-run"), + envFile: readOption({ args: argv, name: "--env-file" }), + help: argv.includes("--help") || argv.includes("-h"), + printManifest: argv.includes("--print-manifest"), + provider, + scopes: scopes + ? scopes.split(",").map((scope) => scope.trim()) + : defaultSlackScopes, + target: + targetArg === "local" || + targetArg === "prod" || + targetArg === "admin" || + targetArg === "all" + ? targetArg + : action === "update-local-manifest" + ? "local" + : action === "update-prod-manifest" + ? "prod" + : action === "update-admin-manifest" + ? "admin" + : action === "update-all-manifests" + ? "all" + : undefined, + teamId: readOption({ args: argv, name: "--team-id" }), + }; +}; + +const trimTrailingSlash = ({ url }: { url: string }) => url.replace(/\/+$/, ""); + +const defaultAppNameForProvider = ({ + provider, +}: { + provider: SlackInstallProvider; +}) => + provider === "slack_admin" ? "Autumn Chat Admin Local" : "Autumn Chat Local"; + +const isUrl = ({ value }: { value: string }) => { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +}; + +const resolveInteractiveArgs = async ({ + args, +}: { + args: Args; +}): Promise => { + const answers = await inquirer.prompt<{ + provider?: SlackInstallProvider; + appName?: string; + baseUrl?: string; + envFile?: string; + }>([ + ...(!args.provider + ? [ + { + type: "list" as const, + name: "provider" as const, + message: "What kind of Slack bot is this?", + default: "slack", + choices: [ + { + name: "Regular org bot", + value: "slack", + }, + { + name: "Admin impersonation bot", + value: "slack_admin", + }, + ], + }, + ] + : []), + { + type: "input", + name: "appName", + message: "Slack app name", + default: ({ provider }: { provider?: SlackInstallProvider }) => + args.appName ?? + defaultAppNameForProvider({ + provider: provider ?? args.provider ?? "slack", + }), + }, + ...(!args.baseUrl + ? [ + { + type: "input" as const, + name: "baseUrl" as const, + message: "Public ngrok/Leaf URL", + default: + process.env.NGROK_URL ?? + process.env.SLACK_BOT_URL ?? + process.env.CHAT_URL, + filter: (value: string) => trimTrailingSlash({ url: value.trim() }), + validate: (value: string) => + isUrl({ value }) || "Enter a valid http(s) URL", + }, + ] + : []), + ...(!args.envFile + ? [ + { + type: "input" as const, + name: "envFile" as const, + message: "Env file to write (leave blank to only print)", + }, + ] + : []), + ]); + + return { + ...args, + provider: answers.provider ?? args.provider ?? "slack", + appName: answers.appName ?? args.appName, + baseUrl: answers.baseUrl ?? args.baseUrl, + envFile: answers.envFile?.trim() || args.envFile, + }; +}; + +const buildSlackManifest = ({ + appName, + baseUrl, + scopes, +}: { + appName: string; + baseUrl: string; + scopes: string[]; +}): SlackManifest => { + const publicBaseUrl = trimTrailingSlash({ url: baseUrl }); + return { + display_information: { + name: appName, + }, + features: { + app_home: { + home_tab_enabled: false, + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + bot_user: { + display_name: appName, + always_online: false, + }, + }, + oauth_config: { + redirect_urls: [`${publicBaseUrl}/slack/oauth/callback`], + scopes: { + bot: scopes, + }, + }, + settings: { + event_subscriptions: { + request_url: `${publicBaseUrl}/slack/events`, + bot_events: defaultBotEvents, + }, + interactivity: { + is_enabled: true, + request_url: `${publicBaseUrl}/slack/interactions`, + }, + org_deploy_enabled: false, + socket_mode_enabled: false, + token_rotation_enabled: false, + }, + }; +}; + +const runSlackCli = ({ + args, + quiet = false, +}: { + args: string[]; + quiet?: boolean; +}) => { + const result = Bun.spawnSync(["slack", "--skip-update", ...args], { + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new TextDecoder().decode(result.stdout).trim(); + const stderr = new TextDecoder().decode(result.stderr).trim(); + + if (!quiet) { + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); + } + + if (result.exitCode !== 0) { + throw new Error( + [ + `slack ${args.join(" ")} failed`, + stdout || undefined, + stderr || undefined, + ] + .filter(Boolean) + .join("\n"), + ); + } + + return stdout; +}; + +const ensureSlackCli = () => { + try { + runSlackCli({ args: ["version"], quiet: true }); + } catch { + console.log( + chalk.yellow( + "Slack CLI is not installed. Install it, then rerun this command:", + ), + ); + console.log( + "curl -fsSL https://downloads.slack-edge.com/slack-cli/install.sh | bash", + ); + throw new Error("Slack CLI is required for Slack setup"); + } +}; + +const maybeShowSlackCliAuthInstructions = () => { + try { + const authList = runSlackCli({ args: ["auth", "list"], quiet: true }); + if (authList.includes("No teams are authorized")) { + console.log(chalk.yellow("\nSlack CLI is not authenticated.")); + console.log("Run this in another terminal if Slack CLI asks for auth:"); + console.log("slack auth login"); + } + } catch { + console.log(chalk.yellow("\nCould not read Slack CLI auth state.")); + console.log("If Slack CLI prompts for auth, run: slack auth login"); + } +}; + +const parseSlackJson = ({ + output, + label, +}: { + output: string; + label: string; +}): T => { + try { + return JSON.parse(output) as T; + } catch { + throw new Error(`Could not parse ${label} JSON from Slack CLI:\n${output}`); + } +}; + +const getSlackApiAuthState = () => { + const output = runSlackCli({ + args: ["api", "auth.test"], + quiet: true, + }); + return parseSlackJson({ output, label: "auth.test" }); +}; + +const getTicketFromAuthTokenOutput = ({ output }: { output: string }) => { + const match = output.match(/\/slackauthticket\s+([^\s]+)/); + return match?.[1]; +}; + +const getServiceTokenFromAuthTokenOutput = ({ output }: { output: string }) => { + const match = output.match(/\b(xoxp-[A-Za-z0-9-]+)\b/); + return match?.[1]; +}; + +const ensureSlackApiAuth = async () => { + const initial = getSlackApiAuthState(); + if (initial.ok) return undefined; + if (initial.error !== "not_authed") { + throw new Error(`Slack API auth failed: ${initial.error}`); + } + + console.log( + chalk.yellow( + "\nSlack CLI is logged in, but API calls need a service token.", + ), + ); + const ticketOutput = runSlackCli({ + args: ["auth", "token", "--no-prompt"], + quiet: true, + }); + console.log(ticketOutput); + + const ticket = getTicketFromAuthTokenOutput({ output: ticketOutput }); + if (!ticket) { + throw new Error("Could not read Slack auth ticket from Slack CLI output"); + } + + const { challenge } = await inquirer.prompt<{ challenge: string }>([ + { + type: "input", + name: "challenge", + message: "Slack challenge code", + validate: (value: string) => + Boolean(value.trim()) || "Challenge code is required", + }, + ]); + + const tokenOutput = runSlackCli({ + args: [ + "auth", + "token", + "--ticket", + ticket, + "--challenge", + challenge.trim(), + ], + quiet: true, + }); + console.log(tokenOutput); + + const serviceToken = getServiceTokenFromAuthTokenOutput({ + output: tokenOutput, + }); + if (!serviceToken) { + throw new Error("Could not read Slack service token from Slack CLI output"); + } + + const next = parseSlackJson({ + output: runSlackCli({ + args: ["api", "auth.test", "--token", serviceToken], + quiet: true, + }), + label: "auth.test", + }); + if (!next.ok) { + throw new Error(`Slack API auth still failed: ${next.error}`); + } + + return serviceToken; +}; + +const createSlackApp = async ({ + manifest, + serviceToken, + teamId, +}: { + manifest: SlackManifest; + serviceToken?: string; + teamId?: string; +}): Promise => { + const output = runSlackCli({ + args: [ + "api", + "apps.manifest.create", + ...(serviceToken ? ["--token", serviceToken] : []), + "--json", + JSON.stringify({ + manifest: JSON.stringify(manifest), + ...(teamId ? { team_id: teamId } : {}), + }), + ], + quiet: true, + }); + const json = parseSlackJson({ + output, + label: "apps.manifest.create", + }); + if (!json.ok) throw new Error(`Slack app creation failed: ${json.error}`); + + return json; +}; + +const updateSlackAppManifest = async ({ + appId, + manifest, + serviceToken, + teamId, +}: { + appId: string; + manifest: SlackManifest; + serviceToken?: string; + teamId?: string; +}): Promise => { + const output = runSlackCli({ + args: [ + "api", + "apps.manifest.update", + ...(serviceToken ? ["--token", serviceToken] : []), + "--json", + JSON.stringify({ + app_id: appId, + manifest: JSON.stringify(manifest), + ...(teamId ? { team_id: teamId } : {}), + }), + ], + quiet: true, + }); + const json = parseSlackJson({ + output, + label: "apps.manifest.update", + }); + if (!json.ok) + throw new Error(`Slack app manifest update failed: ${json.error}`); + + return json; +}; + +const escapeEnvValue = ({ value }: { value: string }) => { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; + return JSON.stringify(value); +}; + +const upsertEnvFile = ({ + filePath, + vars, +}: { + filePath: string; + vars: Record; +}) => { + const resolved = resolve(process.cwd(), filePath); + const current = existsSync(resolved) ? readFileSync(resolved, "utf-8") : ""; + const lines = current.split("\n"); + const seen = new Set(); + + const updated = lines.map((line) => { + for (const [key, value] of Object.entries(vars)) { + if (line.startsWith(`${key}=`)) { + seen.add(key); + return `${key}=${escapeEnvValue({ value })}`; + } + } + return line; + }); + + for (const [key, value] of Object.entries(vars)) { + if (!seen.has(key)) { + updated.push(`${key}=${escapeEnvValue({ value })}`); + } + } + + writeFileSync(resolved, updated.join("\n").replace(/\n{3,}/g, "\n\n")); + console.log(chalk.green(`Wrote Slack env vars to ${resolved}`)); +}; + +const printEnvExports = ({ vars }: { vars: Record }) => { + console.log(chalk.cyan("\nEnv exports:")); + for (const [key, value] of Object.entries(vars)) { + console.log(`export ${key}=${escapeEnvValue({ value })}`); + } +}; + +const setupSlackBot = async ({ args }: { args: Args }) => { + const resolvedArgs = await resolveInteractiveArgs({ args }); + const provider = resolvedArgs.provider; + const baseUrl = resolvedArgs.baseUrl; + if (!baseUrl) throw new Error("Missing public Leaf URL"); + const readyLabel = + provider === "slack_admin" + ? "Slack admin app ready" + : "Slack local app ready"; + const nextStep = + provider === "slack_admin" + ? "Start Leaf with these env vars, then go to Admin > Slack Bot and click Install." + : "Start Leaf with these env vars, then go to Settings > Integrations and install Slack for the selected org."; + + const manifest = buildSlackManifest({ + appName: resolvedArgs.appName ?? defaultAppNameForProvider({ provider }), + baseUrl, + scopes: resolvedArgs.scopes, + }); + + if (resolvedArgs.printManifest || resolvedArgs.dryRun) { + console.log(chalk.cyan("Slack app manifest:")); + console.log(JSON.stringify(manifest, null, 2)); + } + + ensureSlackCli(); + maybeShowSlackCliAuthInstructions(); + const serviceToken = resolvedArgs.dryRun + ? undefined + : await ensureSlackApiAuth(); + + const slackResponse = resolvedArgs.dryRun + ? undefined + : await createSlackApp({ + manifest, + serviceToken, + teamId: resolvedArgs.teamId, + }); + + const credentials = slackResponse?.credentials; + const clientId = credentials?.client_id; + const clientSecret = credentials?.client_secret; + const signingSecret = credentials?.signing_secret; + const redirectUrl = manifest.oauth_config.redirect_urls[0]; + + if (!resolvedArgs.dryRun && (!clientId || !clientSecret || !signingSecret)) { + console.log( + chalk.yellow("Slack response did not include all credentials."), + ); + console.log(JSON.stringify(slackResponse, null, 2)); + throw new Error("Could not extract Slack app credentials from response"); + } + + const envVars = { + SLACK_CLIENT_ID: clientId ?? "", + SLACK_CLIENT_SECRET: clientSecret ?? "", + SLACK_SIGNING_SECRET: signingSecret ?? "", + SLACK_REDIRECT_URI: redirectUrl, + }; + + console.log(chalk.green(`\n${readyLabel}`)); + if (slackResponse?.app_id) console.log(`App ID: ${slackResponse.app_id}`); + printEnvExports({ vars: envVars }); + + if (resolvedArgs.envFile) { + upsertEnvFile({ + filePath: resolvedArgs.envFile, + vars: envVars, + }); + } + + if (slackResponse?.oauth_authorize_url) { + console.log( + chalk.gray( + "\nSlack returned a raw OAuth URL, but Autumn installs require signed state. Use the Autumn UI install flow instead.", + ), + ); + } + console.log(chalk.cyan(`\nNext step:\n${nextStep}`)); +}; + +const setupAdminBot = async ({ args }: { args: Args }) => + setupSlackBot({ args: { ...args, provider: "slack_admin" } }); + +const setupLocalBot = async ({ args }: { args: Args }) => + setupSlackBot({ args: { ...args, provider: "slack" } }); + +const prodBaseUrl = "https://api.useautumn.com"; + +const targetDefaults = ({ + target, +}: { + target: Exclude; +}) => { + if (target === "prod") { + return { + appId: process.env.SLACK_PROD_APP_ID, + appName: process.env.SLACK_PROD_APP_NAME ?? "Autumn", + baseUrl: prodBaseUrl, + provider: "slack" as const, + }; + } + if (target === "admin") { + return { + appId: process.env.SLACK_ADMIN_APP_IDS ?? process.env.SLACK_ADMIN_APP_ID, + appName: process.env.SLACK_ADMIN_APP_NAME ?? "Autumn Chat Admin Local", + baseUrl: + process.env.NGROK_URL ?? + process.env.SLACK_BOT_URL ?? + process.env.CHAT_URL, + provider: "slack_admin" as const, + }; + } + return { + appId: process.env.SLACK_APP_ID ?? process.env.SLACK_LOCAL_APP_ID, + appName: process.env.SLACK_APP_NAME ?? "Autumn Chat Local", + baseUrl: + process.env.NGROK_URL ?? + process.env.SLACK_BOT_URL ?? + process.env.CHAT_URL, + provider: "slack" as const, + }; +}; + +const resolveManifestUpdateTarget = async ({ + args, + target, +}: { + args: Args; + target: Exclude; +}) => { + const defaults = targetDefaults({ target }); + const answers = await inquirer.prompt<{ + appIds?: string; + appName?: string; + baseUrl?: string; + }>([ + ...(!args.dryRun && !args.appId && !defaults.appId + ? [ + { + type: "input" as const, + name: "appIds" as const, + message: `Slack app id(s) for ${target}`, + validate: (value: string) => + Boolean(value.trim()) || "At least one Slack app id is required", + }, + ] + : []), + ...(!args.baseUrl && !defaults.baseUrl + ? [ + { + type: "input" as const, + name: "baseUrl" as const, + message: `Public Leaf URL for ${target}`, + filter: (value: string) => trimTrailingSlash({ url: value.trim() }), + validate: (value: string) => + isUrl({ value }) || "Enter a valid http(s) URL", + }, + ] + : []), + ]); + const baseUrl = args.baseUrl ?? answers.baseUrl ?? defaults.baseUrl; + if (!baseUrl) throw new Error(`Missing base URL for ${target} manifest`); + + return { + appIds: (args.appId ?? answers.appIds ?? defaults.appId) + ?.split(",") + .map((appId) => appId.trim()) + .filter(Boolean), + appName: args.appName ?? defaults.appName, + baseUrl, + provider: defaults.provider, + }; +}; + +const updateManifestTargets = async ({ args }: { args: Args }) => { + const target = args.target ?? "local"; + const targets = + target === "all" + ? (["local", "prod", "admin"] as const) + : ([target] as Exclude[]); + + if (!args.dryRun) { + ensureSlackCli(); + maybeShowSlackCliAuthInstructions(); + } + const serviceToken = args.dryRun ? undefined : await ensureSlackApiAuth(); + + for (const updateTarget of targets) { + const targetArgs = + target === "all" + ? { + ...args, + appId: undefined, + appName: undefined, + baseUrl: updateTarget === "prod" ? undefined : args.baseUrl, + } + : args; + const resolved = await resolveManifestUpdateTarget({ + args: targetArgs, + target: updateTarget, + }); + const manifest = buildSlackManifest({ + appName: resolved.appName, + baseUrl: resolved.baseUrl, + scopes: args.scopes, + }); + + if (args.printManifest || args.dryRun) { + console.log(chalk.cyan(`\n${updateTarget} Slack app manifest:`)); + console.log(JSON.stringify(manifest, null, 2)); + } + + if (args.dryRun) continue; + if (!resolved.appIds?.length) { + throw new Error(`Missing Slack app id for ${updateTarget} manifest`); + } + for (const appId of resolved.appIds) { + await updateSlackAppManifest({ + appId, + manifest, + serviceToken, + teamId: args.teamId, + }); + console.log( + chalk.green(`Updated ${updateTarget} Slack app manifest (${appId})`), + ); + } + } +}; + +const actions = { + "setup-bot": setupSlackBot, + "setup-admin-bot": setupAdminBot, + "setup-local-bot": setupLocalBot, + "setup-regular-bot": setupLocalBot, + "update-admin-manifest": updateManifestTargets, + "update-all-manifests": updateManifestTargets, + "update-local-manifest": updateManifestTargets, + "update-manifest": updateManifestTargets, + "update-prod-manifest": updateManifestTargets, +} satisfies Record Promise>; + +type Action = keyof typeof actions; + +const isAction = (action: string | undefined): action is Action => + action !== undefined && Object.hasOwn(actions, action); + +const main = async () => { + const args = parseArgs({ argv: process.argv.slice(2) }); + if (args.help || !isAction(args.action)) { + console.log(usage()); + process.exit(args.help ? 0 : 1); + } + + await actions[args.action]({ args }); +}; + +try { + await main(); +} catch (error) { + console.error( + chalk.red(error instanceof Error ? error.message : String(error)), + ); + process.exit(1); +} diff --git a/scripts/testScripts/getDescribeAtCursor.ts b/scripts/testScripts/getDescribeAtCursor.ts index b4cc079f6..782e10f7d 100644 --- a/scripts/testScripts/getDescribeAtCursor.ts +++ b/scripts/testScripts/getDescribeAtCursor.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "fs"; +import { readFileSync } from "node:fs"; const file = process.argv[2]; const lineNum = parseInt(process.argv[3], 10); @@ -8,13 +8,13 @@ const lines = content.split("\n"); const MULTILINE_LOOKAHEAD = 5; -const escape = (raw: string) => raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const escapeRegex = (raw: string) => raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const CHALK_PATTERN = /(?:describe|test(?:\.concurrent)?)\s*\(\s*`\$\{chalk\.\w+\(["'](.*?)["']\)\}`/; -const SIMPLE_PATTERN = - /(?:describe|test(?:\.concurrent)?)\s*\(\s*["'`](.*?)["'`]/; -const OPEN_PATTERN = /(?:describe|test(?:\.concurrent)?)\s*\(\s*$/; +const BLOCK_NAME = String.raw`(?:describe|test(?:\.concurrent)?|Eval(?:<[^>]+>)?)`; +const SIMPLE_PATTERN = new RegExp(`${BLOCK_NAME}\\s*\\(\\s*["'\`](.*?)["'\`]`); +const OPEN_PATTERN = new RegExp(`${BLOCK_NAME}\\s*\\(\\s*$`); // Walk backwards from cursor to find enclosing describe, test, or test.concurrent. // Each candidate also gets a multi-line lookahead so name args wrapped onto the @@ -24,13 +24,13 @@ for (let i = lineNum - 1; i >= 0; i--) { const chalkMatch = line.match(CHALK_PATTERN); if (chalkMatch) { - console.log(escape(chalkMatch[1])); + console.log(escapeRegex(chalkMatch[1])); process.exit(0); } const simpleMatch = line.match(SIMPLE_PATTERN); if (simpleMatch) { - console.log(escape(simpleMatch[1])); + console.log(escapeRegex(simpleMatch[1])); process.exit(0); } @@ -40,12 +40,12 @@ for (let i = lineNum - 1; i >= 0; i--) { .join("\n"); const chalkMulti = joined.match(CHALK_PATTERN); if (chalkMulti) { - console.log(escape(chalkMulti[1])); + console.log(escapeRegex(chalkMulti[1])); process.exit(0); } const simpleMulti = joined.match(SIMPLE_PATTERN); if (simpleMulti) { - console.log(escape(simpleMulti[1])); + console.log(escapeRegex(simpleMulti[1])); process.exit(0); } } diff --git a/scripts/testScripts/testDispatcher.ts b/scripts/testScripts/testDispatcher.ts index f69cc7078..d7502af50 100644 --- a/scripts/testScripts/testDispatcher.ts +++ b/scripts/testScripts/testDispatcher.ts @@ -22,6 +22,9 @@ const PROJECT_ROOT = resolve(import.meta.dirname, "../.."); const TESTS_DIR = join(PROJECT_ROOT, testRunConfig.testsBaseDir); const LEGACY_SCRIPTS_DIR = join(PROJECT_ROOT, testRunConfig.legacyScriptsDir); const RUNNER_SCRIPT = join(PROJECT_ROOT, "scripts/testScripts/runTestsV2.tsx"); +const LEAF_EVALS_DIR = join(PROJECT_ROOT, "apps/leaf/tests/evals"); +const BRAINTRUST_BIN = join(PROJECT_ROOT, "node_modules/.bin/braintrust"); +const BRAINTRUST_EXTERNAL_PACKAGES = ["@mastra/mcp", "@mastra/core"]; // Worktree .env.local loading happens in scripts/preload-env.ts, which Bun // auto-runs via bunfig.toml `preload` for every `bun` and `bun test` invocation. @@ -157,6 +160,52 @@ async function collectTestFilesFromDir({ return files; } +async function collectEvalFilesFromDir({ + dir, +}: { + dir: string; +}): Promise { + const files: string[] = []; + + const walk = async ({ d }: { d: string }) => { + const entries = await readdir(d); + for (const entry of entries) { + const fullPath = join(d, entry); + const entryStat = await stat(fullPath); + + if (entryStat.isDirectory()) { + await walk({ d: fullPath }); + } else if (entry.endsWith(".eval.ts")) { + files.push(fullPath); + } + } + }; + + await walk({ d: dir }); + return files; +} + +async function resolveLeafEvalTarget({ + target, +}: { + target: string; +}): Promise { + const candidates = [ + resolve(process.cwd(), target), + join(PROJECT_ROOT, target), + join(LEAF_EVALS_DIR, target), + ]; + + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + const s = await stat(candidate); + if (s.isDirectory()) return collectEvalFilesFromDir({ dir: candidate }); + if (candidate.endsWith(".eval.ts")) return [candidate]; + } + + return []; +} + async function main() { const args = process.argv.slice(2); @@ -202,11 +251,18 @@ async function main() { } const resolvedFiles: string[] = []; + const evalTargets: string[] = []; const fallbackArgs: string[] = []; // Track the max concurrency from matched groups (use lowest if multiple) let groupMaxConcurrency: number | null = null; for (const arg of positionalArgs) { + const evalTarget = await resolveLeafEvalTarget({ target: arg }); + if (evalTarget.length > 0) { + evalTargets.push(...evalTarget); + continue; + } + // Priority 1: Test group or suite from _groups/ const groupPaths = resolveTestPaths({ name: arg }); if (groupPaths) { @@ -280,6 +336,17 @@ async function main() { ? options : [...options, `--max=${concurrency}`]; + if (evalTargets.length > 0) { + if (resolvedFiles.length > 0 || fallbackArgs.length > 0) { + console.error("Error: Cannot mix Braintrust evals with bun test targets"); + process.exit(1); + } + + const evalOptions = options.filter((option) => !option.startsWith("--max")); + await spawnBraintrustEval({ args: [...evalTargets, ...evalOptions] }); + return; + } + if (resolvedFiles.length > 0 && fallbackArgs.length > 0) { const runnerArgs = [...resolvedFiles, ...fallbackArgs, ...finalOptions]; await spawnRunner({ args: runnerArgs }); @@ -312,4 +379,26 @@ async function spawnRunner({ args }: { args: string[] }) { process.exit(exitCode); } +async function spawnBraintrustEval({ args }: { args: string[] }) { + const proc = spawn( + [ + BRAINTRUST_BIN, + "eval", + ...args, + "--external-packages", + ...BRAINTRUST_EXTERNAL_PACKAGES, + ], + { + cwd: join(PROJECT_ROOT, "apps/leaf"), + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + env: { ...process.env }, + }, + ); + + const exitCode = await proc.exited; + process.exit(exitCode); +} + main(); diff --git a/scripts/tinybird/index.ts b/scripts/tinybird/index.ts index 8a967e6f5..c0738184f 100644 --- a/scripts/tinybird/index.ts +++ b/scripts/tinybird/index.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { createTinybirdApi } from "@tinybirdco/sdk"; type ProfileName = "dev" | "prod" | "prod-legacy"; type TinybirdTarget = "new" | "legacy"; @@ -35,6 +36,7 @@ const usage = `Usage: bun tb info bun tb deploy:check bun tb deploy + bun tb token:read bun tb:prod bun tb:prod-legacy @@ -70,15 +72,58 @@ const requireEnv = (name: string) => { return value; }; +const requireEnvValue = (env: NodeJS.ProcessEnv, name: string) => { + const value = env[name]; + if (!value) { + console.error(`${name} is not set`); + process.exit(1); + } + return value; +}; + const resolveTinybirdArgs = (args: string[]) => { if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { console.log(usage); process.exit(args.length === 0 ? 1 : 0); } + if (args[0] === "token:read") { + const tokenName = args[1]; + if (!tokenName || args.length > 2) { + console.error("Usage: bun tb token:read "); + process.exit(1); + } + + return args; + } + return commandAliases[args[0]] ?? args; }; +const createReadToken = async (tokenName: string, env: NodeJS.ProcessEnv) => { + const baseUrl = requireEnvValue(env, "TINYBIRD_API_URL"); + const api = createTinybirdApi({ + baseUrl, + token: requireEnvValue(env, "TINYBIRD_TOKEN"), + }); + + const url = new URL("/v0/tokens/", `${baseUrl}/`); + url.searchParams.set("name", tokenName); + url.searchParams.set("scope", "WORKSPACE:READ_ALL"); + + const response = await api.request(url.toString(), { + method: "POST", + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to create Tinybird read token: ${body}`); + } + + const result = (await response.json()) as { token?: string }; + console.log(result.token ?? JSON.stringify(result)); +}; + const executeTinybird = async () => { const target = requireEnv("AUTUMN_TINYBIRD_TARGET") as TinybirdTarget; const env = { ...process.env }; @@ -92,6 +137,11 @@ const executeTinybird = async () => { } const args = resolveTinybirdArgs(Bun.argv.slice(2)); + if (args[0] === "token:read") { + await createReadToken(args[1], env); + return; + } + const exitCode = await run(["bunx", "tinybird", ...args], { cwd: serverDir, env, diff --git a/server/experiments/explainIncludeProcessedFilter.ts b/server/experiments/explainIncludeProcessedFilter.ts new file mode 100644 index 000000000..ea6e0cac0 --- /dev/null +++ b/server/experiments/explainIncludeProcessedFilter.ts @@ -0,0 +1,349 @@ +import { AppEnv } from "@autumn/shared"; +import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js"; +import type { CustomerFilter } from "@autumn/shared/api/migrations/filters/customerFilter.js"; +import { type SQL, sql } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + buildCustomerCount, + buildCustomerSelect, + buildProcessedPreviewCount, + buildProcessedPreviewSelect, +} from "@/internal/migrations/v2/filters/customers/buildCustomerSelect.js"; +import { rawWithParamsToDrizzle } from "@/internal/migrations/v2/filters/rawWithParamsToDrizzle.js"; +// Import initDrizzle directly — avoid `experimentEnv` because its +// `loadLocalEnv()` reads `server/.env` and clobbers env vars injected by +// `infisical run --env=staging` (e.g. DATABASE_URL). +import { initDrizzle } from "../src/db/initDrizzle"; +import { FeatureService } from "../src/internal/features/FeatureService.js"; + +// Why this experiment exists: the "include processed customers" preview +// (handlePreviewMigrationFilter + buildCustomerSelect) ORs the org/env-scoped +// compiled filter with `c.internal_id IN ()`. The OR strips +// org/env scoping from the second branch, so the planner can't use +// idx_customers_org_env_internal_id and may seq-scan ALL customers. This script +// EXPLAINs the current OR query against an equivalent UNION rewrite to confirm +// the bottleneck and decide whether a new index is needed. +// +// Run against a remote env (e.g. staging) via infisical: +// infisical run --env=staging --recursive -- \ +// bun run server/experiments/explainIncludeProcessedFilter.ts + +const prodTestOrgId = (() => { + const v = process.env.PROD_TEST_ORG_ID; + if (!v) throw new Error("PROD_TEST_ORG_ID env var is required"); + return v; +})(); + +const dbUrl = process.env.DATABASE_URL ?? ""; +console.log( + "DATABASE URL host:", + dbUrl.replace(/:\/\/[^@]+@/, "://***:***@") || "(empty)", +); + +// ─── Configuration ────────────────────────────────────────────────── +const ORG_ID = prodTestOrgId; +const ENV = AppEnv.Live; +const SAMPLE_LIMIT = 10; // matches the default preview page size +const TRUNCATE_EXPLAIN = true; +const EXPLAIN_MAX_LINES = 40; + +// Optional override. When unset, the script auto-discovers the migration in +// this org/env with the most live (dry_run = false) customer item runs. +const MIGRATION_INTERNAL_ID = process.env.MIGRATION_INTERNAL_ID || undefined; + +// User-facing migration id (the `id` column, resolved to internal_id like the +// production handler does). Takes precedence over auto-discovery. +const MIGRATION_ID = process.env.MIGRATION_ID || "plan_pro-update"; + +// Filter the live preview applies. Keep it representative of a real migration +// selection. An empty `{}` matches all customers in the org/env. +const FILTER: CustomerFilter = { + plan: { plan_id: "free" }, +}; + +// ═════════════════════════════════════════════════════════════════════ + +const truncateExplainText = (text: string, maxLines: number): string => { + const lines = text.split("\n"); + if (lines.length <= maxLines) return text; + const omitted = lines.length - maxLines; + return [...lines.slice(0, maxLines), `... (${omitted} more lines truncated)`].join( + "\n", + ); +}; + +const printExplainPlan = async ({ + db, + query, + label, +}: { + db: ReturnType["db"]; + query: SQL; + label: string; +}) => { + console.log(`\n--- EXPLAIN ANALYZE: ${label} ---`); + const explainResult = await db.execute( + sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`, + ); + const lines: string[] = []; + for (const row of explainResult) + lines.push(String((row as Record)["QUERY PLAN"])); + const joined = lines.join("\n"); + console.log( + TRUNCATE_EXPLAIN ? truncateExplainText(joined, EXPLAIN_MAX_LINES) : joined, + ); +}; + +const dialect = new PgDialect(); + +const inlineParams = (text: string, params: readonly unknown[]): string => + text.replace(/\$(\d+)/g, (_, n) => { + const v = params[Number(n) - 1]; + if (v === null || v === undefined) return "NULL"; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return `'${String(v).replace(/'/g, "''")}'`; + }); + +const printSqlQuery = ({ query, label }: { query: SQL; label: string }) => { + const { sql: text, params } = dialect.sqlToQuery(query); + console.log(`\n--- SQL: ${label} ---`); + console.log(inlineParams(text, params)); +}; + +const runMeasured = async ({ + db, + query, + label, +}: { + db: ReturnType["db"]; + query: SQL; + label: string; +}) => { + console.log(`\n=== ${label} ===`); + printSqlQuery({ query, label }); + const startedAt = performance.now(); + const result = await db.execute(query); + const elapsedMs = performance.now() - startedAt; + console.log(`Rows returned: ${result.length}`); + console.log(`Wall-clock: ${elapsedMs.toFixed(2)}ms`); + if (label.startsWith("COUNT") && result.length > 0) + console.log(`Count: ${(result[0] as Record).count}`); + await printExplainPlan({ db, query, label }); +}; + +const compiledWhere = ({ + filter, + features, +}: { + filter: CustomerFilter; + features: Awaited>; +}): SQL => + rawWithParamsToDrizzle( + compileFilter({ + filter, + ctx: { features }, + ambient: { orgId: ORG_ID, env: ENV }, + }), + ); + +// The processed-customers subquery, identical to buildIncludeProcessedOr. +const processedSubquery = (migrationInternalId: string): SQL => sql` + SELECT mir.item_id FROM migration_item_runs mir + WHERE mir.migration_internal_id = ${migrationInternalId} + AND mir.item_kind = 'customer' + AND mir.dry_run = false +`; + +// Proposed UNION rewrite: each branch keeps its own scoping so the planner can +// use an index per branch instead of seq-scanning all customers. +const buildUnionSelect = ({ + where, + migrationInternalId, + limit, +}: { + where: SQL; + migrationInternalId: string; + limit: number; +}): SQL => sql` + SELECT u.internal_id, u.id, u.name, u.email + FROM ( + SELECT c.internal_id, c.id, c.name, c.email + FROM customers c + WHERE (${where}) + UNION + SELECT c.internal_id, c.id, c.name, c.email + FROM customers c + WHERE c.internal_id IN (${processedSubquery(migrationInternalId)}) + ) u + ORDER BY u.internal_id DESC + LIMIT ${limit} +`; + +const buildUnionCount = ({ + where, + migrationInternalId, +}: { + where: SQL; + migrationInternalId: string; +}): SQL => sql` + SELECT COUNT(*)::bigint AS count + FROM ( + SELECT c.internal_id + FROM customers c + WHERE (${where}) + UNION + SELECT c.internal_id + FROM customers c + WHERE c.internal_id IN (${processedSubquery(migrationInternalId)}) + ) u +`; + +// Resolve a user-facing migration `id` to its `internal_id`, scoped to org/env +// — mirrors migrationRepo.find used by handlePreviewMigrationFilter. +const resolveMigrationInternalId = async ( + db: ReturnType["db"], + id: string, +): Promise => { + const rows = (await db.execute(sql` + SELECT internal_id FROM migrations + WHERE org_id = ${ORG_ID} AND env = ${ENV} AND id = ${id} + LIMIT 1 + `)) as Array<{ internal_id: string }>; + return rows[0]?.internal_id; +}; + +const discoverMigrationInternalId = async ( + db: ReturnType["db"], +): Promise => { + const rows = (await db.execute(sql` + SELECT mir.migration_internal_id AS migration_internal_id, COUNT(*) AS n + FROM migration_item_runs mir + JOIN migration_runs mr ON mr.migration_internal_id = mir.migration_internal_id + WHERE mr.org_id = ${ORG_ID} + AND mr.env = ${ENV} + AND mir.item_kind = 'customer' + AND mir.dry_run = false + GROUP BY mir.migration_internal_id + ORDER BY n DESC + LIMIT 5 + `)) as Array<{ migration_internal_id: string; n: bigint | number }>; + + if (rows.length === 0) return undefined; + console.log("\nMigrations with live customer item runs (top 5):"); + for (const r of rows) + console.log(` ${r.migration_internal_id} → ${Number(r.n)} processed`); + return rows[0].migration_internal_id; +}; + +const main = async () => { + const replicaUrl = process.env.DATABASE_REPLICA_URL; + const usingReplica = Boolean(replicaUrl); + if (!usingReplica) + console.warn( + "DATABASE_REPLICA_URL not set — falling back to DATABASE_URL (primary). Set the replica URL to test against the read replica.", + ); + const { db } = initDrizzle({ replica: usingReplica }); + + console.log( + `=== INCLUDE-PROCESSED FILTER EXPERIMENT (${usingReplica ? "REPLICA" : "PRIMARY"}) ===`, + ); + console.log(JSON.stringify({ ORG_ID, ENV, FILTER }, null, 2)); + + let migrationInternalId = MIGRATION_INTERNAL_ID; + if (!migrationInternalId && MIGRATION_ID) { + migrationInternalId = await resolveMigrationInternalId(db, MIGRATION_ID); + if (migrationInternalId) + console.log(`\nResolved MIGRATION_ID '${MIGRATION_ID}' → ${migrationInternalId}`); + else + console.warn( + `\nMIGRATION_ID '${MIGRATION_ID}' not found for this org/env — falling back to auto-discovery.`, + ); + } + migrationInternalId ??= await discoverMigrationInternalId(db); + if (!migrationInternalId) { + console.error( + "\nNo migration with live customer item runs found for this org/env. " + + "Set MIGRATION_INTERNAL_ID or MIGRATION_ID explicitly to test a specific migration.", + ); + process.exit(1); + } + console.log(`\nUsing migration_internal_id: ${migrationInternalId}`); + + const orgFeatures = await FeatureService.list({ db, orgId: ORG_ID, env: ENV }); + console.log(`\nLoaded ${orgFeatures.length} features for resolution context.`); + const ctx = { features: orgFeatures }; + const where = compiledWhere({ filter: FILTER, features: orgFeatures }); + + const includeProcessed = { migrationInternalId }; + + // 1. Isolated processed subquery — confirms migration_item_runs index coverage. + await runMeasured({ + db, + query: sql`SELECT mir.item_id FROM migration_item_runs mir + WHERE mir.migration_internal_id = ${migrationInternalId} + AND mir.item_kind = 'customer' + AND mir.dry_run = false`, + label: "SUBQUERY (processed item_ids only)", + }); + + // 2. Pure filter — exactly what the FILTER STEP (no migrationId) runs. + // Baseline to prove the customer filter alone is fast; only the live + // view's includeProcessed OR is slow. + await runMeasured({ + db, + query: buildCustomerCount({ orgId: ORG_ID, env: ENV, filter: FILTER, ctx }), + label: "COUNT [filter only — filter step]", + }); + await runMeasured({ + db, + query: buildCustomerSelect({ + orgId: ORG_ID, + env: ENV, + filter: FILTER, + ctx, + limit: SAMPLE_LIMIT, + }), + label: `SELECT [filter only — filter step] (limit ${SAMPLE_LIMIT})`, + }); + + // 3. Live-view path: the dedicated preview builders (filter ∪ processed). + await runMeasured({ + db, + query: buildProcessedPreviewCount({ + orgId: ORG_ID, + env: ENV, + filter: FILTER, + ctx, + includeProcessed, + }), + label: "COUNT [preview builder]", + }); + await runMeasured({ + db, + query: buildProcessedPreviewSelect({ + orgId: ORG_ID, + env: ENV, + filter: FILTER, + ctx, + includeProcessed, + limit: SAMPLE_LIMIT, + }), + label: `SELECT [preview builder] (limit ${SAMPLE_LIMIT})`, + }); + + // 4. Hand-written UNION reference (sanity check the builder matches this). + await runMeasured({ + db, + query: buildUnionCount({ where, migrationInternalId }), + label: "COUNT [UNION — proposed]", + }); + await runMeasured({ + db, + query: buildUnionSelect({ where, migrationInternalId, limit: SAMPLE_LIMIT }), + label: `SELECT [UNION — proposed] (limit ${SAMPLE_LIMIT})`, + }); + + process.exit(0); +}; + +await main(); diff --git a/server/experiments/explainMigrationFilterPreview.ts b/server/experiments/explainMigrationFilterPreview.ts new file mode 100644 index 000000000..ebca22682 --- /dev/null +++ b/server/experiments/explainMigrationFilterPreview.ts @@ -0,0 +1,245 @@ +import { AppEnv, type CustomerFilter } from "@autumn/shared"; +import { sql, type SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + buildProcessedPreviewCount, + buildProcessedPreviewSelect, + type CustomerExecutionStatus, + type IncludeProcessed, +} from "@/internal/migrations/v2/filters/customers/buildCustomerSelect.js"; +import { initDrizzle } from "../src/db/initDrizzle"; +import { FeatureService } from "../src/internal/features/FeatureService.js"; + +const ORG_ID = process.env.MIGRATION_PREVIEW_ORG_ID; +const MIGRATION_ID = process.env.MIGRATION_PREVIEW_MIGRATION_ID; +const ENV = (process.env.MIGRATION_PREVIEW_ENV ?? AppEnv.Live) as AppEnv; +const PAGE_SIZE = Number(process.env.MIGRATION_PREVIEW_PAGE_SIZE ?? 50); +const EXPLAIN_MAX_LINES = Number(process.env.EXPLAIN_MAX_LINES ?? 80); + +if (!ORG_ID) throw new Error("MIGRATION_PREVIEW_ORG_ID is required"); +if (!MIGRATION_ID) throw new Error("MIGRATION_PREVIEW_MIGRATION_ID is required"); + +const dbUrl = process.env.DATABASE_URL ?? ""; +console.log( + "DATABASE URL host:", + dbUrl.replace(/:\/\/[^@]+@/, "://***:***@") || "(empty)", +); + +const dialect = new PgDialect(); + +const inlineParams = (text: string, params: readonly unknown[]): string => + text.replace(/\$(\d+)/g, (_, n) => { + const value = params[Number(n) - 1]; + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number" || typeof value === "boolean") + return String(value); + return `'${String(value).replace(/'/g, "''")}'`; + }); + +const truncateExplainText = (text: string, maxLines: number): string => { + const lines = text.split("\n"); + if (lines.length <= maxLines) return text; + return [ + ...lines.slice(0, maxLines), + `... (${lines.length - maxLines} more lines truncated)`, + ].join("\n"); +}; + +const printSql = ({ label, query }: { label: string; query: SQL }) => { + const { sql: text, params } = dialect.sqlToQuery(query); + console.log(`\n--- SQL: ${label} ---`); + console.log(inlineParams(text, params)); +}; + +const explain = async ({ + db, + label, + query, +}: { + db: ReturnType["db"]; + label: string; + query: SQL; +}) => { + console.log(`\n=== ${label} ===`); + printSql({ label, query }); + const startedAt = performance.now(); + const result = await db.execute(sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`); + const elapsedMs = performance.now() - startedAt; + const lines = result.map((row) => + String((row as Record)["QUERY PLAN"]), + ); + console.log(`EXPLAIN wall-clock: ${elapsedMs.toFixed(2)}ms`); + console.log(truncateExplainText(lines.join("\n"), EXPLAIN_MAX_LINES)); +}; + +const runScalar = async ({ + db, + label, + query, +}: { + db: ReturnType["db"]; + label: string; + query: SQL; +}) => { + const startedAt = performance.now(); + const result = await db.execute(query); + console.log( + `${label}: ${JSON.stringify(result)} (${(performance.now() - startedAt).toFixed(2)}ms)`, + ); +}; + +const makeIncludeProcessed = ({ + migrationInternalId, + statuses, +}: { + migrationInternalId: string; + statuses?: CustomerExecutionStatus[]; +}): IncludeProcessed => ({ + migrationInternalId, + executionFilter: statuses ? { statuses } : undefined, +}); + +const buildEnrichQuery = (internalIds: string[]): SQL => sql` + SELECT c.internal_id, c.id, c.name, c.email, cp.id AS customer_product_id, p.id AS product_id + FROM customers c + LEFT JOIN customer_products cp ON c.internal_id = cp.internal_customer_id + LEFT JOIN products p ON cp.internal_product_id = p.internal_id + WHERE c.internal_id IN (${sql.join( + internalIds.map((id) => sql`${id}`), + sql`, `, + )}) +`; + +const main = async () => { + const usingReplica = Boolean(process.env.DATABASE_REPLICA_URL); + const { db } = initDrizzle({ replica: usingReplica }); + console.log( + `=== MIGRATION FILTER PREVIEW (${usingReplica ? "REPLICA" : "PRIMARY"}) ===`, + ); + console.log(JSON.stringify({ ORG_ID, MIGRATION_ID, ENV, PAGE_SIZE }, null, 2)); + + await db.execute(sql`SET statement_timeout = '15000ms'`); + await db.execute(sql`SET lock_timeout = '100ms'`); + await db.execute(sql`SET default_transaction_read_only = on`); + + const [migration] = (await db.execute(sql` + SELECT internal_id, id, filter + FROM migrations + WHERE org_id = ${ORG_ID} AND env = ${ENV} AND id = ${MIGRATION_ID} + LIMIT 1 + `)) as Array<{ + internal_id: string; + id: string; + filter: { customer?: CustomerFilter } | null; + }>; + + if (!migration) { + throw new Error( + `Migration ${MIGRATION_ID} not found for org ${ORG_ID} in env ${ENV}`, + ); + } + + const filter = migration.filter?.customer ?? {}; + console.log(`Resolved migration_internal_id: ${migration.internal_id}`); + console.log(`Customer filter: ${JSON.stringify(filter, null, 2)}`); + + await runScalar({ + db, + label: "migration_item_runs by dry_run/status", + query: sql` + SELECT dry_run, status, COUNT(*)::bigint AS count + FROM migration_item_runs + WHERE migration_internal_id = ${migration.internal_id} + AND item_kind = 'customer' + GROUP BY dry_run, status + ORDER BY dry_run, status + `, + }); + + const features = await FeatureService.list({ db, orgId: ORG_ID, env: ENV }); + const ctx = { features }; + console.log(`Loaded ${features.length} features for filter resolution.`); + + const baseIncludeProcessed = makeIncludeProcessed({ + migrationInternalId: migration.internal_id, + }); + const succeededIncludeProcessed = makeIncludeProcessed({ + migrationInternalId: migration.internal_id, + statuses: ["succeeded"], + }); + const notRunIncludeProcessed = makeIncludeProcessed({ + migrationInternalId: migration.internal_id, + statuses: ["not_run"], + }); + + const selectQuery = buildProcessedPreviewSelect({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: baseIncludeProcessed, + limit: PAGE_SIZE, + }); + + await explain({ + db, + label: "COUNT no execution status", + query: buildProcessedPreviewCount({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: baseIncludeProcessed, + }), + }); + await explain({ db, label: `SELECT first page limit ${PAGE_SIZE}`, query: selectQuery }); + + const selectedRows = (await db.execute(selectQuery)) as Array<{ internal_id: string }>; + if (selectedRows.length > 0) { + await explain({ + db, + label: "ENRICH selected page", + query: buildEnrichQuery(selectedRows.map((row) => row.internal_id)), + }); + } + + await explain({ + db, + label: "COUNT status=succeeded", + query: buildProcessedPreviewCount({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: succeededIncludeProcessed, + }), + }); + await explain({ + db, + label: "SELECT status=succeeded first page", + query: buildProcessedPreviewSelect({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: succeededIncludeProcessed, + limit: PAGE_SIZE, + }), + }); + + await explain({ + db, + label: "COUNT status=not_run", + query: buildProcessedPreviewCount({ + orgId: ORG_ID, + env: ENV, + filter, + ctx, + includeProcessed: notRunIncludeProcessed, + }), + }); + + process.exit(0); +}; + +await main(); diff --git a/server/package.json b/server/package.json index d4a4c7b6d..d035d7f18 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,7 @@ "start": "bun src/index.ts", "workers": "bun src/workers.ts", "cron": "bun src/cron.ts", + "leaf": "bun ../apps/leaf/src/index.ts", "check": "bun src/check.ts", "t": "cd ../ && bun t $* && cd ./server", "parallel-tests": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/testRunner/runParallelGroupsV3.ts", @@ -29,7 +30,7 @@ "clear-master": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/clearMasterOrg.ts", "cm": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/clearMaster.ts", "ts": "bunx tsgo --build --noEmit", - "test:unit": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test tests/unit", + "test:unit": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test --isolate tests/unit", "test:integration": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts", "loadtest": "ENV_FILE=.env infisical run --env=dev --recursive -- npx artillery run perf/load-test/artillery.yml", "loadtest:leak": "ENV_FILE=.env infisical run --env=dev --recursive -- bun perf/load-test/runLeakTest.ts", @@ -46,6 +47,7 @@ "dependencies": { "@ai-sdk/anthropic": "^3.0.9", "@anthropic-ai/sdk": "^0.32.1", + "@autumn/auth": "workspace:*", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", "@autumn/stripe-sync": "workspace:*", @@ -78,7 +80,7 @@ "@opentelemetry/sdk-trace-base": "^2.6.0", "@posthog/ai": "^7.4.2", "@puzzmo/revenue-cat-webhook-types": "^1.1.0", - "@react-email/components": "^0.0.42", + "@react-email/components": "0.0.42", "@sentry/bun": "catalog:", "@supabase/supabase-js": "^2.46.2", "@tinybirdco/sdk": "^0.0.69", @@ -137,8 +139,9 @@ "posthog-node": "^5.20.0", "puppeteer-core": "^24.14.0", "qs": "^6.14.0", - "react": "^18.2.0", - "resend": "^4.1.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "resend": "4.8.0", "semver": "^7.7.2", "stripe": "catalog:", "svix": "^1.45.1", @@ -155,8 +158,8 @@ "@types/mocha": "^10.0.10", "@types/node": "^25.0.7", "@types/pg": "8.11.10", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", "@types/ws": "^8.18.1", "artillery": "^2.0.30", "cross-env": "^7.0.3", diff --git a/server/src/cron/invoiceCron/runInvoiceCron.ts b/server/src/cron/invoiceCron/runInvoiceCron.ts index 6392f9078..675f95405 100644 --- a/server/src/cron/invoiceCron/runInvoiceCron.ts +++ b/server/src/cron/invoiceCron/runInvoiceCron.ts @@ -113,26 +113,34 @@ export const handleVoidInvoiceCron = async ({ } }; +export const getExpiredInvoiceMetadata = async ({ + db, +}: { + db: CronContext["db"]; +}) => { + return db + .select() + .from(metadata) + .where( + and( + or( + eq(metadata.type, MetadataType.InvoiceActionRequired), + eq(metadata.type, MetadataType.InvoiceCheckout), + eq(metadata.type, MetadataType.DeferredInvoice), + ), + isNotNull(metadata.expires_at), + lt(metadata.expires_at, Date.now()), + isNotNull(metadata.stripe_invoice_id), + ), + ); +}; + export const runInvoiceCron = async ({ ctx }: { ctx: CronContext }) => { try { console.log("Running invoice cron"); const { db } = ctx; - // 1. Fetch from metadata invoices - const invoices = await db - .select() - .from(metadata) - .where( - and( - or( - eq(metadata.type, MetadataType.InvoiceActionRequired), - eq(metadata.type, MetadataType.InvoiceCheckout), - eq(metadata.type, MetadataType.DeferredInvoice), - ), - lt(metadata.expires_at, Date.now()), - isNotNull(metadata.stripe_invoice_id), - ), - ); + const invoices = await getExpiredInvoiceMetadata({ db }); const batchSize = 50; for (let i = 0; i < invoices.length; i += batchSize) { diff --git a/server/src/db/dbUtils.ts b/server/src/db/dbUtils.ts index feb1ff2ca..2cfc08291 100644 --- a/server/src/db/dbUtils.ts +++ b/server/src/db/dbUtils.ts @@ -113,12 +113,11 @@ export const isConnectionDropError = ({ return CONNECTION_DROP_CODES.has(code); }; -/** Throws if DATABASE_URL looks like a production database. Single source of truth for this check. */ -export const assertNotProductionDb = () => { - const url = process.env.DATABASE_URL || ""; +/** Throws if the connection string looks like a production database. Single source of truth for this check. */ +export const assertNotProductionDb = (url = process.env.DATABASE_URL || "") => { if (url.includes("us-east-2")) { throw new Error( - "Refusing to run against production database (DATABASE_URL contains us-east-2)", + "Refusing to run against production database (connection string contains us-east-2)", ); } }; diff --git a/server/src/db/pgPoolMonitor.ts b/server/src/db/pgPoolMonitor.ts index 2d59eec78..21aed6622 100644 --- a/server/src/db/pgPoolMonitor.ts +++ b/server/src/db/pgPoolMonitor.ts @@ -49,23 +49,23 @@ export const attachPoolErrorHandlers = ({ }; const emitSnapshot = (): void => { - const role = getRole(); - for (const { pool, name, max } of registry.values()) { - const totalCount = pool.totalCount; - const idleCount = pool.idleCount; - const waitingCount = pool.waitingCount; - logger.debug("pg_pool_stats", { - type: "pg_pool_stats", - pool: name, - pid: process.pid, - role, - totalCount, - idleCount, - waitingCount, - max, - utilization: max > 0 ? totalCount / max : 0, - }); - } + // const role = getRole(); + // for (const { pool, name, max } of registry.values()) { + // const totalCount = pool.totalCount; + // const idleCount = pool.idleCount; + // const waitingCount = pool.waitingCount; + // logger.debug("pg_pool_stats", { + // type: "pg_pool_stats", + // pool: name, + // pid: process.pid, + // role, + // totalCount, + // idleCount, + // waitingCount, + // max, + // utilization: max > 0 ? totalCount / max : 0, + // }); + // } }; export const startPgPoolMonitor = (intervalMs = 30_000): void => { diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 5b7611ee3..2e76b5c22 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -40,6 +40,8 @@ import { type Operations, type OrgConfig, type ProductItem, + type RecalculateBalanceParamsV0, + type RecalculateBalancePreview, type RestoreParamsV1, type RestoreResponse, type RewardRedemption, @@ -182,7 +184,8 @@ export class AutumnInt { if (parsed && typeof parsed === "object") { throw new AutumnError({ - message: parsed.message ?? `request failed (status ${response.status})`, + message: + parsed.message ?? `request failed (status ${response.status})`, code: parsed.code ?? ErrCode.InternalError, }); } @@ -985,6 +988,7 @@ export class AutumnInt { id: string; filter?: MigrationFilter | null; operations?: Operations | null; + no_billing_changes?: boolean; }): Promise => { const data = await this.post(`/migrations.create`, params); return data as Migration; @@ -999,7 +1003,8 @@ export class AutumnInt { id?: string; filter?: MigrationFilter | null; operations?: Operations | null; - retry_failed?: boolean; + no_billing_changes?: boolean; + archived?: boolean; }; }): Promise => { const data = await this.post(`/migrations.update`, params); @@ -1013,6 +1018,7 @@ export class AutumnInt { id: string; filter?: MigrationFilter | null; operations?: Operations | null; + no_billing_changes?: boolean; }): Promise => { try { await this.post(`/migrations.delete`, { id: params.id }); @@ -1032,11 +1038,14 @@ export class AutumnInt { dry_run?: boolean; only?: string[]; limit?: number; + concurrency?: number; lazy_run?: boolean; + retry_item_statuses?: ("failed" | "skipped")[]; }): Promise<{ migration_id: string; dry_run: boolean; lazy_run: boolean; + concurrency?: number; run_id: string; }> => { const data = await this.post(`/migrations.run`, params); @@ -1044,6 +1053,7 @@ export class AutumnInt { migration_id: string; dry_run: boolean; lazy_run: boolean; + concurrency?: number; run_id: string; }; }, @@ -1056,6 +1066,20 @@ export class AutumnInt { const data = await this.post(`/migrations.lazy_run`, params); return data as { migration_id: string; run_id: string }; }, + cancelRun: async (params: { + id: string; + }): Promise<{ + migration_id: string; + run_id: string; + canceled: boolean; + }> => { + const data = await this.post(`/migrations.cancel_run`, params); + return data as { + migration_id: string; + run_id: string; + canceled: boolean; + }; + }, listRuns: async (params: { migrationId: string; }): Promise<{ list: MigrationRun[] }> => { @@ -1090,6 +1114,16 @@ export class AutumnInt { const data = await this.post(`/balances.delete`, params); return data; }, + recalculate: async (params: RecalculateBalanceParamsV0) => { + const data = await this.post(`/balances.recalculate`, params); + return data; + }, + previewRecalculate: async ( + params: RecalculateBalanceParamsV0, + ): Promise => { + const data = await this.post(`/balances.preview_recalculate`, params); + return data as RecalculateBalancePreview; + }, finalize: async ( params: FinalizeLockParamsV0, { diff --git a/server/src/external/axiom/queryAxiom.ts b/server/src/external/axiom/queryAxiom.ts new file mode 100644 index 000000000..50f8c2940 --- /dev/null +++ b/server/src/external/axiom/queryAxiom.ts @@ -0,0 +1,12 @@ +import { getAxiomClient } from "./initAxiom.js"; + +export const queryAxiom = async ({ + apl, + options, +}: { + apl: string; + options?: { + startTime?: string; + endTime?: string; + }; +}) => getAxiomClient().query(apl, options); diff --git a/server/src/external/axiom/aplUtils.ts b/server/src/external/axiom/utils/aplUtils.ts similarity index 92% rename from server/src/external/axiom/aplUtils.ts rename to server/src/external/axiom/utils/aplUtils.ts index faea744dd..46e2633a7 100644 --- a/server/src/external/axiom/aplUtils.ts +++ b/server/src/external/axiom/utils/aplUtils.ts @@ -44,8 +44,8 @@ export const buildRequestLogsQuery = ({ }): string => { const filters: string[] = [ `_time > ago(${rangeDays}d)`, - `isnotnull(statusCode)`, - `isnotnull(['req.url'])`, + "isnotnull(statusCode)", + "isnotnull(['req.url'])", `(['context.org_slug'] == '${escapeApl(orgSlug)}' or orgSlug == '${escapeApl(orgSlug)}')`, `(['context.env'] == '${escapeApl(env)}' or env == '${escapeApl(env)}')`, `(['req.customer_id'] == '${escapeApl(customerId)}' or customer_id == '${escapeApl(customerId)}')`, @@ -65,7 +65,7 @@ export const buildRequestLogsQuery = ({ ); } - const wheres = filters.map((f) => `| where ${f}`).join("\n"); + const wheres = filters.map((filter) => `| where ${filter}`).join("\n"); return `['express'] ${wheres} diff --git a/server/src/external/axiom/utils/resultUtils.ts b/server/src/external/axiom/utils/resultUtils.ts new file mode 100644 index 000000000..26503622c --- /dev/null +++ b/server/src/external/axiom/utils/resultUtils.ts @@ -0,0 +1,18 @@ +export const getAxiomMatchData = ( + result: unknown, +): Record[] => + result && typeof result === "object" && "matches" in result + ? ( + (result as { matches?: Array<{ data?: Record }> }) + .matches ?? [] + ).flatMap((match) => (match.data ? [match.data] : [])) + : []; + +export const axiomNumberFrom = (value: unknown) => { + if (typeof value === "number") return value; + if (typeof value === "string") return Number(value.replace(/,/g, "")); + return 0; +}; + +export const axiomStringFrom = (value: unknown) => + typeof value === "string" ? value : ""; diff --git a/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts index 116e7e92c..1401b3f14 100644 --- a/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts +++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts @@ -1,32 +1,30 @@ import { AppEnv } from "@shared/index"; import { Scopes } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { + getRevenuecatAccessToken, + getRevenuecatProjectId, +} from "../misc/getRevenuecatAccessToken"; import { initRevenuecatCli } from "../misc/initRevenuecatCli"; export const handleGetRevenueCatProducts = createRoute({ scopes: [Scopes.Organisation.Read], handler: async (c) => { - const { org, env } = c.get("ctx"); + const { db, org, env } = c.get("ctx"); const revenueCatConfig = org.processor_configs?.revenuecat; if (!revenueCatConfig) { return c.json({ products: [] }, 404); } - const projectId = - env === AppEnv.Live - ? revenueCatConfig.project_id - : revenueCatConfig.sandbox_project_id; - const apiKey = - env === AppEnv.Live - ? revenueCatConfig.api_key - : revenueCatConfig.sandbox_api_key; + const projectId = getRevenuecatProjectId({ revenueCatConfig, env }); + const accessToken = await getRevenuecatAccessToken({ db, org, env }); - if (!projectId || !apiKey) { + if (!projectId || !accessToken) { return c.json({ products: [] }, 404); } - const rcCli = initRevenuecatCli({ projectId, apiKey }); + const rcCli = initRevenuecatCli({ projectId, accessToken }); const products = await rcCli.listProducts(); return c.json(products); diff --git a/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts new file mode 100644 index 000000000..fae9585a1 --- /dev/null +++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts @@ -0,0 +1,52 @@ +import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { getRevenuecatAccessToken } from "../misc/getRevenuecatAccessToken"; +import { initRevenuecatCli } from "../misc/initRevenuecatCli"; + +export const handleGetRevenueCatProjects = createRoute({ + scopes: [Scopes.Organisation.Read], + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + const revenueCatConfig = org.processor_configs?.revenuecat; + + if (!revenueCatConfig) { + return c.json({ projects: [] }, 404); + } + + const accessToken = await getRevenuecatAccessToken({ db, org, env }); + + if (!accessToken) { + return c.json({ projects: [] }, 404); + } + + const rcCli = initRevenuecatCli({ accessToken }); + const projects = await rcCli.listProjects(); + + return c.json(projects); + }, +}); + +export const handleCreateRevenueCatProject = createRoute({ + scopes: [Scopes.Organisation.Write], + body: z.object({ name: z.string().min(1).max(255) }), + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + const { name } = c.req.valid("json"); + + const accessToken = await getRevenuecatAccessToken({ db, org, env }); + if (!accessToken) { + throw new RecaseError({ + message: "Connect RevenueCat via OAuth before creating a project", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + const rcCli = initRevenuecatCli({ accessToken }); + const project = await rcCli.createProject({ name }); + + return c.json({ id: project.id, name: project.name }); + }, +}); diff --git a/server/src/external/revenueCat/handlers/handleListRevenueCatMappings.ts b/server/src/external/revenueCat/handlers/handleListRevenueCatMappings.ts new file mode 100644 index 000000000..0d3019946 --- /dev/null +++ b/server/src/external/revenueCat/handlers/handleListRevenueCatMappings.ts @@ -0,0 +1,24 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { RCMappingService } from "../misc/RCMappingService.js"; + +/** + * POST /v1/plans.revenuecat_mappings — returns each Autumn plan's RevenueCat store + * product identifier(s) for the calling org/env, so SDK implementers can map a plan + * to the product to purchase without reconstructing the identifier themselves. + */ +export const handleListRevenueCatMappings = createRoute({ + scopes: [Scopes.Plans.Read], + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + + const rows = await RCMappingService.getAll({ db, orgId: org.id, env }); + + return c.json({ + mappings: rows.map((row) => ({ + autumn_product_id: row.autumn_product_id, + revenuecat_product_ids: row.revenuecat_product_ids, + })), + }); + }, +}); diff --git a/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts b/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts new file mode 100644 index 000000000..5b0102c80 --- /dev/null +++ b/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts @@ -0,0 +1,126 @@ +import { + type AppEnv, + type FullProduct, + type Organization, + Scopes, +} from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { ProductService } from "@/internal/products/ProductService"; +import { + getRevenuecatAccessToken, + getRevenuecatProjectId, +} from "../misc/getRevenuecatAccessToken"; +import { initRevenuecatCli } from "../misc/initRevenuecatCli"; +import type { RevenueCatPrice, RevenueCatProduct } from "../revenuecatTypes"; +import { + getRcBasePrice, + getRcStoreIdentifier, +} from "../sync/revenuecatProductSyncUtils"; + +type PreflightPrice = { amount_micros: number; currency: string }; + +export type PreflightItem = { + plan_id: string; + autumn_name: string; + autumn_price: PreflightPrice | null; + rc_exists: boolean; + rc_name: string | null; + rc_price: PreflightPrice | null; +}; + +/** + * Pure assembly of the preflight diff: match each plan to its minted RC product and + * surface Autumn's base price alongside RC's. `listPrices` is injected so this stays + * free of network/DB — the handler wires in `rcCli.listProductPrices`. + */ +export const buildRcPreflightItems = async ({ + products, + rcProducts, + org, + env, + listPrices, +}: { + products: FullProduct[]; + rcProducts: RevenueCatProduct[]; + org: Organization; + env: AppEnv; + listPrices: (rcProductId: string) => Promise; +}): Promise => { + // One RC product per store_identifier is enough to read the name + price. + const rcByStoreId = new Map(); + for (const rcProduct of rcProducts) { + if (!rcByStoreId.has(rcProduct.store_identifier)) { + rcByStoreId.set(rcProduct.store_identifier, rcProduct); + } + } + + return Promise.all( + products.map(async (product) => { + const storeId = getRcStoreIdentifier({ + env, + orgId: org.id, + planId: product.id, + }); + const base = getRcBasePrice({ product, org }); + const autumn_price = base + ? { amount_micros: base.amountMicros, currency: base.currency } + : null; + + const rcProduct = rcByStoreId.get(storeId); + if (!rcProduct) { + return { + plan_id: product.id, + autumn_name: product.name || product.id, + autumn_price, + rc_exists: false, + rc_name: null, + rc_price: null, + }; + } + + const prices = await listPrices(rcProduct.id); + const rc_price = prices[0] + ? { amount_micros: prices[0].amount_micros, currency: prices[0].currency } + : null; + + return { + plan_id: product.id, + autumn_name: product.name || product.id, + autumn_price, + rc_exists: true, + rc_name: rcProduct.display_name, + rc_price, + }; + }), + ); +}; + +/** Read-only preview of what a sync would do per plan (create/rename) + Autumn-vs-RC price divergence. */ +export const handlePreflightRevenueCatSync = createRoute({ + scopes: [Scopes.Organisation.Read], + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + const revenueCatConfig = org.processor_configs?.revenuecat; + if (!revenueCatConfig) return c.json({ items: [] }); + + const projectId = getRevenuecatProjectId({ revenueCatConfig, env }); + const accessToken = await getRevenuecatAccessToken({ db, org, env }); + if (!projectId || !accessToken) return c.json({ items: [] }); + + const rcCli = initRevenuecatCli({ projectId, accessToken }); + const [products, rcProducts] = await Promise.all([ + ProductService.listFull({ db, orgId: org.id, env }), + rcCli.listAllProducts(), + ]); + + const items = await buildRcPreflightItems({ + products, + rcProducts, + org, + env, + listPrices: (id) => rcCli.listProductPrices(id), + }); + + return c.json({ items }); + }, +}); diff --git a/server/src/external/revenueCat/handlers/handleSyncRevenueCatProducts.ts b/server/src/external/revenueCat/handlers/handleSyncRevenueCatProducts.ts new file mode 100644 index 000000000..3d37582ee --- /dev/null +++ b/server/src/external/revenueCat/handlers/handleSyncRevenueCatProducts.ts @@ -0,0 +1,21 @@ +import { Scopes } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { syncProductsToRevenueCat } from "../sync/syncRevenueCatProducts.js"; + +/** POST /v1/organization/revenuecat/sync — push selected Autumn plans into RevenueCat. */ +export const handleSyncRevenueCatProducts = createRoute({ + scopes: [Scopes.Organisation.Write], + body: z.object({ product_ids: z.array(z.string()).min(1) }), + handler: async (c) => { + const ctx = c.get("ctx"); + const { product_ids } = c.req.valid("json"); + + const results = await syncProductsToRevenueCat({ + ctx, + productIds: product_ids, + }); + + return c.json({ results }); + }, +}); diff --git a/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts b/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts new file mode 100644 index 000000000..49331fed4 --- /dev/null +++ b/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts @@ -0,0 +1,152 @@ +import { + AppEnv, + type Organization, + type RevenueCatOAuthConfig, + type RevenueCatProcessorConfig, +} from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { refreshRcTokens } from "@/external/revenueCat/misc/revenuecatOAuth.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { decryptData, encryptData } from "@/utils/encryptUtils.js"; + +const TOKEN_EXPIRY_SKEW_MS = 60_000; + +const getOAuthConfigForEnv = ({ + revenueCatConfig, + env, +}: { + revenueCatConfig: RevenueCatProcessorConfig; + env: AppEnv; +}): RevenueCatOAuthConfig | undefined => + env === AppEnv.Live ? revenueCatConfig.oauth : revenueCatConfig.sandbox_oauth; + +const persistOAuthTokens = async ({ + db, + org, + env, + oauthConfig, +}: { + db: DrizzleCli; + org: Organization; + env: AppEnv; + oauthConfig: RevenueCatOAuthConfig; +}) => { + const existing = org.processor_configs?.revenuecat || {}; + + await OrgService.update({ + db, + orgId: org.id, + updates: { + processor_configs: { + ...org.processor_configs, + revenuecat: { + ...existing, + ...(env === AppEnv.Live + ? { oauth: oauthConfig } + : { sandbox_oauth: oauthConfig }), + }, + }, + }, + }); +}; + +const isOAuthAccessTokenValid = (oauthConfig: RevenueCatOAuthConfig) => + oauthConfig.expires_at - TOKEN_EXPIRY_SKEW_MS > Date.now(); + +/** Rotate the OAuth tokens, persist the new pair, and return the fresh access token. */ +const refreshAndPersistTokens = async ({ + db, + org, + env, + oauthConfig, +}: { + db: DrizzleCli; + org: Organization; + env: AppEnv; + oauthConfig: RevenueCatOAuthConfig; +}): Promise => { + const refreshToken = decryptData(oauthConfig.refresh_token); + const tokens = await refreshRcTokens({ refreshToken }); + + const refreshedOAuthConfig: RevenueCatOAuthConfig = { + ...oauthConfig, + access_token: encryptData(tokens.accessToken()), + refresh_token: encryptData(tokens.refreshToken()), + expires_at: tokens.accessTokenExpiresAt().getTime(), + ...(tokens.hasScopes() ? { scope: tokens.scopes().join(" ") } : {}), + }; + + await persistOAuthTokens({ db, org, env, oauthConfig: refreshedOAuthConfig }); + return tokens.accessToken(); +}; + +/** + * Force-refresh the env's OAuth access token, persisting the rotated refresh token for us. + * Returns the fresh access token, or null if the org isn't OAuth-connected for this env. + * Used to hand a platform master a usable access token WITHOUT exposing the refresh token — + * so they can't rotate it and lock Autumn out. + */ +export const refreshRevenuecatOAuthAccessToken = async ({ + db, + org, + env, +}: { + db: DrizzleCli; + org: Organization; + env: AppEnv; +}): Promise => { + const oauthConfig = getOAuthConfigForEnv({ + revenueCatConfig: org.processor_configs?.revenuecat ?? {}, + env, + }); + if (!oauthConfig) return null; + return refreshAndPersistTokens({ db, org, env, oauthConfig }); +}; + +export const getRevenuecatAccessToken = async ({ + db, + org, + env, +}: { + db: DrizzleCli; + org: Organization; + env: AppEnv; +}): Promise => { + const revenueCatConfig = org.processor_configs?.revenuecat; + if (!revenueCatConfig) return null; + + const oauthConfig = getOAuthConfigForEnv({ revenueCatConfig, env }); + + if (oauthConfig) { + if (isOAuthAccessTokenValid(oauthConfig)) { + return decryptData(oauthConfig.access_token); + } + + return refreshAndPersistTokens({ db, org, env, oauthConfig }); + } + + const apiKey = + env === AppEnv.Live + ? revenueCatConfig.api_key + : revenueCatConfig.sandbox_api_key; + + return apiKey ? decryptData(apiKey) : null; +}; + +export const getRevenuecatProjectId = ({ + revenueCatConfig, + env, +}: { + revenueCatConfig: RevenueCatProcessorConfig; + env: AppEnv; +}): string | undefined => { + const oauthConfig = getOAuthConfigForEnv({ revenueCatConfig, env }); + + if (oauthConfig?.project_id) { + return oauthConfig.project_id; + } + + return env === AppEnv.Live + ? revenueCatConfig.project_id + : revenueCatConfig.sandbox_project_id; +}; diff --git a/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts b/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts index 45e504c66..66e167935 100644 --- a/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts +++ b/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts @@ -1,5 +1,17 @@ import { AppEnv, type Organization } from "@autumn/shared"; +/** Random 64-char alphanumeric secret RevenueCat echoes back in the Authorization header. */ +export const generateRevenuecatWebhookSecret = (): string => { + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + const randomBytes = crypto.getRandomValues(new Uint8Array(64)); + for (let i = 0; i < 64; i++) { + result += chars[randomBytes[i] % chars.length]; + } + return result; +}; + export const getRevenuecatWebhookSecret = ({ org, env, diff --git a/server/src/external/revenueCat/misc/initRevenuecatCli.ts b/server/src/external/revenueCat/misc/initRevenuecatCli.ts index 6067d3314..cf5832c1a 100644 --- a/server/src/external/revenueCat/misc/initRevenuecatCli.ts +++ b/server/src/external/revenueCat/misc/initRevenuecatCli.ts @@ -1,34 +1,267 @@ import { decryptData } from "@server/utils/encryptUtils.js"; -import type { RevenueCatProductsResponse } from "../revenuecatTypes"; +import { callRcMcpTool } from "./revenuecatMcp.js"; +import type { + RevenueCatApp, + RevenueCatAppsResponse, + RevenueCatCreateInStoreBody, + RevenueCatCreateProductBody, + RevenueCatCreateProjectBody, + RevenueCatPrice, + RevenueCatProduct, + RevenueCatProductsResponse, + RevenueCatProject, + RevenueCatProjectsResponse, + RevenueCatCreateWebhookBody, + RevenueCatPublicApiKey, + RevenueCatPublicApiKeysResponse, + RevenueCatUpdateProductBody, + RevenueCatWebhookIntegration, + RevenueCatWebhooksResponse, +} from "../revenuecatTypes"; type ListRevenuecatProductsResponse = { products: { id: string; name: string }[]; }; +type ListRevenuecatProjectsResponse = { + projects: { id: string; name: string }[]; +}; + export const initRevenuecatCli = ({ projectId, apiKey, + accessToken, + // Injected so unit tests can supply a fake transport instead of touching global fetch. + fetchImpl = fetch, }: { - projectId: string; - apiKey: string; + projectId?: string; + apiKey?: string; + accessToken?: string; + fetchImpl?: typeof fetch; }) => { - let resolvedApiKey = apiKey; + const resolvedAccessToken = + accessToken ?? (apiKey ? decryptData(apiKey) : undefined); - resolvedApiKey = decryptData(apiKey); + if (!resolvedAccessToken) { + throw new Error("RevenueCat access token or API key is required"); + } + + const authHeaders = { + Authorization: `Bearer ${resolvedAccessToken}`, + "Content-Type": "application/json", + }; + + const checkOk = async (response: Response) => { + if (!response.ok) { + let message: string; + try { + const body = await response.json(); + message = JSON.stringify(body); + } catch { + message = response.statusText; + } + const error = new Error( + `RevenueCat error (${response.status}): ${message}`, + ) as Error & { status: number }; + error.status = response.status; + throw error; + } + }; return { + createProject: async ({ name }: RevenueCatCreateProjectBody) => { + const url = new URL("https://api.revenuecat.com/v2/projects"); + const response = await fetchImpl(url, { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ name }), + }); + await checkOk(response); + return (await response.json()) as RevenueCatProject; + }, + + listAppPublicApiKeys: async ( + appId: string, + ): Promise => { + const url = new URL( + `https://api.revenuecat.com/v2/projects/${projectId}/apps/${appId}/public_api_keys`, + ); + const response = await fetchImpl(url, { headers: authHeaders }); + await checkOk(response); + const data = (await response.json()) as + | RevenueCatPublicApiKeysResponse + | RevenueCatPublicApiKey[]; + return Array.isArray(data) ? data : (data.items ?? []); + }, + + listApps: async (): Promise => { + const url = new URL( + `https://api.revenuecat.com/v2/projects/${projectId}/apps`, + ); + url.searchParams.set("limit", "50"); + + const response = await fetchImpl(url, { headers: authHeaders }); + await checkOk(response); + + const data = (await response.json()) as RevenueCatAppsResponse; + return data.items ?? []; + }, + + createProduct: async (body: RevenueCatCreateProductBody) => { + const url = new URL( + `https://api.revenuecat.com/v2/projects/${projectId}/products`, + ); + const response = await fetchImpl(url, { + method: "POST", + headers: authHeaders, + body: JSON.stringify(body), + }); + await checkOk(response); + return (await response.json()) as RevenueCatProduct; + }, + + findProductByStoreIdentifier: async ({ + appId, + storeIdentifier, + }: { + appId: string; + storeIdentifier: string; + }): Promise => { + let nextPage: + | string + | null = `/v2/projects/${projectId}/products?limit=100`; + + while (nextPage) { + const response = await fetchImpl( + new URL(`https://api.revenuecat.com${nextPage}`), + { headers: authHeaders }, + ); + await checkOk(response); + const data = (await response.json()) as RevenueCatProductsResponse; + + const match = data.items.find( + (p) => + p.app_id === appId && p.store_identifier === storeIdentifier, + ); + if (match) return match; + + nextPage = data.next_page; + } + + return null; + }, + + listAllProducts: async (): Promise => { + const items: RevenueCatProduct[] = []; + let nextPage: + | string + | null = `/v2/projects/${projectId}/products?limit=100`; + + while (nextPage) { + const response = await fetchImpl( + new URL(`https://api.revenuecat.com${nextPage}`), + { headers: authHeaders }, + ); + await checkOk(response); + const data = (await response.json()) as RevenueCatProductsResponse; + items.push(...data.items); + nextPage = data.next_page; + } + + return items; + }, + + listProductPrices: async ( + revenuecatProductId: string, + ): Promise => { + const url = new URL( + `https://api.revenuecat.com/v2/projects/${projectId}/products/${revenuecatProductId}/prices`, + ); + const response = await fetchImpl(url, { headers: authHeaders }); + await checkOk(response); + // RC returns a bare array here, not the usual { items } envelope. + const data = (await response.json()) as + | RevenueCatPrice[] + | { items?: RevenueCatPrice[] }; + return Array.isArray(data) ? data : (data.items ?? []); + }, + + // Test-store prices can't be set over the REST API — only via RC's MCP server. + setTestStoreProductPrice: async ( + revenuecatProductId: string, + { amountMicros, currency }: { amountMicros: number; currency: string }, + ) => + callRcMcpTool({ + accessToken: resolvedAccessToken, + name: "create-product-prices", + arguments: { + project_id: projectId, + product_id: revenuecatProductId, + prices: [{ amount_micros: amountMicros, currency }], + }, + fetchImpl, + }), + + listProductStoreIdentifiers: async (): Promise> => { + const ids = new Set(); + let nextPage: + | string + | null = `/v2/projects/${projectId}/products?limit=100`; + + while (nextPage) { + const response = await fetchImpl( + new URL(`https://api.revenuecat.com${nextPage}`), + { headers: authHeaders }, + ); + await checkOk(response); + const data = (await response.json()) as RevenueCatProductsResponse; + for (const product of data.items) ids.add(product.store_identifier); + nextPage = data.next_page; + } + + return ids; + }, + + updateProduct: async ( + revenuecatProductId: string, + body: RevenueCatUpdateProductBody, + ) => { + const url = new URL( + `https://api.revenuecat.com/v2/projects/${projectId}/products/${revenuecatProductId}`, + ); + const response = await fetchImpl(url, { + method: "POST", + headers: authHeaders, + body: JSON.stringify(body), + }); + await checkOk(response); + return (await response.json()) as RevenueCatProduct; + }, + + createInStore: async ( + revenuecatProductId: string, + body?: RevenueCatCreateInStoreBody, + ) => { + const url = new URL( + `https://api.revenuecat.com/v2/projects/${projectId}/products/${revenuecatProductId}/create_in_store`, + ); + const response = await fetchImpl(url, { + method: "POST", + headers: authHeaders, + body: JSON.stringify(body ?? {}), + }); + await checkOk(response); + return await response.json(); + }, + listProducts: async () => { const url = new URL( `https://api.revenuecat.com/v2/projects/${projectId}/products`, ); url.searchParams.set("limit", "20"); - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${resolvedApiKey}`, - "Content-Type": "application/json", - }, - }); + const response = await fetchImpl(url, { headers: authHeaders }); + await checkOk(response); const data = (await response.json()) as RevenueCatProductsResponse; @@ -50,5 +283,59 @@ export const initRevenuecatCli = ({ })), } satisfies ListRevenuecatProductsResponse; }, + + listWebhookIntegrations: async (): Promise< + RevenueCatWebhookIntegration[] + > => { + const items: RevenueCatWebhookIntegration[] = []; + let nextPage: + | string + | null = `/v2/projects/${projectId}/integrations/webhooks?limit=100`; + + while (nextPage) { + const response = await fetchImpl( + new URL(`https://api.revenuecat.com${nextPage}`), + { headers: authHeaders }, + ); + await checkOk(response); + const data = (await response.json()) as RevenueCatWebhooksResponse; + items.push(...data.items); + nextPage = data.next_page; + } + + return items; + }, + + createWebhookIntegration: async ( + body: RevenueCatCreateWebhookBody, + ): Promise => { + const url = new URL( + `https://api.revenuecat.com/v2/projects/${projectId}/integrations/webhooks`, + ); + const response = await fetchImpl(url, { + method: "POST", + headers: authHeaders, + body: JSON.stringify(body), + }); + await checkOk(response); + return (await response.json()) as RevenueCatWebhookIntegration; + }, + + listProjects: async () => { + const url = new URL("https://api.revenuecat.com/v2/projects"); + url.searchParams.set("limit", "100"); + + const response = await fetchImpl(url, { headers: authHeaders }); + await checkOk(response); + + const data = (await response.json()) as RevenueCatProjectsResponse; + + return { + projects: (data.items ?? []).map((project) => ({ + id: project.id, + name: project.name, + })), + } satisfies ListRevenuecatProjectsResponse; + }, }; }; diff --git a/server/src/external/revenueCat/misc/provisionRevenueCatCusProduct.ts b/server/src/external/revenueCat/misc/provisionRevenueCatCusProduct.ts new file mode 100644 index 000000000..0d726f283 --- /dev/null +++ b/server/src/external/revenueCat/misc/provisionRevenueCatCusProduct.ts @@ -0,0 +1,94 @@ +import { + type BillingContextOverride, + ErrCode, + type FullCusProduct, + type FullCustomer, + type FullProduct, + ProcessorType, + RecaseError, +} from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { attach } from "@/internal/billing/v2/actions/attach/attach"; +import { customerProductRepo } from "@/internal/customers/cusProducts/repos"; + +/** + * Provisions a RevenueCat customer product via V2 attach. + * + * RC payments happen on App Store / Play Store / etc., so Autumn never reads + * or writes Stripe state for these flows. We funnel through V2 `attach()` so + * the new cus_product, entitlements, prices, line items, webhooks, and rollover + * carry-overs all run through the same pipeline as Stripe/Vercel, just with + * the Stripe and external-PSP guards disabled. + * + * Handles new / upgrade / downgrade scenarios via `computeAttachPlan`'s + * transition logic — the caller does not need to expire the outgoing + * cus_product manually. Transitions are forced immediate (`plan_schedule`) + * since RC is the payment source-of-truth; we don't schedule downgrades + * end-of-cycle the way a Stripe-billed attach would. + */ +export const provisionRevenueCatCusProduct = async ({ + ctx, + customer, + product, + revenuecatMetadata, +}: { + ctx: AutumnContext; + customer: FullCustomer; + product: FullProduct; + revenuecatMetadata?: Record; +}): Promise<{ cusProduct: FullCusProduct; product: FullProduct }> => { + const { db, org, env } = ctx; + + // `resolveRevenuecatResources` loads `customer` with `withEntities: true`, which + // is what `setupFullCustomerContext` would do anyway. Passing it as an override + // skips a redundant DB fetch. + const contextOverride: BillingContextOverride = { + fullCustomer: customer, + productContext: { fullProduct: product }, + skipBillingFetching: true, + skipExternalPSPGuard: true, + processorTypeOverride: ProcessorType.RevenueCat, + }; + + await attach({ + ctx, + params: { + customer_id: customer.id || customer.internal_id, + plan_id: product.id, + redirect_mode: "if_required", + no_billing_changes: true, + enable_plan_immediately: true, + // RC payments are source-of-truth: apply product changes now rather + // than scheduling downgrades end-of-cycle like Stripe-style attaches. + plan_schedule: "immediate", + ...(revenuecatMetadata ? { metadata: revenuecatMetadata } : {}), + }, + contextOverride, + skipAutumnCheckout: true, + }); + + const cusProducts = await customerProductRepo.getByCustomerAndProduct({ + db, + internalCustomerId: customer.internal_id, + internalProductId: product.internal_id, + orgId: org.id, + env, + inStatuses: ["active", "trialing", "scheduled"], + }); + + const cusProduct = cusProducts.find( + (cp) => cp.processor?.type === ProcessorType.RevenueCat, + ); + + if (!cusProduct) { + throw new RecaseError({ + message: + "Failed to find newly-provisioned RevenueCat customer product after attach", + code: ErrCode.CusProductNotFound, + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + }); + } + + return { cusProduct, product }; +}; diff --git a/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts b/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts new file mode 100644 index 000000000..76328996d --- /dev/null +++ b/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts @@ -0,0 +1,57 @@ +import { AppEnv } from "@autumn/shared"; +import type { initRevenuecatCli } from "./initRevenuecatCli.js"; + +type RcCli = ReturnType; + +/** + * Outbound base URL for our webhook receiver. Dev/staging use NGROK_URL (so RevenueCat + * can reach a local tunnel); production uses BETTER_AUTH_URL. + */ +const getServerBaseUrl = (): string | undefined => + process.env.NODE_ENV !== "production" + ? process.env.NGROK_URL + : process.env.BETTER_AUTH_URL; + +export const getRevenuecatWebhookUrl = ({ + orgId, + env, +}: { + orgId: string; + env: AppEnv; +}): string | null => { + const base = getServerBaseUrl(); + if (!base) return null; + // `:env` segment is the AppEnv value ("sandbox"/"live") — revenueCatMiddleware reads it verbatim. + return `${base.replace(/\/$/, "")}/webhooks/revenuecat/${orgId}/${env}`; +}; + +/** + * Idempotently register the org's RevenueCat webhook for an env: one integration per + * environment, matched by URL, with the org's webhook secret as the Authorization header. + */ +export const registerRevenuecatWebhook = async ({ + rcCli, + orgId, + env, + secret, +}: { + rcCli: RcCli; + orgId: string; + env: AppEnv; + secret: string; +}): Promise<"exists" | "created" | "skipped"> => { + const url = getRevenuecatWebhookUrl({ orgId, env }); + if (!url) return "skipped"; + + const existing = await rcCli.listWebhookIntegrations(); + if (existing.some((webhook) => webhook.url === url)) return "exists"; + + await rcCli.createWebhookIntegration({ + name: `Autumn (${env})`, + url, + authorization_header: secret, + environment: env === AppEnv.Live ? "production" : "sandbox", + // no event_types / app_id → all events, all apps for this environment + }); + return "created"; +}; diff --git a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts index 43f5919ef..0c1264f25 100644 --- a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts +++ b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts @@ -65,6 +65,7 @@ export const resolveRevenuecatResources = async ({ ? getOrCreateCustomer({ ctx, customerId, + withEntities: true, }) : CusService.getFull({ ctx, diff --git a/server/src/external/revenueCat/misc/revenuecatMcp.ts b/server/src/external/revenueCat/misc/revenuecatMcp.ts new file mode 100644 index 000000000..1dad9fa74 --- /dev/null +++ b/server/src/external/revenueCat/misc/revenuecatMcp.ts @@ -0,0 +1,81 @@ +const RC_MCP_URL = "https://mcp.revenuecat.ai/mcp"; + +type JsonRpcResult = { + result?: { isError?: boolean; content?: unknown }; + error?: { message?: string }; +}; + +/** + * Call a tool on RevenueCat's hosted MCP server (the only supported way to write + * test-store prices). Auth is the org's RC token — OAuth (`atk_`) or secret (`sk_`). + */ +export const callRcMcpTool = async ({ + accessToken, + name, + arguments: args, + fetchImpl = fetch, +}: { + accessToken: string; + name: string; + arguments: Record; + fetchImpl?: typeof fetch; +}): Promise => { + const response = await fetchImpl(RC_MCP_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }), + }); + + if (!response.ok) { + throw new Error(`RevenueCat MCP error (${response.status})`); + } + + // Streamable-HTTP MCP replies as SSE and may emit preamble frames (ping, + // progress) before the result — pick the frame that carries result/error. + const text = await response.text(); + const candidates = text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("data:") || line.startsWith("{")) + .map((line) => line.replace(/^data:\s*/, "")); + + let parsed: JsonRpcResult | undefined; + for (const candidate of candidates) { + let frame: JsonRpcResult; + try { + frame = JSON.parse(candidate) as JsonRpcResult; + } catch { + continue; + } + if (frame.result !== undefined || frame.error !== undefined) { + parsed = frame; + break; + } + } + + if (!parsed) { + throw new Error( + `RevenueCat MCP returned no result frame: ${text.slice(0, 200)}`, + ); + } + + if (parsed.error) { + throw new Error(`RevenueCat MCP tool error: ${parsed.error.message ?? "unknown"}`); + } + if (parsed.result?.isError) { + throw new Error( + `RevenueCat MCP tool "${name}" failed: ${JSON.stringify(parsed.result.content).slice(0, 300)}`, + ); + } + + return parsed.result; +}; diff --git a/server/src/external/revenueCat/misc/revenuecatOAuth.ts b/server/src/external/revenueCat/misc/revenuecatOAuth.ts new file mode 100644 index 000000000..b2427bc6b --- /dev/null +++ b/server/src/external/revenueCat/misc/revenuecatOAuth.ts @@ -0,0 +1,114 @@ +import { + CodeChallengeMethod, + generateCodeVerifier, + OAuth2Client, +} from "arctic"; + +const RC_AUTHORIZE_URL = "https://api.revenuecat.com/oauth2/authorize"; +const RC_TOKEN_URL = "https://api.revenuecat.com/oauth2/token"; + +export const RC_OAUTH_SCOPES = [ + "project_configuration:projects:read_write", + "project_configuration:apps:read_write", + "project_configuration:entitlements:read_write", + "project_configuration:offerings:read_write", + "project_configuration:packages:read_write", + "project_configuration:products:read_write", + "project_configuration:integrations:read_write", + "project_configuration:virtual_currencies:read_write", + "customer_information:customers:read_write", + "customer_information:subscriptions:read_write", + "customer_information:purchases:read_write", + "customer_information:invoices:read", + "charts_metrics:overview:read", + "charts_metrics:charts:read", +]; + +const parseScope = (scope: string) => { + const [domain, resource, access] = scope.split(":"); + return { domain, resource, access }; +}; + +// RevenueCat collapses broad grants into wildcards (e.g. "*:*:read_write"); +// read_write also satisfies a read requirement. +const grantSatisfies = (granted: string, required: string): boolean => { + const g = parseScope(granted); + const r = parseScope(required); + const domainOk = g.domain === "*" || g.domain === r.domain; + const resourceOk = g.resource === "*" || g.resource === r.resource; + const accessOk = g.access === "read_write" || g.access === r.access; + return domainOk && resourceOk && accessOk; +}; + +export const findMissingRcScopes = (grantedScopes: string[]): string[] => { + return RC_OAUTH_SCOPES.filter( + (required) => + !grantedScopes.some((granted) => grantSatisfies(granted, required)), + ); +}; + +const getRcOAuthClient = () => { + const clientId = process.env.REVENUECAT_OAUTH_CLIENT_ID; + const clientSecret = process.env.REVENUECAT_OAUTH_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + throw new Error("RevenueCat OAuth client credentials not configured"); + } + + return new OAuth2Client(clientId, clientSecret, getRcOAuthRedirectUri()); +}; + +export const getRcOAuthRedirectUri = () => { + let serverUrl = process.env.BETTER_AUTH_URL; + + if (process.env.NGROK_URL) { + serverUrl = process.env.NGROK_URL; + } + + return `${(serverUrl ?? "").replace(/\/+$/, "")}/revenuecat/oauth_callback`; +}; + +export const createRcAuthorizationUrl = ({ + state, + codeVerifier, + scopes = RC_OAUTH_SCOPES, +}: { + state: string; + codeVerifier: string; + scopes?: string[]; +}) => { + const client = getRcOAuthClient(); + return client.createAuthorizationURLWithPKCE( + RC_AUTHORIZE_URL, + state, + CodeChallengeMethod.S256, + codeVerifier, + scopes, + ); +}; + +export const exchangeRcCode = async ({ + code, + codeVerifier, +}: { + code: string; + codeVerifier: string; +}) => { + const client = getRcOAuthClient(); + return client.validateAuthorizationCode(RC_TOKEN_URL, code, codeVerifier); +}; + +export const refreshRcTokens = async ({ + refreshToken, + // Omit scopes on refresh — re-requesting the full set triggers RC `invalid_scope`. + // An empty list reuses the originally-granted scopes (OAuth2 §6). + scopes = [], +}: { + refreshToken: string; + scopes?: string[]; +}) => { + const client = getRcOAuthClient(); + return client.refreshAccessToken(RC_TOKEN_URL, refreshToken, scopes); +}; + +export { generateCodeVerifier }; diff --git a/server/src/external/revenueCat/revenuecatTypes.ts b/server/src/external/revenueCat/revenuecatTypes.ts index 0277b8657..b5d440533 100644 --- a/server/src/external/revenueCat/revenuecatTypes.ts +++ b/server/src/external/revenueCat/revenuecatTypes.ts @@ -146,6 +146,7 @@ export type RevenueCatProduct = { created_at: number; app_id: string; display_name: string; + state?: string; }; export type RevenueCatProductsResponse = { @@ -154,3 +155,135 @@ export type RevenueCatProductsResponse = { next_page: string | null; url: string; }; + +export type RevenueCatPrice = { + id: string; + amount_micros: number; + currency: string; +}; + +export type RevenueCatPublicApiKey = { + object?: string; + id: string; + key: string; + environment?: string; + app_id?: string; + created_at?: number; +}; + +export type RevenueCatPublicApiKeysResponse = { + object: "list"; + items: RevenueCatPublicApiKey[]; + next_page: string | null; + url: string; +}; + +export type RevenueCatWebhookEnvironment = "production" | "sandbox"; + +export type RevenueCatWebhookIntegration = { + object?: string; + id: string; + project_id?: string; + name: string; + url: string; + environment?: RevenueCatWebhookEnvironment | null; + event_types?: string[] | null; + app_id?: string | null; + created_at?: number; +}; + +export type RevenueCatCreateWebhookBody = { + name: string; + url: string; + authorization_header?: string; + environment?: RevenueCatWebhookEnvironment | null; + event_types?: string[] | null; + app_id?: string | null; +}; + +export type RevenueCatWebhooksResponse = { + object: "list"; + items: RevenueCatWebhookIntegration[]; + next_page: string | null; + url: string; +}; + +export type RevenueCatProductType = "subscription" | "one_time"; + +export type RevenueCatCreateProductBody = { + store_identifier: string; + app_id: string; + type: RevenueCatProductType; + display_name: string; + // Required by Test Store apps ("user-facing title"); harmless for store apps. + title?: string; + // ISO-8601 duration (e.g. "P1M", "P1Y"). Required when type is "subscription". + subscription?: { duration: string }; + one_time?: { is_consumable?: boolean }; +}; + +export type RevenueCatUpdateProductBody = { + display_name?: string; +}; + +// create_in_store uses an enum duration (NOT the ISO-8601 one createProduct uses). +export type RevenueCatStoreDuration = + | "ONE_WEEK" + | "ONE_MONTH" + | "TWO_MONTHS" + | "THREE_MONTHS" + | "SIX_MONTHS" + | "ONE_YEAR"; + +export type RevenueCatCreateInStoreBody = { + store_information?: { + duration: RevenueCatStoreDuration; + subscription_group_name: string; + subscription_group_id?: string; + }; +}; + +export type RevenueCatAppStoreType = + | "app_store" + | "mac_app_store" + | "play_store" + | "amazon" + | "roku" + | "stripe" + | "paddle" + | "rc_billing" + | "test_store"; + +export type RevenueCatApp = { + object: "app"; + id: string; + name: string; + type: RevenueCatAppStoreType; + project_id: string; + created_at: number; +}; + +export type RevenueCatAppsResponse = { + object: "list"; + items: RevenueCatApp[]; + next_page: string | null; + url: string; +}; + +export type RevenueCatProject = { + object: "project"; + id: string; + name: string; + created_at: number; +}; + +export type RevenueCatCreateProjectBody = { + name: string; +}; + +export type RevenueCatProjectsResponse = { + object: "list"; + items: RevenueCatProject[]; + next_page: string | null; + url: string; +}; diff --git a/server/src/external/revenueCat/revenuecatWebhookRouter.ts b/server/src/external/revenueCat/revenuecatWebhookRouter.ts index d7f050767..7cdb3d8dc 100644 --- a/server/src/external/revenueCat/revenuecatWebhookRouter.ts +++ b/server/src/external/revenueCat/revenuecatWebhookRouter.ts @@ -42,10 +42,12 @@ revenuecatWebhookRouter.post( try { const webhookSecret = getRevenuecatWebhookSecret({ org, env }); - if (Authorization !== webhookSecret) { + // Missing secret must fail closed — otherwise an unauthenticated + // request (no header) matches an unconfigured secret (both undefined). + if (!webhookSecret || Authorization !== webhookSecret) { logger.error("Invalid authorization for RevenueCat webhook", { - Authorization, - webhookSecret, + secretConfigured: Boolean(webhookSecret), + authorizationProvided: Boolean(Authorization), }); return c.json({ error: "Unauthorized" }, 401); } diff --git a/server/src/external/revenueCat/sync/revenuecatProductSyncUtils.ts b/server/src/external/revenueCat/sync/revenuecatProductSyncUtils.ts new file mode 100644 index 000000000..05a8cc143 --- /dev/null +++ b/server/src/external/revenueCat/sync/revenuecatProductSyncUtils.ts @@ -0,0 +1,114 @@ +import { + AppEnv, + BillingInterval, + type FullProduct, + type Organization, + isFixedPrice, + orgToCurrency, + type RevenueCatProcessorConfig, +} from "@autumn/shared"; +import type { RevenueCatStoreDuration } from "../revenuecatTypes.js"; + +/** Autumn's base (flat) price for a plan, as RevenueCat micros + currency. Null when free/usage-only. */ +export const getRcBasePrice = ({ + product, + org, +}: { + product: FullProduct; + org: Organization; +}): { amountMicros: number; currency: string } | null => { + const base = product.prices.find(isFixedPrice); + const amount = base?.config && "amount" in base.config ? base.config.amount : 0; + if (!amount || amount <= 0) return null; + return { + amountMicros: Math.round(amount * 1_000_000), + currency: orgToCurrency({ org }).toUpperCase(), + }; +}; + +/** Push is available once the org connected RevenueCat via OAuth for this env. */ +export const isRevenueCatPushEnabled = ({ + revenueCatConfig, + env, +}: { + revenueCatConfig: RevenueCatProcessorConfig; + env: AppEnv; +}): boolean => + env === AppEnv.Live + ? !!revenueCatConfig.oauth + : !!revenueCatConfig.sandbox_oauth; + +/** Version-stable, env-scoped store identifier Autumn mints for a pushed plan. */ +export const getRcStoreIdentifier = ({ + env, + orgId, + planId, +}: { + env: AppEnv; + orgId: string; + planId: string; +}): string => `autumn.${env}.${orgId}.${planId}`; + +/** Apple subscription group name for create_in_store. */ +export const getSubscriptionGroupName = (group?: string | null): string => + group && group.length > 0 ? `Autumn - ${group} Group` : "Autumn - Default Group"; + +/** ISO-8601 duration for createProduct. Null = RC can't represent it (lossy). */ +export const autumnIntervalToRcDuration = ({ + interval, + intervalCount, +}: { + interval: BillingInterval; + intervalCount: number; +}): string | null => { + const count = intervalCount || 1; + switch (interval) { + case BillingInterval.Week: + return count === 1 ? "P1W" : null; + case BillingInterval.Month: + if (count === 1) return "P1M"; + if (count === 2) return "P2M"; + if (count === 3) return "P3M"; + if (count === 6) return "P6M"; + if (count === 12) return "P1Y"; + return null; + case BillingInterval.Quarter: + return count === 1 ? "P3M" : null; + case BillingInterval.SemiAnnual: + return count === 1 ? "P6M" : null; + case BillingInterval.Year: + return count === 1 ? "P1Y" : null; + default: + return null; + } +}; + +/** Enum duration for create_in_store (different format than createProduct). */ +export const autumnIntervalToStoreDuration = ({ + interval, + intervalCount, +}: { + interval: BillingInterval; + intervalCount: number; +}): RevenueCatStoreDuration | null => { + const count = intervalCount || 1; + switch (interval) { + case BillingInterval.Week: + return count === 1 ? "ONE_WEEK" : null; + case BillingInterval.Month: + if (count === 1) return "ONE_MONTH"; + if (count === 2) return "TWO_MONTHS"; + if (count === 3) return "THREE_MONTHS"; + if (count === 6) return "SIX_MONTHS"; + if (count === 12) return "ONE_YEAR"; + return null; + case BillingInterval.Quarter: + return count === 1 ? "THREE_MONTHS" : null; + case BillingInterval.SemiAnnual: + return count === 1 ? "SIX_MONTHS" : null; + case BillingInterval.Year: + return count === 1 ? "ONE_YEAR" : null; + default: + return null; + } +}; diff --git a/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts b/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts new file mode 100644 index 000000000..eaafffae1 --- /dev/null +++ b/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts @@ -0,0 +1,333 @@ +import { + AppEnv, + ErrCode, + type FullProduct, + RecaseError, +} from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { + getBillingInterval, + pricesOnlyOneOff, +} from "@/internal/products/prices/priceUtils.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { + getRevenuecatAccessToken, + getRevenuecatProjectId, +} from "../misc/getRevenuecatAccessToken.js"; +import { initRevenuecatCli } from "../misc/initRevenuecatCli.js"; +import { RCMappingService } from "../misc/RCMappingService.js"; +import type { RevenueCatApp, RevenueCatProductType } from "../revenuecatTypes.js"; +import { + autumnIntervalToRcDuration, + autumnIntervalToStoreDuration, + getRcBasePrice, + getRcStoreIdentifier, + getSubscriptionGroupName, + isRevenueCatPushEnabled, +} from "./revenuecatProductSyncUtils.js"; + +type RcCli = ReturnType; + +type AppResult = { + app_id: string; + app_type: string; + product: "created" | "updated" | "exists"; + store_push?: "pushed" | "failed" | "skipped"; + price?: "set" | "skipped" | "failed"; + message?: string; +}; + +export type ProductSyncResult = { + plan_id: string; + status: "synced" | "skipped" | "error"; + store_identifier?: string; + apps?: AppResult[]; + message?: string; +}; + +/** + * Push a single Autumn product into RevenueCat across every app: create the RC + * product if missing (adopt on 409 via find), else patch its name; then (live only) + * push it into the store via create_in_store. The minted store id is unioned into the + * plan's revenuecat_mappings row — never replacing existing ids. + */ +export const syncProductToRevenueCat = async ({ + ctx, + rcCli, + apps, + isLive, + projectId, + product, +}: { + ctx: AutumnContext; + rcCli: RcCli; + apps: RevenueCatApp[]; + isLive: boolean; + projectId: string; + product: FullProduct; +}): Promise => { + const { db, org, env, logger } = ctx; + + if (product.prices.length === 0) { + return { + plan_id: product.id, + status: "skipped", + message: "Free plan (no price) — nothing to sell in the store", + }; + } + + let type: RevenueCatProductType; + let isoDuration: string | null = null; + let storeDuration = null as ReturnType; + + if (pricesOnlyOneOff(product.prices)) { + type = "one_time"; + } else { + type = "subscription"; + const { interval, intervalCount } = getBillingInterval(product.prices); + isoDuration = autumnIntervalToRcDuration({ interval, intervalCount }); + storeDuration = autumnIntervalToStoreDuration({ interval, intervalCount }); + if (!isoDuration) { + return { + plan_id: product.id, + status: "skipped", + message: `Unsupported billing interval (${interval} x${intervalCount}) for RevenueCat`, + }; + } + } + + const storeIdentifier = getRcStoreIdentifier({ + env, + orgId: org.id, + planId: product.id, + }); + const displayName = product.name || product.id; + const appResults: AppResult[] = []; + let syncedAnyApp = false; + + for (const app of apps) { + try { + let rcProductId: string; + let productAction: AppResult["product"]; + + // RC only accepts subscription params on create for the simulated test store; + // real store apps get a bare product, and duration is set via create_in_store. + const isTestStore = app.type === "test_store"; + + const existing = await rcCli.findProductByStoreIdentifier({ + appId: app.id, + storeIdentifier, + }); + + if (existing) { + rcProductId = existing.id; + if (existing.display_name !== displayName) { + await rcCli.updateProduct(existing.id, { display_name: displayName }); + productAction = "updated"; + } else { + productAction = "exists"; + } + } else { + const created = await rcCli.createProduct({ + app_id: app.id, + store_identifier: storeIdentifier, + type, + display_name: displayName, + title: displayName, + ...(type === "subscription" && isTestStore + ? { subscription: { duration: isoDuration as string } } + : {}), + ...(type === "one_time" ? { one_time: {} } : {}), + }); + rcProductId = created.id; + productAction = "created"; + } + + const appResult: AppResult = { + app_id: app.id, + app_type: app.type, + product: productAction, + }; + + // Test-store products are already usable; only push real store apps (live). + if (isLive && !isTestStore) { + try { + await rcCli.createInStore( + rcProductId, + type === "subscription" && storeDuration + ? { + store_information: { + duration: storeDuration, + subscription_group_name: getSubscriptionGroupName( + product.group, + ), + }, + } + : undefined, + ); + appResult.store_push = "pushed"; + } catch (storeError) { + appResult.store_push = "failed"; + appResult.message = `${storeError}. Check the app's store credentials at https://app.revenuecat.com/projects/${projectId}/apps/${app.id}`; + logger.warn( + `[RC sync] create_in_store failed for ${product.id} / app ${app.id}: ${storeError}`, + ); + } + } else { + appResult.store_push = "skipped"; + } + + // Real-store prices come from Apple/Google. Only the test store needs an + // explicit price, set via RC's MCP server (no REST endpoint for it). + if (isTestStore) { + const basePrice = getRcBasePrice({ product, org }); + if (basePrice) { + try { + await rcCli.setTestStoreProductPrice(rcProductId, basePrice); + appResult.price = "set"; + } catch (priceError) { + appResult.price = "failed"; + appResult.message = `Price not set: ${priceError}`; + logger.warn( + `[RC sync] set test-store price failed for ${product.id} / app ${app.id}: ${priceError}`, + ); + } + } else { + appResult.price = "skipped"; + } + } + + syncedAnyApp = true; + appResults.push(appResult); + } catch (error) { + appResults.push({ + app_id: app.id, + app_type: app.type, + product: "exists", + store_push: "failed", + message: `${error}`, + }); + logger.error( + `[RC sync] Failed to sync ${product.id} for app ${app.id}: ${error}`, + { error }, + ); + } + } + + // Every app failed: don't persist a mapping or report "synced", else the plan + // is marked connected to an RC product that was never created. + if (!syncedAnyApp) { + return { + plan_id: product.id, + status: "error", + store_identifier: storeIdentifier, + apps: appResults, + message: "RevenueCat product sync failed for every app", + }; + } + + // Union the minted id into the mapping — never clobber existing manual ids. + const existingRows = await RCMappingService.get({ + db, + orgId: org.id, + env, + autumnProductId: product.id, + }); + const currentIds = existingRows[0]?.revenuecat_product_ids ?? []; + const revenuecat_product_ids = currentIds.includes(storeIdentifier) + ? currentIds + : [...currentIds, storeIdentifier]; + + await RCMappingService.upsert({ + db, + data: { + org_id: org.id, + env, + autumn_product_id: product.id, + revenuecat_product_ids, + }, + }); + + return { + plan_id: product.id, + status: "synced", + store_identifier: storeIdentifier, + apps: appResults, + }; +}; + +/** + * On-demand push of selected Autumn plans into RevenueCat. Throws only if + * RevenueCat isn't connected / has no apps; per-product issues are collected. + */ +export const syncProductsToRevenueCat = async ({ + ctx, + productIds, +}: { + ctx: AutumnContext; + productIds: string[]; +}): Promise => { + const { db, org, env } = ctx; + + const revenueCatConfig = org.processor_configs?.revenuecat; + if (!revenueCatConfig || !isRevenueCatPushEnabled({ revenueCatConfig, env })) { + throw new RecaseError({ + message: "Connect RevenueCat via OAuth for this environment before syncing", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + const projectId = getRevenuecatProjectId({ revenueCatConfig, env }); + const accessToken = await getRevenuecatAccessToken({ db, org, env }); + if (!projectId || !accessToken) { + throw new RecaseError({ + message: "RevenueCat is not fully configured (missing project or token)", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + const rcCli = initRevenuecatCli({ projectId, accessToken }); + const apps = await rcCli.listApps(); + if (apps.length === 0) { + throw new RecaseError({ + message: + "No apps configured in this RevenueCat project. Add one in the RevenueCat dashboard first.", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + const isLive = env === AppEnv.Live; + const results: ProductSyncResult[] = []; + + for (const planId of productIds) { + const product = await ProductService.getFull({ + db, + idOrInternalId: planId, + orgId: org.id, + env, + allowNotFound: true, + }); + + if (!product) { + results.push({ plan_id: planId, status: "error", message: "Plan not found" }); + continue; + } + + results.push( + await syncProductToRevenueCat({ + ctx, + rcCli, + apps, + isLive, + projectId, + product, + }), + ); + } + + return results; +}; diff --git a/server/src/external/revenueCat/utils/recordRevenueCatInvoice.ts b/server/src/external/revenueCat/utils/recordRevenueCatInvoice.ts index d1b33039d..e8c0f92a5 100644 --- a/server/src/external/revenueCat/utils/recordRevenueCatInvoice.ts +++ b/server/src/external/revenueCat/utils/recordRevenueCatInvoice.ts @@ -13,8 +13,10 @@ import { generateId } from "@/utils/genUtils"; type RecordableEvent = { transaction_id?: string | null; original_transaction_id?: string | null; + // RevenueCat's `price` is always normalized to USD; `currency` describes + // `price_in_purchased_currency`, NOT `price`. We record `price`, so the + // invoice currency is always USD. price?: number | null; - currency?: string | null; purchased_at_ms?: number | null; event_timestamp_ms?: number | null; }; @@ -51,7 +53,9 @@ export const recordRevenueCatInvoice = async ({ } const total = event.price ?? 0; - const currency = event.currency ?? "usd"; + // `event.price` is normalized to USD by RevenueCat regardless of the + // purchase currency, so the recorded invoice is always denominated in USD. + const currency = "usd"; const createdAt = event.purchased_at_ms ?? event.event_timestamp_ms ?? Date.now(); diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts index 9d98157a9..2d0fe4374 100644 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts @@ -3,22 +3,14 @@ import { ACTIVE_STATUSES, AttachScenario, CusProductStatus, - cusProductToPrices, - ProcessorType, } from "@shared/index"; -import { createStripeCli } from "@/external/connect/createStripeCli"; +import { provisionRevenueCatCusProduct } from "@/external/revenueCat/misc/provisionRevenueCatCusProduct"; import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources"; import { recordRevenueCatInvoice } from "@/external/revenueCat/utils/recordRevenueCatInvoice"; import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated"; -import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { customerProductActions } from "@/internal/customers/cusProducts/actions"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts"; -import { - attachToInsertParams, - isProductUpgrade, -} from "@/internal/products/productUtils"; -import { isMainProduct } from "@/internal/products/productUtils/classifyProduct"; export const handleRenewal = async ({ event, @@ -27,7 +19,7 @@ export const handleRenewal = async ({ event: WebhookRenewal; ctx: RevenueCatWebhookContext; }) => { - const { db, org, env, logger, features } = ctx; + const { org, env, logger } = ctx; const { product_id, app_user_id } = event; const { @@ -41,20 +33,18 @@ export const handleRenewal = async ({ customerId: app_user_id, }); - const { curSameProduct, curMainProduct } = getExistingCusProducts({ + const { curSameProduct } = getExistingCusProducts({ product, cusProducts, }); - const now = Date.now(); - - // If same product exists and is active, this is just a renewal - send webhook only + // Same active product: pure side-effect (webhook + invoice record). No DB + // mutation on the cusProduct; the cycle anchor is owned by the app store. if (curSameProduct && ACTIVE_STATUSES.includes(curSameProduct.status)) { logger.info( `Renewal for existing active product ${product.id}, sending webhook`, ); - // Send webhook for simple renewal (no state change) await addProductsUpdatedWebhookTask({ ctx: customerCtx, internalCustomerId: curSameProduct.internal_customer_id, @@ -73,30 +63,19 @@ export const handleRenewal = async ({ }); return { success: true }; - } else if ( - curSameProduct && - curSameProduct.status === CusProductStatus.PastDue - ) { + } + + // Past-due → active recovery. + if (curSameProduct && curSameProduct.status === CusProductStatus.PastDue) { logger.info( `Renewal for existing past due product ${product.id}, marking as active`, ); - await CusProductService.update({ - ctx: customerCtx, - cusProductId: curSameProduct.id, - updates: { - status: CusProductStatus.Active, - }, - }); - // Send webhook for past_due → active recovery - await addProductsUpdatedWebhookTask({ + await customerProductActions.markActive({ ctx: customerCtx, - internalCustomerId: curSameProduct.internal_customer_id, - org, - env, - customerId: customer.id || "", - scenario: AttachScenario.Renew, - cusProduct: curSameProduct, + customerProduct: curSameProduct, + fullCustomer: customer, + sendWebhook: true, }); logger.info(`Marked past due product as active: ${curSameProduct.id}`); @@ -111,69 +90,12 @@ export const handleRenewal = async ({ return { success: true }; } - // Check if this is an upgrade (renewing to a different/better product) - const isNewProductMain = isMainProduct({ product, prices: product.prices }); - let scenario = AttachScenario.New; - - if (curMainProduct && isNewProductMain) { - const curPrices = cusProductToPrices({ cusProduct: curMainProduct }); - const newPrices = product.prices; - - const isUpgrade = isProductUpgrade({ - prices1: curPrices, - prices2: newPrices, - }); - - scenario = isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade; - - logger.info( - `Renewal with ${isUpgrade ? "upgrade" : "downgrade"}: ${curMainProduct.product.id} -> ${product.id}`, - ); - - // Expire old cus_product - await CusProductService.update({ + // Reactivate same product (expired/canceled → active). + if (curSameProduct) { + await customerProductActions.uncancel({ ctx: customerCtx, - cusProductId: curMainProduct.id, - updates: { - status: CusProductStatus.Expired, - ended_at: now, - }, - }); - - // Send webhook for the expired product - await addProductsUpdatedWebhookTask({ - ctx: customerCtx, - internalCustomerId: curMainProduct.internal_customer_id, - org, - env, - customerId: customer.id || "", - scenario: AttachScenario.Expired, - cusProduct: curMainProduct, - }); - - logger.info(`Expired old cus_product: ${curMainProduct.id}`); - } else if (curSameProduct) { - // Reactivate the same product if it was expired/cancelled - await CusProductService.update({ - ctx: customerCtx, - cusProductId: curSameProduct.id, - updates: { - status: CusProductStatus.Active, - canceled_at: null, - ended_at: null, - canceled: false, - }, - }); - - // Send webhook for reactivation - await addProductsUpdatedWebhookTask({ - ctx: customerCtx, - internalCustomerId: curSameProduct.internal_customer_id, - org, - env, - customerId: customer.id || "", - scenario: AttachScenario.Renew, - cusProduct: curSameProduct, + customerProduct: curSameProduct, + fullCustomer: customer, }); logger.info(`Reactivated cus_product: ${curSameProduct.id}`); @@ -188,37 +110,15 @@ export const handleRenewal = async ({ return { success: true }; } - // Create new cus_product for upgrade or new product - await createFullCusProduct({ - db, - logger, - scenario, - processorType: ProcessorType.RevenueCat, - attachParams: attachToInsertParams( - { - customer, - products: [product], - prices: product.prices, - entitlements: product.entitlements, - entities: customer.entities || [], - org, - stripeCli: createStripeCli({ org, env }), - now, - paymentMethod: null, - freeTrial: null, - optionsList: [], - cusProducts, - replaceables: [], - features, - }, - product, - ), - sendWebhook: true, + // Different product (upgrade or downgrade). V2 attach handles expiring the + // outgoing cusProduct via computeAttachPlan's transition logic. + await provisionRevenueCatCusProduct({ + ctx: customerCtx, + customer, + product, }); - logger.info( - `Created cus_product for ${product.id} with scenario: ${scenario} (renewal)`, - ); + logger.info(`Created RC cus_product for ${product.id} (renewal transition)`); await recordRevenueCatInvoice({ ctx: customerCtx, event, customer, product }); diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts index 06d3e25e1..e56d9329b 100644 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts @@ -1,24 +1,10 @@ import type { WebhookInitialPurchase } from "@puzzmo/revenue-cat-webhook-types"; -import { - AttachScenario, - CusProductStatus, - cusProductToPrices, - ErrCode, - ProcessorType, - RecaseError, -} from "@shared/index"; -import { createStripeCli } from "@/external/connect/createStripeCli"; +import { ErrCode, RecaseError } from "@shared/index"; +import { provisionRevenueCatCusProduct } from "@/external/revenueCat/misc/provisionRevenueCatCusProduct"; import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources"; import { recordRevenueCatInvoice } from "@/external/revenueCat/utils/recordRevenueCatInvoice"; import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext"; -import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts"; -import { - attachToInsertParams, - isProductUpgrade, -} from "@/internal/products/productUtils"; -import { isMainProduct } from "@/internal/products/productUtils/classifyProduct"; export const handleInitialPurchase = async ({ event, @@ -27,7 +13,7 @@ export const handleInitialPurchase = async ({ event: WebhookInitialPurchase; ctx: RevenueCatWebhookContext; }) => { - const { db, org, env, logger, features } = ctx; + const { logger } = ctx; const { product_id, app_user_id } = event; const { @@ -42,12 +28,13 @@ export const handleInitialPurchase = async ({ autoCreateCustomer: true, }); - const { curSameProduct, curMainProduct } = getExistingCusProducts({ + const { curSameProduct } = getExistingCusProducts({ product, cusProducts, }); - // If same product already exists, skip + // Guard the same-product attach explicitly so RC consumers get the canonical + // CustomerAlreadyHasProduct error rather than V2 attach's PlanAlreadyAttached. if (curSameProduct) { throw new RecaseError({ message: `[handleInitialPurchase] Customer ${customer.id} already has product ${product.id}`, @@ -56,71 +43,13 @@ export const handleInitialPurchase = async ({ }); } - const now = Date.now(); - let scenario = AttachScenario.New; - - // Handle upgrade/downgrade (only when both are main products) - const isNewProductMain = isMainProduct({ product, prices: product.prices }); - - if (curMainProduct && isNewProductMain) { - const curPrices = cusProductToPrices({ cusProduct: curMainProduct }); - const newPrices = product.prices; - - const isUpgrade = isProductUpgrade({ - prices1: curPrices, - prices2: newPrices, - }); - - scenario = isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade; - - logger.info( - `${isUpgrade ? "Upgrade" : "Downgrade"} detected: ${curMainProduct.product.id} -> ${product.id}`, - ); - - // Expire old cus_product - await CusProductService.update({ - ctx: customerCtx, - cusProductId: curMainProduct.id, - updates: { - status: CusProductStatus.Expired, - ended_at: now, - }, - }); - - logger.info(`Expired old cus_product: ${curMainProduct.id}`); - } - - // Create new cus_product - await createFullCusProduct({ - db, - logger, - scenario, - processorType: ProcessorType.RevenueCat, - attachParams: attachToInsertParams( - { - customer, - products: [product], - prices: product.prices, - entitlements: product.entitlements, - entities: customer.entities || [], - org, - stripeCli: createStripeCli({ org, env }), - now, - paymentMethod: null, - freeTrial: null, - optionsList: [], - cusProducts, - replaceables: [], - features, - }, - product, - ), - sendWebhook: true, + await provisionRevenueCatCusProduct({ + ctx: customerCtx, + customer, + product, }); - logger.info( - `Created cus_product for ${product.id} with scenario: ${scenario}`, - ); + logger.info(`Created RC cus_product for ${product.id} (initial purchase)`); await recordRevenueCatInvoice({ ctx: customerCtx, event, customer, product }); }; diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts index 1b08a5127..646f34b25 100644 --- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts +++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts @@ -1,16 +1,9 @@ import type { WebhookNonRenewingPurchase } from "@puzzmo/revenue-cat-webhook-types"; -import { - AttachScenario, - ErrCode, - ProcessorType, - RecaseError, -} from "@shared/index"; -import { createStripeCli } from "@/external/connect/createStripeCli"; +import { ErrCode, RecaseError } from "@shared/index"; +import { provisionRevenueCatCusProduct } from "@/external/revenueCat/misc/provisionRevenueCatCusProduct"; import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources"; import { recordRevenueCatInvoice } from "@/external/revenueCat/utils/recordRevenueCatInvoice"; import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext"; -import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct"; -import { attachToInsertParams } from "@/internal/products/productUtils"; import { oneOffOrAddOn } from "@/internal/products/productUtils/classifyProduct"; export const handleNonRenewingPurchase = async ({ @@ -20,13 +13,12 @@ export const handleNonRenewingPurchase = async ({ event: WebhookNonRenewingPurchase; ctx: RevenueCatWebhookContext; }) => { - const { db, org, env, logger, features } = ctx; + const { logger } = ctx; const { ctx: customerCtx, product, customer, - cusProducts, } = await resolveRevenuecatResources({ ctx, revenuecatProductId: event.product_id, @@ -41,40 +33,13 @@ export const handleNonRenewingPurchase = async ({ }); } - const now = Date.now(); - const scenario = AttachScenario.New; - - // Create new cus_product - await createFullCusProduct({ - db, - logger, - scenario, - processorType: ProcessorType.RevenueCat, - attachParams: attachToInsertParams( - { - customer, - products: [product], - prices: product.prices, - entitlements: product.entitlements, - entities: customer.entities || [], - org, - stripeCli: createStripeCli({ org, env }), - now, - paymentMethod: null, - freeTrial: null, - optionsList: [], - cusProducts, - replaceables: [], - features, - }, - product, - ), - sendWebhook: true, + await provisionRevenueCatCusProduct({ + ctx: customerCtx, + customer, + product, }); - logger.info( - `Created cus_product for ${product.id} with scenario: ${scenario}`, - ); + logger.info(`Created RC cus_product for ${product.id} (non-renewing purchase)`); await recordRevenueCatInvoice({ ctx: customerCtx, event, customer, product }); }; diff --git a/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts b/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts index 718be7204..d269f8eb9 100644 --- a/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts +++ b/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts @@ -156,3 +156,15 @@ export const stripeSubscriptionToScheduleId = ({ ? stripeSubscription.schedule : (stripeSubscription.schedule?.id ?? undefined); }; + +export const stripeSubscriptionToApplication = ({ + stripeSubscription, +}: { + stripeSubscription?: Stripe.Subscription; +}): string | null => { + if (!stripeSubscription?.application) return null; + + return typeof stripeSubscription.application === "string" + ? stripeSubscription.application + : stripeSubscription.application.id; +}; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts index 8565fc0cd..d909817f4 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts @@ -79,7 +79,7 @@ export const handlePrepaidPrices = async ({ const rolloverUpdate = getRolloverUpdates({ cusEnt, - nextResetAt: end * 1000, + nextResetAt: start * 1000, }); if (notNullish(options?.upcoming_quantity)) { diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts index c2c46a484..da2a9c936 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -149,7 +149,7 @@ export const handleUsagePrices = async ({ allowance: ent.interval === EntInterval.Lifetime ? 0 : ent.allowance!, }); - const { end } = subToPeriodStartEnd({ sub: usageSub }); + const { start, end } = subToPeriodStartEnd({ sub: usageSub }); await CusEntService.update({ ctx, id: relatedCusEnt.id, @@ -162,7 +162,7 @@ export const handleUsagePrices = async ({ const rolloverUpdate = getRolloverUpdates({ cusEnt: relatedCusEnt, - nextResetAt: end * 1000, + nextResetAt: start * 1000, }); if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts index 0356f4712..b72217626 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts @@ -3,12 +3,14 @@ import { MetadataType, } from "@autumn/shared"; import type { CheckoutSessionCompletedContext } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext"; +import { createStripeScheduleFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/createStripeScheduleFromCheckout"; import { modifyStripeSubscriptionFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/modifyStripeSubscriptionFromCheckout"; import { syncSubscriptionItemMetadataFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout"; import { updateBillingPlanFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateBillingPlanFromCheckout"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; import { persistDeferredCreateSchedule } from "@/internal/billing/v2/actions/createSchedule/utils/persistDeferredCreateSchedule"; import { checkoutSessionLock } from "@/internal/billing/v2/actions/locks/checkoutSessionLock/checkoutSessionLock"; +import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan"; import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook"; @@ -54,6 +56,20 @@ export const handleCheckoutSessionMetadataV2 = async ({ deferredData: updatedDeferredData, }); + const stripeScheduleId = await createStripeScheduleFromCheckout({ + ctx, + checkoutContext, + deferredData: updatedDeferredData, + }); + + if (stripeScheduleId) { + addStripeSubscriptionScheduleIdToBillingPlan({ + autumnBillingPlan: updatedDeferredData.billingPlan.autumn, + stripeBillingPlan: updatedDeferredData.billingPlan.stripe, + stripeSubscriptionScheduleId: stripeScheduleId, + }); + } + addToExtraLogs({ ctx, extras: { diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts index 545b95061..8ec5d6ae3 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts @@ -36,13 +36,17 @@ export const handleStripeInvoiceCreated = async ({ await processPrepaidPricesForInvoiceCreated({ ctx, eventContext }); await processAllocatedPricesForInvoiceCreated({ ctx, eventContext }); + const shouldStoreScheduleProrationInvoice = + eventContext.stripeInvoice.billing_reason === "subscription_update" && + !!eventContext.stripeSubscription.schedule; + // Upsert Autumn invoice record const autumnInvoice = await upsertAutumnInvoice({ ctx, stripeInvoice: eventContext.stripeInvoice, stripeSubscription: eventContext.stripeSubscription, customerProducts: eventContext.customerProducts, - options: { skipNonCycleInvoices: true }, + options: { skipNonCycleInvoices: !shouldStoreScheduleProrationInvoice }, }); // Store invoice line items (async via SQS workflow) diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts index 1005cecbb..16f5abfde 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts @@ -11,6 +11,7 @@ import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService"; import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; import { parseSkipOverageSubmissionFlag } from "@/internal/misc/featureFlags/parseSkipOverageSubmission"; import type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext"; import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext"; @@ -84,7 +85,6 @@ export const processConsumablePricesForInvoiceCreated = async ({ invoicePeriodEndMs, }), }); - const skipOverageSubmission = parseSkipOverageSubmissionFlag({ org: ctx.org, customerId: eventContext.fullCustomer.id, @@ -105,11 +105,18 @@ export const processConsumablePricesForInvoiceCreated = async ({ data: updateCustomerEntitlements, }); + await deleteCachedFullCustomer({ + ctx, + customerId: + eventContext.fullCustomer.id ?? eventContext.fullCustomer.internal_id, + source: "invoice-created-consumable-reset", + }); + // Handle rollovers updateCustomerEntitlements.forEach(async (update) => { const rolloverUpdates = getRolloverUpdates({ cusEnt: update.customerEntitlement, - nextResetAt: Date.now(), + nextResetAt: invoicePeriodEndMs, }); const fullCusEnt: FullCusEntWithProduct = { diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated.ts index 5384211f5..5e2db5331 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated.ts @@ -41,8 +41,7 @@ const processPrepaidPrice = async ({ const customerProduct = customerEntitlement.customer_product; - const { stripeSubscription, fullCustomer } = eventContext; - const { db } = ctx; + const { stripeSubscription } = eventContext; if (!options) return; const previousQuantity = options?.quantity ?? 0; @@ -60,11 +59,11 @@ const processPrepaidPrice = async ({ const ent = customerEntitlement.entitlement; - const { end } = subToPeriodStartEnd({ sub: stripeSubscription }); + const { start, end } = subToPeriodStartEnd({ sub: stripeSubscription }); const rolloverUpdate = getRolloverUpdates({ cusEnt: customerEntitlement, - nextResetAt: end * 1000, + nextResetAt: start * 1000, }); if (notNullish(options?.upcoming_quantity) && customerProduct) { diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/processConsumablePricesForSubscriptionDeleted.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/processConsumablePricesForSubscriptionDeleted.ts index bc8e4942b..d45cdf79e 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/processConsumablePricesForSubscriptionDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/processConsumablePricesForSubscriptionDeleted.ts @@ -9,6 +9,7 @@ import { lineItemsToInvoiceAddLinesParams } from "@/internal/billing/v2/provider import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling"; import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; import { parseSkipOverageSubmissionFlag } from "@/internal/misc/featureFlags/parseSkipOverageSubmission"; import type { StripeSubscriptionDeletedContext } from "../setupStripeSubscriptionDeletedContext"; @@ -117,4 +118,11 @@ export const processConsumablePricesForSubscriptionDeleted = async ({ ctx, data: updateCustomerEntitlements, }); + + await deleteCachedFullCustomer({ + ctx, + customerId: + eventContext.fullCustomer.id ?? eventContext.fullCustomer.internal_id, + source: "subscription-deleted-consumable-reset", + }); }; diff --git a/server/src/external/tinybird/initClickhouse.ts b/server/src/external/tinybird/initClickhouse.ts index 5afb4f595..6e944fdc0 100644 --- a/server/src/external/tinybird/initClickhouse.ts +++ b/server/src/external/tinybird/initClickhouse.ts @@ -1,12 +1,8 @@ import { type ClickHouseClient, createClient } from "@clickhouse/client"; -// ClickHouse URL is different from API URL -// API: https://api.europe-west2.gcp.tinybird.co -// ClickHouse: https://europe-west2.gcp.clickhouse.tinybird.co -const TINYBIRD_CLICKHOUSE_URL = process.env.TINYBIRD_CLICKHOUSE_URL; -const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN; +const TINYBIRD_CLICKHOUSE_URL = process.env.TINYBIRD_US_EAST_CLICKHOUSE_URL; +const TINYBIRD_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; -// Debug logging if (TINYBIRD_CLICKHOUSE_URL && TINYBIRD_TOKEN) { console.log( `[Tinybird ClickHouse] Configured with URL: ${TINYBIRD_CLICKHOUSE_URL}`, diff --git a/server/src/external/tinybird/initTinybirdV2.ts b/server/src/external/tinybird/initTinybirdV2.ts index e2984697d..f50dd5b51 100644 --- a/server/src/external/tinybird/initTinybirdV2.ts +++ b/server/src/external/tinybird/initTinybirdV2.ts @@ -1,19 +1,22 @@ import { createTinybirdApi } from "@tinybirdco/sdk"; -const TINYBIRD_US_EAST_API_URL = process.env.TINYBIRD_US_EAST_API_URL; -const TINYBIRD_US_EAST_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; +const TINYBIRD_SECONDARY_API_URL = process.env.TINYBIRD_API_URL; +const TINYBIRD_SECONDARY_TOKEN = process.env.TINYBIRD_TOKEN; -/** Secondary Tinybird API client (us-east region) for dual-write during migration. */ -export const tinybirdUsEastApi = - TINYBIRD_US_EAST_API_URL && TINYBIRD_US_EAST_TOKEN +/** Secondary Tinybird API client for dual-write safety net during region cutover. + * Reads from the legacy TINYBIRD_API_URL / TINYBIRD_TOKEN env vars (europe-west2 + * GCP). Once us-east is stable, delete this file + the dual-write logic in + * sendEvents.ts. */ +export const tinybirdSecondaryApi = + TINYBIRD_SECONDARY_API_URL && TINYBIRD_SECONDARY_TOKEN ? createTinybirdApi({ - baseUrl: TINYBIRD_US_EAST_API_URL, - token: TINYBIRD_US_EAST_TOKEN, + baseUrl: TINYBIRD_SECONDARY_API_URL, + token: TINYBIRD_SECONDARY_TOKEN, }) : null; -if (tinybirdUsEastApi) { +if (tinybirdSecondaryApi) { console.log( - `[Tinybird] us-east dual-write configured with URL: ${TINYBIRD_US_EAST_API_URL}`, + `[Tinybird] secondary dual-write configured with URL: ${TINYBIRD_SECONDARY_API_URL}`, ); } diff --git a/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts b/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts index 4bd7f9c3c..f92235955 100644 --- a/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts +++ b/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts @@ -74,6 +74,7 @@ export const listMigrationItemEventsEndpoint = defineEndpoint( env: p.string(), migration_internal_id: p.string(), migration_run_id: p.string().optional(""), + item_ids: p.array(p.string()).optional(), limit: p.int32().optional(1000), }, nodes: [ @@ -99,6 +100,9 @@ export const listMigrationItemEventsEndpoint = defineEndpoint( {% if defined(migration_run_id) and String(migration_run_id, '') != '' %} AND migration_run_id = {{String(migration_run_id)}} {% end %} + {% if defined(item_ids) and length(item_ids) > 0 %} + AND item_id IN {{Array(item_ids, 'String')}} + {% end %} ORDER BY timestamp DESC, item_kind ASC, item_id ASC LIMIT {{Int32(limit, 1000)}} `, diff --git a/server/src/external/tinybird/sendEvents/sendEvents.ts b/server/src/external/tinybird/sendEvents/sendEvents.ts index a30a31137..60a32f93c 100644 --- a/server/src/external/tinybird/sendEvents/sendEvents.ts +++ b/server/src/external/tinybird/sendEvents/sendEvents.ts @@ -3,7 +3,7 @@ import type { EventInsert } from "@autumn/shared"; import * as Sentry from "@sentry/bun"; import type { Logger } from "@/external/logtail/logtailUtils.js"; import { tinybirdIngest } from "../initTinybird.js"; -import { tinybirdUsEastApi } from "../initTinybirdV2.js"; +import { tinybirdSecondaryApi } from "../initTinybirdV2.js"; import { isTinybirdConfigured } from "../tinybirdUtils.js"; import { mapToTinybirdEvent } from "./mapEvent.js"; @@ -35,7 +35,7 @@ export const sendEventsToTinybird = async ({ const tinybirdEvents = events.map(mapToTinybirdEvent); - const reportFailure = (error: unknown, region: "primary" | "us-east") => { + const reportFailure = (error: unknown, region: "primary" | "secondary") => { const errorId = generateErrorId(); const errorMessage = error instanceof Error ? error.message : String(error); @@ -81,24 +81,21 @@ export const sendEventsToTinybird = async ({ }) .catch((error: unknown) => reportFailure(error, "primary")); - const usEastWrite = tinybirdUsEastApi - ? tinybirdUsEastApi + const secondaryWrite = tinybirdSecondaryApi + ? tinybirdSecondaryApi .ingestBatch("events", tinybirdEvents) .then((result) => { - logger?.info( - `Sent ${events.length} events to Tinybird (us-east)`, - { - data: { - region: "us-east", - eventCount: events.length, - successfulRows: result?.successful_rows, - quarantinedRows: result?.quarantined_rows, - }, + logger?.info(`Sent ${events.length} events to Tinybird (secondary)`, { + data: { + region: "secondary", + eventCount: events.length, + successfulRows: result?.successful_rows, + quarantinedRows: result?.quarantined_rows, }, - ); + }); }) - .catch((error: unknown) => reportFailure(error, "us-east")) + .catch((error: unknown) => reportFailure(error, "secondary")) : Promise.resolve(); - await Promise.all([primaryWrite, usEastWrite]); + await Promise.all([primaryWrite, secondaryWrite]); }; diff --git a/server/src/external/tinybird/tinybirdUtils.ts b/server/src/external/tinybird/tinybirdUtils.ts index b5d10c720..1af3e38e2 100644 --- a/server/src/external/tinybird/tinybirdUtils.ts +++ b/server/src/external/tinybird/tinybirdUtils.ts @@ -1,8 +1,8 @@ import { ErrCode, RecaseError } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL; -const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN; +const TINYBIRD_API_URL = process.env.TINYBIRD_US_EAST_API_URL; +const TINYBIRD_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; export type TinybirdConfig = { baseUrl: string; diff --git a/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts b/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts new file mode 100644 index 000000000..d0951fe23 --- /dev/null +++ b/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts @@ -0,0 +1,80 @@ +import { + AppEnv, + AuthType, + ErrCode, + RecaseError, + sortFeatures, +} from "@autumn/shared"; +import type { Context, Next } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { getOAuthAccessTokenRecord } from "@/internal/auth/oauth/oauthAccessTokenApiKey.js"; +import { oauthConsentRepo } from "@/internal/auth/repos/index.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; + +const getOAuthEnvironment = ({ c }: { c: Context }) => { + const env = c.req.header("x-autumn-environment") ?? AppEnv.Sandbox; + if (env === AppEnv.Live || env === AppEnv.Sandbox) return env; + + throw new RecaseError({ + message: "Invalid x-autumn-environment", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); +}; + +export const handleOAuthMiddleware = async ({ + c, + token, + next, +}: { + c: Context; + token: string; + next: Next; +}) => { + const ctx = c.get("ctx"); + const env = getOAuthEnvironment({ c }); + const tokenRecord = await getOAuthAccessTokenRecord({ + db: ctx.db, + accessToken: token, + resource: c.req.header("x-autumn-oauth-resource") ?? null, + requestedScopes: null, + }); + const consent = await oauthConsentRepo.getForClientUserOrg({ + db: ctx.db, + clientId: tokenRecord.clientId, + userId: tokenRecord.userId, + referenceId: tokenRecord.referenceId, + env, + }); + + if (!consent) { + throw new RecaseError({ + message: "OAuth consent not found for environment", + code: ErrCode.InvalidRequest, + statusCode: 401, + }); + } + + const data = await OrgService.getWithFeatures({ + db: ctx.db, + orgId: tokenRecord.referenceId, + env, + }); + if (!data) { + throw new RecaseError({ + message: "Org not found", + code: ErrCode.OrgNotFound, + statusCode: 404, + }); + } + + ctx.org = data.org; + ctx.features = sortFeatures({ features: data.features }) ?? []; + ctx.env = env; + ctx.userId = tokenRecord.userId; + ctx.oauthResource = c.req.header("x-autumn-oauth-resource") ?? undefined; + ctx.authType = AuthType.SecretKey; + ctx.scopes = tokenRecord.scopes; + + await next(); +}; diff --git a/server/src/honoMiddlewares/idempotencyMiddleware.ts b/server/src/honoMiddlewares/idempotencyMiddleware.ts index 512a8ff5e..db367d6b9 100644 --- a/server/src/honoMiddlewares/idempotencyMiddleware.ts +++ b/server/src/honoMiddlewares/idempotencyMiddleware.ts @@ -1,10 +1,30 @@ +import { ErrCode } from "@autumn/shared"; import type { Context, Next } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { checkIdempotencyKey } from "@/internal/misc/idempotency/checkIdempotencyKey.js"; +import { + checkIdempotencyKey, + releaseIdempotencyKey, +} from "@/internal/misc/idempotency/checkIdempotencyKey.js"; + +const shouldReleaseStatus = (status: number) => status >= 400 && status !== 409; + +const shouldReleaseError = (error: unknown) => { + const statusCode = + typeof error === "object" && error !== null && "statusCode" in error + ? Number(error.statusCode) + : null; + const code = + typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : null; + + return ( + statusCode !== null && + shouldReleaseStatus(statusCode) && + code !== ErrCode.DuplicateIdempotencyKey + ); +}; -/** - * Middleware that checks for idempotence in a request - */ export const idempotencyMiddleware = async ( c: Context, next: Next, @@ -23,5 +43,25 @@ export const idempotencyMiddleware = async ( }); } - await next(); + try { + await next(); + } catch (error) { + if (idempotencyKey && shouldReleaseError(error)) { + await releaseIdempotencyKey({ + orgId: ctx.org.id, + env: ctx.env, + idempotencyKey, + }); + } + + throw error; + } + + if (idempotencyKey && shouldReleaseStatus(c.res.status)) { + await releaseIdempotencyKey({ + orgId: ctx.org.id, + env: ctx.env, + idempotencyKey, + }); + } }; diff --git a/server/src/honoMiddlewares/routerRateLimiter/index.ts b/server/src/honoMiddlewares/routerRateLimiter/index.ts new file mode 100644 index 000000000..82e36b050 --- /dev/null +++ b/server/src/honoMiddlewares/routerRateLimiter/index.ts @@ -0,0 +1,45 @@ +import type { Context, Next } from "hono"; +import { rateLimiter } from "hono-rate-limiter"; +import { logger } from "@/external/logtail/logtailUtils.js"; +import { shouldUseRedis } from "@/external/redis/initRedis.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { createRateLimitRedisStore } from "@/internal/misc/rateLimiter/rateLimitRedisStore.js"; + +export const createRouterRateLimiter = ({ + keyPrefix, + limit, + windowMs, +}: { + keyPrefix: string; + limit: number; + windowMs: number; +}) => { + let limiter: ReturnType> | null = null; + + const getLimiter = () => { + limiter ??= rateLimiter({ + windowMs, + limit, + standardHeaders: "draft-6", + store: createRateLimitRedisStore(), + keyGenerator: (c: Context) => { + const ctx = c.get("ctx"); + return `${keyPrefix}:${ctx.org.id}:${ctx.env}:${c.req.path}`; + }, + }); + + return limiter; + }; + + return async (c: Context, next: Next) => { + if (!shouldUseRedis()) return next(); + + try { + return await getLimiter()(c, next); + } catch (error) { + limiter = null; + logger.error(`[router-rate-limit] Redis rate limit failed: ${error}`); + return next(); + } + }; +}; diff --git a/server/src/honoMiddlewares/secretKeyMiddleware.ts b/server/src/honoMiddlewares/secretKeyMiddleware.ts index f158eb0ca..441d88505 100644 --- a/server/src/honoMiddlewares/secretKeyMiddleware.ts +++ b/server/src/honoMiddlewares/secretKeyMiddleware.ts @@ -1,7 +1,14 @@ -import { AuthType, ErrCode, type Feature, RecaseError } from "@autumn/shared"; +import { + getBearerToken, + isOAuthToken, + isPublishableKeyPrefix, + isSecretKeyPrefix, +} from "@autumn/auth"; +import { AuthType, ErrCode, RecaseError, sortFeatures } from "@autumn/shared"; import type { Context, Next } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js"; +import { handleOAuthMiddleware } from "./authMiddlewares/handleOAuthMiddleware.js"; import { betterAuthMiddleware } from "./betterAuthMiddleware.js"; import { publicKeyMiddleware } from "./publicKeyMiddleware.js"; @@ -29,12 +36,11 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => { return betterAuthMiddleware(c, next); } - const authHeader = - c.req.header("authorization") || c.req.header("Authorization"); + const bearerToken = getBearerToken({ headers: c.req.raw.headers }); // Step 1 & 2: Check if Authorization header exists // If from dashboard and no Bearer token, use Better Auth session instead - if (!authHeader || !authHeader.startsWith("Bearer ")) { + if (!bearerToken) { throw new RecaseError({ message: "Secret key not found in Authorization header", code: ErrCode.NoSecretKey, @@ -42,30 +48,31 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => { }); } - // Step 2: Extract and validate API key format - const apiKey = authHeader.split(" ")[1]; - - if (!apiKey.startsWith("am_")) { - throw new RecaseError({ - message: `Invalid secret key: ${maskApiKey(apiKey)}`, - code: ErrCode.InvalidSecretKey, - statusCode: 401, - }); + if (isOAuthToken({ token: bearerToken })) { + return handleOAuthMiddleware({ c, token: bearerToken, next }); } // Step 3: Handle publishable key verification - if (apiKey.startsWith("am_pk")) { - return publicKeyMiddleware(c, apiKey, next); + if (isPublishableKeyPrefix({ token: bearerToken })) { + return publicKeyMiddleware(c, bearerToken, next); + } + + if (!isSecretKeyPrefix({ token: bearerToken })) { + throw new RecaseError({ + message: "Invalid authorization token prefix", + code: ErrCode.InvalidRequest, + statusCode: 401, + }); } // Step 4: Verify the API key const { valid, data } = await verifyKey({ db: ctx.db, - key: apiKey, + key: bearerToken, }); if (!valid || !data) { - const maskedKey = maskApiKey(apiKey); + const maskedKey = maskApiKey(bearerToken); throw new RecaseError({ message: `Invalid secret key: ${maskedKey}`, code: ErrCode.InvalidSecretKey, @@ -77,13 +84,7 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => { const { org, features, env, userId } = data; const scopes = (data as { scopes?: string[] | null }).scopes ?? []; - if (features) { - features.sort((a: Feature, b: Feature) => { - if (a.archived && !b.archived) return 1; - if (!a.archived && b.archived) return -1; - return 0; - }); - } + sortFeatures({ features }); ctx.org = org; ctx.features = features; diff --git a/server/src/honoUtils/HonoEnv.ts b/server/src/honoUtils/HonoEnv.ts index fa47174a7..c7f928183 100644 --- a/server/src/honoUtils/HonoEnv.ts +++ b/server/src/honoUtils/HonoEnv.ts @@ -28,6 +28,7 @@ export type RequestContext = { features: Feature[]; user?: User; userId?: string; + oauthResource?: string; customerId?: string; entityId?: string; diff --git a/server/src/init.ts b/server/src/init.ts index 707bd94f9..254807594 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -45,7 +45,6 @@ import { import { preWarmOrgRedisConnections } from "./external/redis/orgRedisPool.js"; import { createHonoApp } from "./initHono.js"; import { otelSdk } from "./instrumentation.js"; -import { initializeDatabaseFunctions } from "./db/initializeDatabaseFunctions.js"; import { checkEnvVars } from "./utils/initUtils.js"; import { startMemoryMonitor } from "./utils/memoryMonitor.js"; @@ -67,7 +66,6 @@ const init = async ({ startupStartedAt }: { startupStartedAt: number }) => { void preWarmOrgRedisConnections({ db }).catch((error) => { logger.warn("[OrgRedis] Warmup failed", { error }); }); - await initializeDatabaseFunctions(); await startAllEdgeConfigPolling({ logger }); await Promise.all([primeRedisMonitor(), primeRedisV2Monitor()]); diff --git a/server/src/initHono.ts b/server/src/initHono.ts index af11957c5..15badabc1 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -1,10 +1,4 @@ -import { oauthClient } from "@autumn/shared"; -import { - oauthProviderAuthServerMetadata, - oauthProviderOpenIdConfigMetadata, -} from "@better-auth/oauth-provider"; import { httpInstrumentationMiddleware } from "@hono/otel"; -import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { autumnWebhookRouter } from "./external/autumn/autumnWebhookRouter.js"; @@ -19,11 +13,13 @@ import type { HonoEnv } from "./honoUtils/HonoEnv.js"; import { handleHealthCheck } from "./honoUtils/handleHealthCheck.js"; import { handleReadyCheck } from "./honoUtils/handleReadyCheck.js"; import { handleListAuthOrganizations } from "./internal/auth/handleListAuthOrganizations.js"; +import { oauthRouter } from "./internal/auth/oauth/oauthRouter.js"; import { cliRouter } from "./internal/dev/cli/cliRouter.js"; import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js"; +import { handleRevenueCatOAuthCallback } from "./internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.js"; import { apiRouter } from "./routers/apiRouter.js"; +import { createChatProxyRouter } from "./routers/chatProxyRouter.js"; import { internalRouter } from "./routers/internalRouter.js"; -import { mcpProxyRouter } from "./routers/mcpProxyRouter.js"; import { publicRouter } from "./routers/publicRouter.js"; import { auth } from "./utils/auth.js"; import { isAllowedOrigin } from "./utils/corsOrigins.js"; @@ -55,6 +51,8 @@ const ALLOWED_HEADERS = [ export const createHonoApp = () => { const app = new Hono(); + app.route("", createChatProxyRouter()); + // CORS configuration (must be before routes) app.use( "*", @@ -68,13 +66,7 @@ export const createHonoApp = () => { }), ); - app.get("/api/auth/.well-known/openid-configuration", (c) => { - return oauthProviderOpenIdConfigMetadata(auth)(c.req.raw); - }); - - app.get("/.well-known/oauth-authorization-server/api/auth", (c) => { - return oauthProviderAuthServerMetadata(auth)(c.req.raw); - }); + app.route("", oauthRouter); // Better Auth's joined Drizzle query defaults to 100 memberships. app.get("/api/auth/organization/list", handleListAuthOrganizations); @@ -87,11 +79,10 @@ export const createHonoApp = () => { // Health check endpoint for AWS/ECS load balancer app.get("/stripe/oauth_callback", handleOAuthCallback); + app.get("/revenuecat/oauth_callback", handleRevenueCatOAuthCallback); app.get("/ready/:token", handleReadyCheck); app.get("/", handleHealthCheck); - app.route("", mcpProxyRouter); - // Step 1: OTel HTTP span + base middleware + span enrichment app.use( "*", @@ -103,33 +94,6 @@ export const createHonoApp = () => { app.use("*", baseMiddleware); app.use("*", replicaDbMiddleware); - // Public endpoint to get OAuth client name (for consent page) - app.get("/oauth/client/:client_id", async (c) => { - const clientId = c.req.param("client_id"); - if (!clientId) { - return c.json({ error: "client_id is required" }, 400); - } - - const db = c.get("ctx").db; - const client = await db - .select({ - name: oauthClient.name, - clientId: oauthClient.clientId, - }) - .from(oauthClient) - .where(eq(oauthClient.clientId, clientId)) - .limit(1); - - if (!client.length) { - return c.json({ error: "Client not found" }, 404); - } - - return c.json({ - client_id: client[0].clientId, - name: client[0].name || "Unknown Application", - }); - }); - // CLI routes (uses Bearer token auth, not session auth) app.route("/cli", cliRouter); diff --git a/server/src/internal/admin/adminRouter.ts b/server/src/internal/admin/adminRouter.ts index 0bae2e7b6..955795de4 100644 --- a/server/src/internal/admin/adminRouter.ts +++ b/server/src/internal/admin/adminRouter.ts @@ -32,6 +32,12 @@ import { handleGetOrgMember } from "./handleGetOrgMember"; import { handleListAdminOrgs } from "./handleListAdminOrgs"; import { handleListAdminUsers } from "./handleListAdminUsers"; import { handleListOAuthClients } from "./handleListOAuthClients"; +import { + handleCreateSlackAdminInstall, + handleDeleteSlackAdminInstall, + handleGetSlackAdminInstall, + handleUpdateSlackAdminTarget, +} from "./handleSlackAdminChat"; import { handleUpsertAdminCustomerBlockConfig } from "./handleUpsertAdminCustomerBlockConfig"; import { handleUpsertAdminFeatureFlagsConfig } from "./handleUpsertAdminFeatureFlagsConfig"; import { handleUpsertAdminFullSubjectGateConfig } from "./handleUpsertAdminFullSubjectGateConfig"; @@ -44,6 +50,7 @@ import { handleUpsertAdminRateLimitRedisAllowlistConfig } from "./handleUpsertAd import { handleUpsertAdminRedisV2CacheConfig } from "./handleUpsertAdminRedisV2CacheConfig"; import { handleUpsertAdminRequestBlockConfig } from "./handleUpsertAdminRequestBlockConfig"; import { handleUpsertAdminStripeSyncConfig } from "./handleUpsertAdminStripeSyncConfig"; +import { handleUpsertSlackMcpOAuthClient } from "./handleUpsertSlackMcpOAuthClient"; import { handleDeleteRollout } from "./rollouts/handleDeleteRollout"; import { handleDeleteRolloutOrg } from "./rollouts/handleDeleteRolloutOrg"; import { handleGetRollouts } from "./rollouts/handleGetRollouts"; @@ -159,6 +166,20 @@ honoAdminRouter.delete("/cache-v2-ramp", ...handleDeleteAdminCacheV2Ramp); honoAdminRouter.get("/org-member", ...handleGetOrgMember); honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount); honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients); +honoAdminRouter.post( + "/oauth-clients/slack-mcp", + ...handleUpsertSlackMcpOAuthClient, +); +honoAdminRouter.get("/chat/slack-admin", ...handleGetSlackAdminInstall); +honoAdminRouter.post( + "/chat/slack-admin/install", + ...handleCreateSlackAdminInstall, +); +honoAdminRouter.patch( + "/chat/slack-admin/target", + ...handleUpdateSlackAdminTarget, +); +honoAdminRouter.delete("/chat/slack-admin", ...handleDeleteSlackAdminInstall); honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems); honoAdminRouter.get("/rollouts", ...handleGetRollouts); diff --git a/server/src/internal/admin/handleListOAuthClients.ts b/server/src/internal/admin/handleListOAuthClients.ts index 01cf75848..0147e1804 100644 --- a/server/src/internal/admin/handleListOAuthClients.ts +++ b/server/src/internal/admin/handleListOAuthClients.ts @@ -1,5 +1,5 @@ -import { oauthClient, Scopes } from "@autumn/shared"; -import { desc } from "drizzle-orm"; +import { Scopes } from "@autumn/shared"; +import { oauthClientRepo } from "@/internal/auth/repos/index.js"; import { createRoute } from "../../honoMiddlewares/routeHandler"; export const handleListOAuthClients = createRoute({ @@ -8,24 +8,7 @@ export const handleListOAuthClients = createRoute({ const ctx = c.get("ctx"); const { db } = ctx; - const clients = await db - .select({ - id: oauthClient.id, - clientId: oauthClient.clientId, - name: oauthClient.name, - redirectUris: oauthClient.redirectUris, - public: oauthClient.public, - disabled: oauthClient.disabled, - skipConsent: oauthClient.skipConsent, - scopes: oauthClient.scopes, - tokenEndpointAuthMethod: oauthClient.tokenEndpointAuthMethod, - grantTypes: oauthClient.grantTypes, - responseTypes: oauthClient.responseTypes, - createdAt: oauthClient.createdAt, - updatedAt: oauthClient.updatedAt, - }) - .from(oauthClient) - .orderBy(desc(oauthClient.createdAt)); + const clients = await oauthClientRepo.listForAdmin({ db }); return c.json({ clients: clients.map((client) => ({ diff --git a/server/src/internal/admin/handleSlackAdminChat.ts b/server/src/internal/admin/handleSlackAdminChat.ts new file mode 100644 index 000000000..dc8d91325 --- /dev/null +++ b/server/src/internal/admin/handleSlackAdminChat.ts @@ -0,0 +1,337 @@ +import crypto, { randomUUID } from "node:crypto"; +import { stripOAuthTokenPrefix } from "@autumn/auth"; +import { + AppEnv, + apiKeys, + type ChatOAuthCredential, + chatInstallations, + chatOAuthCredentials, + createChatInstallState, + ErrCode, + oauthAccessToken, + oauthConsent, + oauthRefreshToken, + organizations, + RecaseError, + Scopes, +} from "@autumn/shared"; +import { addMinutes } from "date-fns"; +import { and, eq, inArray, or, sql } from "drizzle-orm"; +import { z } from "zod/v4"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { hashOAuthToken } from "@/utils/oauthUtils.js"; +import { + createSlackInstallUrl, + getChatStateSecret, + getSlackAdminProvider, +} from "../chat/chatUtils.js"; +import { clearSecretKeyCache } from "../dev/api-keys/cacheApiKeyUtils.js"; + +const targetBody = z.strictObject({ + org_id: z.string().min(1), + env: z.enum(AppEnv), +}); + +const findTargetOrg = ({ + db, + orgIdOrSlug, +}: { + db: DrizzleCli; + orgIdOrSlug: string; +}) => + db.query.organizations.findFirst({ + where: or( + eq(organizations.id, orgIdOrSlug), + eq(organizations.slug, orgIdOrSlug), + ), + }); + +const getSlackAdminInstallation = async ({ db }: { db: DrizzleCli }) => + db.query.chatInstallations.findFirst({ + where: eq(chatInstallations.provider, getSlackAdminProvider()), + }); + +const getSlackAdminOAuthCredentials = async ({ + db, + installationId, +}: { + db: DrizzleCli; + installationId: string; +}) => + db.query.chatOAuthCredentials.findMany({ + where: eq(chatOAuthCredentials.chat_installation_id, installationId), + }); + +const getOrgSummary = async ({ + db, + orgId, +}: { + db: DrizzleCli; + orgId: string; +}) => + db.query.organizations.findFirst({ + where: eq(organizations.id, orgId), + columns: { + id: true, + name: true, + slug: true, + }, + }); + +const decryptChatCredentialToken = ({ token }: { token: string }) => { + const key = crypto + .createHash("sha256") + .update(process.env.ENCRYPTION_PASSWORD ?? "") + .digest(); + const buffer = Buffer.from(token, "base64"); + if (buffer[0] !== 1) throw new Error("Unsupported encrypted payload"); + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + key, + buffer.subarray(1, 13), + ); + decipher.setAuthTag(buffer.subarray(13, 29)); + return Buffer.concat([ + decipher.update(buffer.subarray(29)), + decipher.final(), + ]).toString("utf8"); +}; + +const getStoredOAuthTokenValues = async ({ + token, + stripPrefix = false, +}: { + token: string; + stripPrefix?: boolean; +}) => { + const rawToken = stripPrefix ? stripOAuthTokenPrefix({ token }) : token; + return [rawToken, await hashOAuthToken(rawToken)]; +}; + +const revokeSlackAdminOAuthArtifacts = async ({ + db, + credentials, +}: { + db: Pick; + credentials: ChatOAuthCredential[]; +}) => { + const consentIds = [ + ...new Set( + credentials + .map((credential) => credential.oauth_consent_id) + .filter((id): id is string => Boolean(id)), + ), + ]; + + if (consentIds.length > 0) { + const accessTokenValues: string[] = []; + const refreshTokenValues: string[] = []; + for (const credential of credentials) { + accessTokenValues.push( + ...(await getStoredOAuthTokenValues({ + token: decryptChatCredentialToken({ token: credential.access_token }), + stripPrefix: true, + })), + ); + refreshTokenValues.push( + ...(await getStoredOAuthTokenValues({ + token: decryptChatCredentialToken({ + token: credential.refresh_token, + }), + })), + ); + } + + const uniqueAccessTokenValues = [...new Set(accessTokenValues)]; + const uniqueRefreshTokenValues = [...new Set(refreshTokenValues)]; + if (uniqueAccessTokenValues.length > 0) { + await db + .delete(oauthAccessToken) + .where(inArray(oauthAccessToken.token, uniqueAccessTokenValues)); + } + if (uniqueRefreshTokenValues.length > 0) { + await db + .delete(oauthRefreshToken) + .where(inArray(oauthRefreshToken.token, uniqueRefreshTokenValues)); + } + + for (const consentId of consentIds) { + const linkedKeys = await db + .select({ id: apiKeys.id, hashedKey: apiKeys.hashed_key }) + .from(apiKeys) + .where(sql`${apiKeys.meta}->>'oauth_consent_id' = ${consentId}`); + + for (const key of linkedKeys) { + await db.delete(apiKeys).where(eq(apiKeys.id, key.id)); + if (key.hashedKey) + await clearSecretKeyCache({ hashedKey: key.hashedKey }); + } + } + + await db.delete(oauthConsent).where(inArray(oauthConsent.id, consentIds)); + } + + const credentialIds = credentials.map((credential) => credential.id); + if (credentialIds.length > 0) { + await db + .delete(chatOAuthCredentials) + .where(inArray(chatOAuthCredentials.id, credentialIds)); + } +}; + +export const handleCreateSlackAdminInstall = createRoute({ + scopes: [Scopes.Superuser], + handler: async (c) => { + const ctx = c.get("ctx"); + const state = createChatInstallState({ + secret: getChatStateSecret(), + provider: getSlackAdminProvider(), + orgId: ctx.org.id, + userId: ctx.userId ?? "", + env: ctx.env, + expiresAt: addMinutes(Date.now(), 10).getTime(), + nonce: randomUUID(), + }); + + return c.json({ url: createSlackInstallUrl(state) }); + }, +}); + +export const handleGetSlackAdminInstall = createRoute({ + scopes: [Scopes.Superuser], + handler: async (c) => { + const { db } = c.get("ctx"); + const installation = await getSlackAdminInstallation({ db }); + const targetOrg = installation + ? await getOrgSummary({ db, orgId: installation.org_id }) + : null; + const oauthCredentials = installation + ? await getSlackAdminOAuthCredentials({ + db, + installationId: installation.id, + }) + : []; + + return c.json({ + installation: installation + ? { + id: installation.id, + workspace_id: installation.workspace_id, + workspace_name: installation.workspace_name, + bot_user_id: installation.bot_user_id, + target_org_id: installation.org_id, + target_org_name: targetOrg?.name ?? null, + target_org_slug: targetOrg?.slug ?? null, + target_env: installation.default_env, + updated_at: installation.updated_at, + installed_by_user_id: installation.installed_by_user_id, + oauth_credentials: oauthCredentials.map((credential) => ({ + id: credential.id, + env: credential.env, + oauth_client_id: credential.oauth_client_id, + oauth_consent_id: credential.oauth_consent_id, + access_token_expires_at: credential.access_token_expires_at, + updated_at: credential.updated_at, + })), + } + : null, + }); + }, +}); + +export const handleUpdateSlackAdminTarget = createRoute({ + scopes: [Scopes.Superuser], + body: targetBody, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db } = ctx; + const { org_id: orgIdOrSlug, env } = c.req.valid("json"); + const installation = await getSlackAdminInstallation({ db }); + if (!installation) { + throw new RecaseError({ + message: "Slack admin bot is not installed", + code: ErrCode.InvalidRequest, + statusCode: 404, + }); + } + + const targetOrg = await findTargetOrg({ db, orgIdOrSlug }); + if (!targetOrg) { + throw new RecaseError({ + message: "Target org not found for ID or slug", + code: ErrCode.OrgNotFound, + statusCode: 404, + }); + } + + const oauthCredentials = await getSlackAdminOAuthCredentials({ + db, + installationId: installation.id, + }); + const updated = await db.transaction(async (tx) => { + const now = Date.now(); + const [updatedInstallation] = await tx + .update(chatInstallations) + .set({ + org_id: targetOrg.id, + default_env: env, + installed_by_user_id: ctx.userId, + updated_at: now, + }) + .where(eq(chatInstallations.id, installation.id)) + .returning(); + + await revokeSlackAdminOAuthArtifacts({ + db: tx, + credentials: oauthCredentials, + }); + + return updatedInstallation; + }); + + return c.json({ + installation: { + id: updated.id, + workspace_id: updated.workspace_id, + workspace_name: updated.workspace_name, + target_org_id: updated.org_id, + target_org_name: targetOrg.name, + target_org_slug: targetOrg.slug, + target_env: updated.default_env, + updated_at: updated.updated_at, + installed_by_user_id: updated.installed_by_user_id, + }, + }); + }, +}); + +export const handleDeleteSlackAdminInstall = createRoute({ + scopes: [Scopes.Superuser], + handler: async (c) => { + const { db } = c.get("ctx"); + const installation = await getSlackAdminInstallation({ db }); + if (!installation) return c.json({ success: true }); + const oauthCredentials = await getSlackAdminOAuthCredentials({ + db, + installationId: installation.id, + }); + + await db.transaction(async (tx) => { + await revokeSlackAdminOAuthArtifacts({ + db: tx, + credentials: oauthCredentials, + }); + await tx + .delete(chatInstallations) + .where( + and( + eq(chatInstallations.id, installation.id), + eq(chatInstallations.provider, getSlackAdminProvider()), + ), + ); + }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts b/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts new file mode 100644 index 000000000..0e818a667 --- /dev/null +++ b/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts @@ -0,0 +1,37 @@ +import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { registerMcpOAuthClient } from "@/internal/auth/actions/index.js"; +import { createRoute } from "../../honoMiddlewares/routeHandler"; + +const getClientUrl = () => + (process.env.CLIENT_URL || "http://localhost:3000").replace(/\/+$/, ""); + +const getSlackMcpRedirectUris = () => { + const clientUrl = getClientUrl(); + return [ + `${clientUrl}/admin/oauth/slack-mcp/callback`, + `${clientUrl}/sandbox/admin/oauth/slack-mcp/callback`, + ]; +}; + +export const handleUpsertSlackMcpOAuthClient = createRoute({ + scopes: [Scopes.Superuser], + handler: async (c) => { + const { db } = c.get("ctx"); + const result = await registerMcpOAuthClient({ + db, + clientName: "Slack MCP", + redirectUris: getSlackMcpRedirectUris(), + scope: undefined, + }); + + if ("error" in result) { + throw new RecaseError({ + message: result.error, + code: ErrCode.InvalidRequest, + statusCode: result.status, + }); + } + + return c.json(result.body, result.status); + }, +}); diff --git a/server/src/internal/agent/rules/actions/generateAndUpdateAgentRules.ts b/server/src/internal/agent/rules/actions/generateAndUpdateAgentRules.ts new file mode 100644 index 000000000..34cd35d26 --- /dev/null +++ b/server/src/internal/agent/rules/actions/generateAndUpdateAgentRules.ts @@ -0,0 +1,27 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { generateAgentRules } from "../../workflows/generateAgentRules/generateAgentRules.js"; +import { agentRulesRepo } from "../repos/index.js"; + +export const generateAndUpdateAgentRules = async ({ + ctx, + endTime, + startTime, +}: { + ctx: AutumnContext; + endTime?: string; + startTime?: string; +}) => { + const generated = await generateAgentRules({ ctx, endTime, startTime }); + const rules = await agentRulesRepo.upsert({ + db: ctx.db, + metadata: generated.metadata, + orgId: ctx.org.id, + orgSlug: ctx.org.slug, + rules: generated.rules, + }); + + return { + ...rules, + unconfigured: generated.unconfigured ?? false, + }; +}; diff --git a/server/src/internal/agent/rules/actions/index.ts b/server/src/internal/agent/rules/actions/index.ts new file mode 100644 index 000000000..d17344a08 --- /dev/null +++ b/server/src/internal/agent/rules/actions/index.ts @@ -0,0 +1,7 @@ +import { generateAndUpdateAgentRules } from "./generateAndUpdateAgentRules.js"; +import { updateAgentRules } from "./updateAgentRules.js"; + +export const agentRulesActions = { + generateAndUpdate: generateAndUpdateAgentRules, + update: updateAgentRules, +}; diff --git a/server/src/internal/agent/rules/actions/updateAgentRules.ts b/server/src/internal/agent/rules/actions/updateAgentRules.ts new file mode 100644 index 000000000..fef58a928 --- /dev/null +++ b/server/src/internal/agent/rules/actions/updateAgentRules.ts @@ -0,0 +1,31 @@ +import { mergeAgentRules, type PartialAgentRules } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { agentRulesRepo } from "../repos/index.js"; + +export const updateAgentRules = async ({ + ctx, + updates, +}: { + ctx: AutumnContext; + updates: PartialAgentRules; +}) => { + const existing = await agentRulesRepo.get({ + db: ctx.db, + orgId: ctx.org.id, + }); + const rules = mergeAgentRules({ + base: { + credit_rules: existing.credit_rules, + entity_rules: existing.entity_rules, + notes: existing.notes, + }, + updates, + }); + + return agentRulesRepo.upsert({ + db: ctx.db, + orgId: ctx.org.id, + orgSlug: ctx.org.slug, + rules, + }); +}; diff --git a/server/src/internal/agent/rules/agentRulesRouter.ts b/server/src/internal/agent/rules/agentRulesRouter.ts new file mode 100644 index 000000000..1479d53f6 --- /dev/null +++ b/server/src/internal/agent/rules/agentRulesRouter.ts @@ -0,0 +1,22 @@ +import { Hono } from "hono"; +import { createRouterRateLimiter } from "@/honoMiddlewares/routerRateLimiter/index.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleGenerateAgentRules } from "./handlers/handleGenerateAgentRules.js"; +import { handleGetAgentRules } from "./handlers/handleGetAgentRules.js"; +import { handleUpdateAgentRules } from "./handlers/handleUpdateAgentRules.js"; + +export const agentRulesRpcRouter = new Hono(); +const generateAgentRulesLimiter = createRouterRateLimiter({ + keyPrefix: "agent_rules_generate", + limit: 1, + windowMs: 1000, +}); + +agentRulesRpcRouter.post("/agent.get_rules", ...handleGetAgentRules); +agentRulesRpcRouter.post("/agent.update_rules", ...handleUpdateAgentRules); + +agentRulesRpcRouter.post( + "/agent.generate_rules", + generateAgentRulesLimiter, + ...handleGenerateAgentRules, +); diff --git a/server/src/internal/agent/rules/handlers/handleGenerateAgentRules.ts b/server/src/internal/agent/rules/handlers/handleGenerateAgentRules.ts new file mode 100644 index 000000000..fd8c547d4 --- /dev/null +++ b/server/src/internal/agent/rules/handlers/handleGenerateAgentRules.ts @@ -0,0 +1,27 @@ +import { Scopes } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { agentRulesActions } from "../actions/index.js"; + +const GenerateAgentRulesSchema = z + .object({ + end_time: z.string().optional(), + start_time: z.string().optional(), + }) + .strict(); + +export const handleGenerateAgentRules = createRoute({ + scopes: [Scopes.Organisation.Write], + body: GenerateAgentRulesSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const input = c.req.valid("json"); + const rules = await agentRulesActions.generateAndUpdate({ + ctx, + endTime: input.end_time, + startTime: input.start_time, + }); + + return c.json(rules); + }, +}); diff --git a/server/src/internal/agent/rules/handlers/handleGetAgentRules.ts b/server/src/internal/agent/rules/handlers/handleGetAgentRules.ts new file mode 100644 index 000000000..1128e5829 --- /dev/null +++ b/server/src/internal/agent/rules/handlers/handleGetAgentRules.ts @@ -0,0 +1,21 @@ +import { Scopes } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { agentRulesRepo } from "../repos/index.js"; + +export const handleGetAgentRules = createRoute({ + scopes: [Scopes.Organisation.Read], + body: z.object({}).strict(), + handler: async (c) => { + const ctx = c.get("ctx"); + const rules = await agentRulesRepo.get({ + db: ctx.db, + orgId: ctx.org.id, + }); + + return c.json({ + ...rules, + org_slug: rules.org_slug ?? ctx.org.slug, + }); + }, +}); diff --git a/server/src/internal/agent/rules/handlers/handleUpdateAgentRules.ts b/server/src/internal/agent/rules/handlers/handleUpdateAgentRules.ts new file mode 100644 index 000000000..9ccd93eb3 --- /dev/null +++ b/server/src/internal/agent/rules/handlers/handleUpdateAgentRules.ts @@ -0,0 +1,18 @@ +import { PartialAgentRulesSchema, Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { agentRulesActions } from "../actions/index.js"; + +export const handleUpdateAgentRules = createRoute({ + scopes: [Scopes.Organisation.Write], + body: PartialAgentRulesSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const updates = c.req.valid("json"); + const rules = await agentRulesActions.update({ + ctx, + updates, + }); + + return c.json(rules); + }, +}); diff --git a/server/src/internal/agent/rules/repos/getAgentRules.ts b/server/src/internal/agent/rules/repos/getAgentRules.ts new file mode 100644 index 000000000..c74810298 --- /dev/null +++ b/server/src/internal/agent/rules/repos/getAgentRules.ts @@ -0,0 +1,32 @@ +import { + AgentRulesSchema, + agentRules, + defaultAgentRules, +} from "@autumn/shared"; +import { eq } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; + +export const getAgentRules = async ({ + db, + orgId, +}: { + db: DrizzleCli; + orgId: string; +}) => { + const row = await db.query.agentRules.findFirst({ + where: eq(agentRules.org_id, orgId), + }); + const defaults = defaultAgentRules(); + + return { + ...AgentRulesSchema.parse({ + credit_rules: row?.credit_rules ?? defaults.credit_rules, + entity_rules: row?.entity_rules ?? defaults.entity_rules, + notes: row?.notes ?? defaults.notes, + }), + metadata: row?.metadata ?? {}, + org_id: orgId, + org_slug: row?.org_slug, + updated_at: row?.updated_at, + }; +}; diff --git a/server/src/internal/agent/rules/repos/index.ts b/server/src/internal/agent/rules/repos/index.ts new file mode 100644 index 000000000..f09cc48ed --- /dev/null +++ b/server/src/internal/agent/rules/repos/index.ts @@ -0,0 +1,7 @@ +import { getAgentRules } from "./getAgentRules.js"; +import { upsertAgentRules } from "./upsertAgentRules.js"; + +export const agentRulesRepo = { + get: getAgentRules, + upsert: upsertAgentRules, +}; diff --git a/server/src/internal/agent/rules/repos/upsertAgentRules.ts b/server/src/internal/agent/rules/repos/upsertAgentRules.ts new file mode 100644 index 000000000..fd1193a7f --- /dev/null +++ b/server/src/internal/agent/rules/repos/upsertAgentRules.ts @@ -0,0 +1,58 @@ +import { type AgentRules, AgentRulesSchema, agentRules } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; + +export const upsertAgentRules = async ({ + db, + metadata, + orgId, + orgSlug, + rules, +}: { + db: DrizzleCli; + metadata?: Record; + orgId: string; + orgSlug: string; + rules: AgentRules; +}) => { + const now = Date.now(); + const parsedRules = AgentRulesSchema.parse(rules); + const rows = await db + .insert(agentRules) + .values({ + credit_rules: parsedRules.credit_rules, + entity_rules: parsedRules.entity_rules, + metadata: metadata ?? {}, + notes: parsedRules.notes, + org_id: orgId, + org_slug: orgSlug, + created_at: now, + updated_at: now, + }) + .onConflictDoUpdate({ + set: { + credit_rules: parsedRules.credit_rules, + entity_rules: parsedRules.entity_rules, + ...(metadata ? { metadata } : {}), + notes: parsedRules.notes, + org_slug: orgSlug, + updated_at: now, + }, + target: agentRules.org_id, + }) + .returning(); + + const row = rows[0]; + if (!row) throw new Error("Failed to upsert agent rules"); + + return { + ...AgentRulesSchema.parse({ + credit_rules: row.credit_rules, + entity_rules: row.entity_rules, + notes: row.notes, + }), + metadata: row.metadata ?? {}, + org_id: row.org_id, + org_slug: row.org_slug, + updated_at: row.updated_at, + }; +}; diff --git a/server/src/internal/agent/workflows/generateAgentRules/generateAgentRules.ts b/server/src/internal/agent/workflows/generateAgentRules/generateAgentRules.ts new file mode 100644 index 000000000..e0f4b429f --- /dev/null +++ b/server/src/internal/agent/workflows/generateAgentRules/generateAgentRules.ts @@ -0,0 +1,39 @@ +import { type AgentRules, AgentRulesSchema } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { generateCreditRules } from "./generateCreditRules.js"; +import { generateEntityRules } from "./generateEntityRules.js"; + +export const generateAgentRules = async ({ + ctx, + endTime = "now", + startTime = "now-30d", +}: { + ctx: AutumnContext; + endTime?: string; + startTime?: string; +}): Promise<{ + rules: AgentRules; + metadata: Record; + unconfigured?: boolean; +}> => { + const [entityResult, creditResult] = await Promise.all([ + generateEntityRules({ ctx, endTime, startTime }), + generateCreditRules({ ctx, endTime, startTime }), + ]); + + return { + rules: AgentRulesSchema.parse({ + credit_rules: creditResult.creditRules, + entity_rules: entityResult.entityRules, + notes: "", + }), + metadata: { + credit_rules: creditResult.metadata, + entity_rules: entityResult.metadata, + generated_at: Date.now(), + generated_from: "axiom", + }, + unconfigured: + entityResult.unconfigured || creditResult.unconfigured || undefined, + }; +}; diff --git a/server/src/internal/agent/workflows/generateAgentRules/generateCreditRules.ts b/server/src/internal/agent/workflows/generateAgentRules/generateCreditRules.ts new file mode 100644 index 000000000..ded9968f1 --- /dev/null +++ b/server/src/internal/agent/workflows/generateAgentRules/generateCreditRules.ts @@ -0,0 +1,125 @@ +import { + AgentRulesSchema, + type CreditRules, + type Feature, + FeatureType, + FeatureUsageType, +} from "@autumn/shared"; +import { isAxiomConfigured } from "@/external/axiom/initAxiom.js"; +import { queryAxiom } from "@/external/axiom/queryAxiom.js"; +import { escapeApl } from "@/external/axiom/utils/aplUtils.js"; +import { + axiomStringFrom, + getAxiomMatchData, +} from "@/external/axiom/utils/resultUtils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; + +const trackedFeatureApl = ({ ctx }: { ctx: AutumnContext }) => + ` +['express'] +| where isnotnull(statusCode) +| where ['context.org_id'] == '${escapeApl(ctx.org.id)}' +| where ['context.env'] == '${ctx.env}' +| where (['req.url'] endswith '/v1/track' or ['req.url'] endswith '/v1/events' or ['req.url'] endswith '/v1/balances.track' or ['req.url'] endswith '/v1/check') +| extend body_feature_id = tostring(['req.body']['feature_id']) +| extend selected_feature_id = case(isnotempty(body_feature_id), body_feature_id, isnotempty(feature_id), feature_id, isnotempty(featureId), featureId, '') +| where isnotempty(selected_feature_id) +| summarize total=count() by selected_feature_id +| top 10 by total +`.trim(); + +const isConsumableCreditSystem = (feature: Feature) => + feature.type === FeatureType.CreditSystem && + feature.config?.usage_type === FeatureUsageType.Single; + +const resolveCreditFeatureId = ({ + features, + trackedFeatureIds, +}: { + features: Feature[]; + trackedFeatureIds: string[]; +}) => { + for (const trackedFeatureId of trackedFeatureIds) { + const trackedFeature = features.find( + (feature) => feature.id === trackedFeatureId, + ); + if (!trackedFeature) continue; + + if (isConsumableCreditSystem(trackedFeature)) return trackedFeature.id; + if (trackedFeature.type !== FeatureType.Metered) continue; + + const creditSystem = getCreditSystemsFromFeature({ + featureId: trackedFeature.id, + features, + }).find(isConsumableCreditSystem); + + if (creditSystem) return creditSystem.id; + } + + return ""; +}; + +const getFeatures = async ({ ctx }: { ctx: AutumnContext }) => + ctx.features.length > 0 + ? ctx.features + : FeatureService.list({ + db: ctx.db, + env: ctx.env, + orgId: ctx.org.id, + archived: false, + }); + +export const generateCreditRules = async ({ + ctx, + endTime = "now", + startTime = "now-30d", +}: { + ctx: AutumnContext; + endTime?: string; + startTime?: string; +}): Promise<{ + creditRules: CreditRules; + metadata: Record; + unconfigured?: boolean; +}> => { + if (!isAxiomConfigured()) { + const defaults = AgentRulesSchema.parse({ + credit_rules: {}, + entity_rules: {}, + notes: "", + }); + return { + creditRules: defaults.credit_rules, + metadata: { generated_from: "axiom", reason: "axiom_not_configured" }, + unconfigured: true, + }; + } + + const [trackedFeatureResult, features] = await Promise.all([ + queryAxiom({ + apl: trackedFeatureApl({ ctx }), + options: { endTime, startTime }, + }), + getFeatures({ ctx }), + ]); + const trackedFeatureIds = getAxiomMatchData(trackedFeatureResult).map( + (match) => axiomStringFrom(match.selected_feature_id), + ); + const creditRules = { + credit_feature_id: resolveCreditFeatureId({ + features, + trackedFeatureIds, + }), + } satisfies CreditRules; + + return { + creditRules, + metadata: { + credit_feature_id: creditRules.credit_feature_id, + generated_from: "axiom", + top_tracked_feature_ids: trackedFeatureIds, + }, + }; +}; diff --git a/server/src/internal/agent/workflows/generateAgentRules/generateEntityRules.ts b/server/src/internal/agent/workflows/generateAgentRules/generateEntityRules.ts new file mode 100644 index 000000000..594c13f62 --- /dev/null +++ b/server/src/internal/agent/workflows/generateAgentRules/generateEntityRules.ts @@ -0,0 +1,103 @@ +import { AgentRulesSchema, type EntityRules } from "@autumn/shared"; +import { isAxiomConfigured } from "@/external/axiom/initAxiom.js"; +import { queryAxiom } from "@/external/axiom/queryAxiom.js"; +import { escapeApl } from "@/external/axiom/utils/aplUtils.js"; +import { + axiomNumberFrom, + axiomStringFrom, + getAxiomMatchData, +} from "@/external/axiom/utils/resultUtils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +const attachScopeApl = ({ ctx }: { ctx: AutumnContext }) => + ` +['express'] +| where isnotnull(statusCode) +| where ['context.org_id'] == '${escapeApl(ctx.org.id)}' +| where ['context.env'] == '${ctx.env}' +| where (['req.url'] endswith '/v1/billing.attach' or ['req.url'] endswith '/v1/billing.update' or ['req.url'] endswith '/v1/billing.preview_attach' or ['req.url'] endswith '/v1/billing.preview_update' or ['req.url'] endswith '/v1/attach') +| summarize total=count(), with_entity=countif(isnotempty(tostring(['req.body']['entity_id']))) +| extend entity_ratio = todouble(with_entity) / todouble(total) +`.trim(); + +const entityFeatureApl = ({ ctx }: { ctx: AutumnContext }) => + ` +['express'] +| where isnotnull(statusCode) +| where ['context.org_id'] == '${escapeApl(ctx.org.id)}' +| where ['context.env'] == '${ctx.env}' +| where isnotempty(tostring(['req.body']['entity_id'])) or isnotempty(['context.entity_id']) or isnotempty(['req.entity_id']) +| extend body_feature_id = tostring(['req.body']['feature_id']) +| extend selected_feature_id = case(isnotempty(body_feature_id), body_feature_id, isnotempty(feature_id), feature_id, isnotempty(featureId), featureId, '') +| where isnotempty(selected_feature_id) +| summarize total=count() by selected_feature_id +| top 1 by total +`.trim(); + +export const generateEntityRules = async ({ + ctx, + endTime = "now", + startTime = "now-30d", +}: { + ctx: AutumnContext; + endTime?: string; + startTime?: string; +}): Promise<{ + entityRules: EntityRules; + metadata: Record; + unconfigured?: boolean; +}> => { + if (!isAxiomConfigured()) { + const defaults = AgentRulesSchema.parse({ + credit_rules: {}, + entity_rules: {}, + notes: "", + }); + return { + entityRules: defaults.entity_rules, + metadata: { generated_from: "axiom", reason: "axiom_not_configured" }, + unconfigured: true, + }; + } + + const [attachScopeResult, entityFeatureResult] = await Promise.all([ + queryAxiom({ + apl: attachScopeApl({ ctx }), + options: { endTime, startTime }, + }), + queryAxiom({ + apl: entityFeatureApl({ ctx }), + options: { endTime, startTime }, + }), + ]); + + const attachScope = getAxiomMatchData(attachScopeResult)[0] ?? {}; + const totalAttachCalls = axiomNumberFrom(attachScope.total); + const entityAttachCalls = axiomNumberFrom(attachScope.with_entity); + const entityRatio = + totalAttachCalls > 0 ? entityAttachCalls / totalAttachCalls : 0; + + const entityFeature = getAxiomMatchData(entityFeatureResult)[0] ?? {}; + const attachToEntities = entityRatio > 0.5; + const entityRules = { + attach_to_entities: attachToEntities, + entity_feature_id: attachToEntities + ? axiomStringFrom(entityFeature.selected_feature_id) + : "", + } satisfies EntityRules; + + return { + entityRules, + metadata: { + attach_scope: { + entity_calls: entityAttachCalls, + ratio: entityRatio, + total_calls: totalAttachCalls, + }, + env: ctx.env, + generated_from: "axiom", + time_range: { endTime, startTime }, + top_entity_feature_id: entityRules.entity_feature_id, + }, + }; +}; diff --git a/server/src/internal/analytics/actions/aggregate.ts b/server/src/internal/analytics/actions/aggregate.ts index 6c93450e7..56752befd 100644 --- a/server/src/internal/analytics/actions/aggregate.ts +++ b/server/src/internal/analytics/actions/aggregate.ts @@ -5,6 +5,7 @@ import { type ClickHouseResult, type TimeseriesEventsParams, } from "@autumn/shared"; +import { TZDate } from "@date-fns/tz"; import { UTCDate } from "@date-fns/utc"; import { addDays, addHours, addMonths, format, sub } from "date-fns"; import { Decimal } from "decimal.js"; @@ -117,22 +118,25 @@ const calculateDateRange = async ({ }; }; -/** Generates all periods between start and end dates based on bin size */ -const generateAllPeriods = ({ +// Grid must match the pipe's buckets for the string join: hour buckets are UTC, +// day/month buckets are in the viewer's timezone. startDate/endDate are UTC. +export const generateAllPeriods = ({ startDate, endDate, binSize, + timezone, }: { startDate: string; endDate: string; binSize: string; + timezone?: string; }): string[] => { const periods: string[] = []; - let current = new UTCDate(startDate); - const end = new UTCDate(endDate); - // Truncate to bin start + // Hour buckets stay on UTC to match the pipe's raw `hour` column. if (binSize === "hour") { + const end = new UTCDate(endDate); + let current = new UTCDate(startDate); current = new UTCDate( current.getFullYear(), current.getMonth(), @@ -142,26 +146,36 @@ const generateAllPeriods = ({ 0, 0, ); - } else if (binSize === "month") { - current = new UTCDate(current.getFullYear(), current.getMonth(), 1); - } else { - // day - current = new UTCDate( - current.getFullYear(), - current.getMonth(), - current.getDate(), - ); + while (current <= end) { + periods.push(format(current, "yyyy-MM-dd HH:mm:ss")); + current = addHours(current, 1); + } + return periods; } - while (current <= end) { + // Day/month: build the grid in the viewer's zone ("UTC" = old behavior). + const tz = timezone ?? "UTC"; + const startInViewerTz = new TZDate(new UTCDate(startDate).getTime(), tz); + const end = new TZDate(new UTCDate(endDate).getTime(), tz); + + let current = + binSize === "month" + ? new TZDate( + startInViewerTz.getFullYear(), + startInViewerTz.getMonth(), + 1, + tz, + ) + : new TZDate( + startInViewerTz.getFullYear(), + startInViewerTz.getMonth(), + startInViewerTz.getDate(), + tz, + ); + + while (current.getTime() <= end.getTime()) { periods.push(format(current, "yyyy-MM-dd HH:mm:ss")); - if (binSize === "hour") { - current = addHours(current, 1); - } else if (binSize === "month") { - current = addMonths(current, 1); - } else { - current = addDays(current, 1); - } + current = binSize === "month" ? addMonths(current, 1) : addDays(current, 1); } return periods; @@ -186,6 +200,7 @@ const formatSimpleResults = ({ startDate, endDate, binSize, + timezone, }: { rows: AggregateSimplePipeRow[]; eventNames: string[]; @@ -193,8 +208,14 @@ const formatSimpleResults = ({ startDate: string; endDate: string; binSize: string; + timezone?: string; }): ClickHouseResult => { - const allPeriods = generateAllPeriods({ startDate, endDate, binSize }); + const allPeriods = generateAllPeriods({ + startDate, + endDate, + binSize, + timezone, + }); // Initialize with all periods and all event columns set to 0 const periodMap = new Map>(); @@ -245,6 +266,7 @@ const formatGroupableResults = ({ startDate, endDate, binSize, + timezone, }: { rows: AggregateGroupablePipeRow[]; eventNames: string[]; @@ -253,9 +275,15 @@ const formatGroupableResults = ({ startDate: string; endDate: string; binSize: string; + timezone?: string; maxGroups?: number; }): ClickHouseResult => { - const allPeriods = generateAllPeriods({ startDate, endDate, binSize }); + const allPeriods = generateAllPeriods({ + startDate, + endDate, + binSize, + timezone, + }); const groupByColumn = groupBy; // Collect all unique group values across all bins (for backfilling zeros). @@ -428,6 +456,7 @@ export const aggregate = async ({ startDate, endDate, binSize, + timezone, maxGroups: params.max_groups, }); } else { @@ -454,6 +483,7 @@ export const aggregate = async ({ startDate, endDate, binSize, + timezone, }); } diff --git a/server/src/internal/analytics/actions/getCountAndSum.ts b/server/src/internal/analytics/actions/getCountAndSum.ts index 8040554a5..92e345e98 100644 --- a/server/src/internal/analytics/actions/getCountAndSum.ts +++ b/server/src/internal/analytics/actions/getCountAndSum.ts @@ -108,24 +108,35 @@ export const getCountAndSum = async ({ } } - const query = ` - WITH customer_events AS ( - SELECT * + const hasFilters = filterBySql !== ""; + + // Org-grain rollup only carries org/env/event/hour, so it can serve only all-customers, + // no-entity, no-property totals. Mirrors aggregate_simple's gate (absence of customer_id). + const useOrgRollup = !params.customer_id && !params.entity_id && !hasFilters; + + // No property filters → read a pre-aggregated rollup (avoids a full raw events scan). + // Property filters → fall back to raw events, where the properties columns live. + const query = hasFilters + ? ` + SELECT event_name, COUNT(*) as count, SUM(coalesce(value, 1)) as sum FROM events WHERE org_id = {org_id:String} AND env = {env:String} - ${params.aggregateAll ? "" : "AND customer_id = {customer_id:String}"} - ${params.entity_id ? "AND entity_id = {entity_id:String}" : ""}${filterBySql} - ) - SELECT - e.event_name, - COUNT(*) as count, - SUM(e.value) as sum - FROM customer_events e - WHERE e.timestamp >= {start_date:DateTime} - AND e.timestamp <= {end_date:DateTime} - AND e.event_name IN {event_names:Array(String)} - GROUP BY e.event_name - `; + ${params.aggregateAll ? "" : "AND customer_id = {customer_id:String}"} + ${params.entity_id ? "AND entity_id = {entity_id:String}" : ""}${filterBySql} + AND timestamp >= {start_date:DateTime} AND timestamp <= {end_date:DateTime} + AND event_name IN {event_names:Array(String)} + GROUP BY event_name + ` + : ` + SELECT event_name, sum(event_count) as count, sum(total_value) as sum + FROM ${useOrgRollup ? "events_org_hourly_mv" : "events_hourly_no_properties_two_mv"} + WHERE org_id = {org_id:String} AND env = {env:String} + ${!useOrgRollup && !params.aggregateAll ? "AND customer_id = {customer_id:String}" : ""} + ${!useOrgRollup && params.entity_id ? "AND entity_id = {entity_id:String}" : ""} + AND hour >= {start_date:DateTime} AND hour <= {end_date:DateTime} + AND event_name IN {event_names:Array(String)} + GROUP BY event_name + `; ctx.logger.debug("Getting count and sum", { eventNames: params.event_names, diff --git a/server/src/internal/analytics/actions/listRawEvents.ts b/server/src/internal/analytics/actions/listRawEvents.ts index 947013dbc..bdb832ba5 100644 --- a/server/src/internal/analytics/actions/listRawEvents.ts +++ b/server/src/internal/analytics/actions/listRawEvents.ts @@ -4,6 +4,7 @@ import type { FullCustomer, RawEventFromClickHouse, } from "@autumn/shared"; +import { UTCDate } from "@date-fns/utc"; import { getTinybirdPipes, type ListEventsPaginatedPipeRow, @@ -66,9 +67,11 @@ export type ListRawEventsParams = { customer_id?: string; entity_id?: string; interval?: string; + custom_range?: { start: number; end: number }; customer?: FullCustomer; aggregateAll?: boolean; event_name?: string; + event_names?: string[]; limit?: number; }; @@ -100,16 +103,28 @@ export const listRawEvents = async ({ // Calculate date range const startDate = calculateStartDateFromInterval(intervalType); - const finalStartDate = - isBillingCycle && billingCycleResult?.startDate + const finalStartDate = params.custom_range + ? formatJsDateToClickHouseDateTime(new UTCDate(params.custom_range.start)) + : isBillingCycle && billingCycleResult?.startDate ? billingCycleResult.startDate : formatJsDateToClickHouseDateTime(startDate); - const finalEndDate = - isBillingCycle && billingCycleResult?.endDate + const finalEndDate = params.custom_range + ? formatJsDateToClickHouseDateTime(new UTCDate(params.custom_range.end)) + : isBillingCycle && billingCycleResult?.endDate ? billingCycleResult.endDate : formatJsDateToClickHouseDateTime(new Date()); + const eventNameFilter = (() => { + if (params.event_names && params.event_names.length > 0) { + return params.event_names; + } + if (params.event_name) { + return [params.event_name]; + } + return undefined; + })(); + const pipeParams = { org_id: org.id, env, @@ -117,7 +132,7 @@ export const listRawEvents = async ({ end_date: finalEndDate, customer_id: params.aggregateAll ? undefined : params.customer_id, entity_id: params.entity_id, - event_names: params.event_name ? [params.event_name] : undefined, + event_names: eventNameFilter, limit: params.limit ?? DEFAULT_LIMIT, offset: 0, }; diff --git a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts index e7ea75a10..90298afd4 100644 --- a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts @@ -28,6 +28,12 @@ const STANDARD_INTERVAL_DAYS: Record = { const InternalAggregateEventsSchema = z.object({ interval: z.string().nullish(), + custom_range: z + .object({ start: z.number(), end: z.number() }) + .refine((range) => range.start < range.end, { + message: "custom_range.start must be before custom_range.end", + }) + .optional(), event_names: z.array(z.string()), customer_id: z.string().optional(), entity_id: z.string().optional(), @@ -49,6 +55,7 @@ export const handleInternalAggregateEvents = createRoute({ const { db, org, env, features } = ctx; const { interval, + custom_range, customer_id, entity_id, group_by, @@ -113,6 +120,7 @@ export const handleInternalAggregateEvents = createRoute({ : undefined; const now = new UTCDate(); const customRange = (() => { + if (custom_range) return custom_range; if (standardIntervalDays === undefined) return undefined; const unaligned = sub(now, { days: standardIntervalDays }); const aligned = diff --git a/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts index ec44a531d..e69883218 100644 --- a/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts @@ -7,6 +7,13 @@ import { eventActions } from "../actions/eventActions.js"; const InternalListRawEventsSchema = z.object({ interval: z.string().nullish(), + custom_range: z + .object({ start: z.number(), end: z.number() }) + .refine((range) => range.start < range.end, { + message: "custom_range.start must be before custom_range.end", + }) + .optional(), + event_names: z.array(z.string()).optional(), customer_id: z.string().nullish(), entity_id: z.string().optional(), }); @@ -20,7 +27,8 @@ export const handleInternalListRawEvents = createRoute({ handler: async (c) => { const ctx = c.get("ctx"); const { db, org, env } = ctx; - const { interval, customer_id, entity_id } = c.req.valid("json"); + const { interval, custom_range, event_names, customer_id, entity_id } = + c.req.valid("json"); let aggregateAll = false; let customer: FullCustomer | undefined; @@ -52,6 +60,8 @@ export const handleInternalListRawEvents = createRoute({ customer_id: customer?.id ?? undefined, entity_id: entity_id, interval: interval ?? undefined, + custom_range: custom_range ?? undefined, + event_names: event_names?.filter((name) => name !== ""), customer, aggregateAll, }, diff --git a/server/src/internal/auth/actions/index.ts b/server/src/internal/auth/actions/index.ts new file mode 100644 index 000000000..ce439fdf0 --- /dev/null +++ b/server/src/internal/auth/actions/index.ts @@ -0,0 +1,4 @@ +export { + isSafeOAuthRedirectUri, + registerMcpOAuthClient, +} from "./registerMcpOAuthClient.js"; diff --git a/server/src/internal/auth/actions/registerMcpOAuthClient.ts b/server/src/internal/auth/actions/registerMcpOAuthClient.ts new file mode 100644 index 000000000..ef59f4c36 --- /dev/null +++ b/server/src/internal/auth/actions/registerMcpOAuthClient.ts @@ -0,0 +1,329 @@ +import { + getDefaultOAuthScopes, + MCP_CLIENT_KIND, + MCP_OAUTH_CLIENTS, + type MpcClientInfo, + type MpcClientType, +} from "@autumn/auth/oauth"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { generateId } from "@/utils/genUtils.js"; +import { type OAuthClientRecord, oauthClientRepo } from "../repos/index.js"; + +const REGISTER_CACHE_TTL_MS = 5 * 60 * 1000; +const DANGEROUS_REDIRECT_SCHEMES = new Set([ + "javascript:", + "data:", + "vbscript:", +]); + +type McpMetadata = { + kind?: string; + mcpClientType?: string; + redirectNames?: Record; +}; + +type RegistrationResponse = { + body: { + client_id: string; + client_id_issued_at: number; + client_name: string | null; + redirect_uris: string[]; + scope: string; + token_endpoint_auth_method: "none"; + grant_types: ["authorization_code", "refresh_token"]; + response_types: ["code"]; + public: true; + type: "native"; + }; + status: 200 | 201; +}; + +const registerCache = new Map(); + +const parseMetadata = (metadata: unknown): McpMetadata => { + if (!metadata) return {}; + if (typeof metadata === "string") { + try { + const parsed = JSON.parse(metadata); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } + } + + return typeof metadata === "object" ? metadata : {}; +}; + +const isLocalhost = (hostname: string) => + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + +export const isSafeOAuthRedirectUri = (redirectUri: string) => { + if (!URL.canParse(redirectUri)) return false; + + const url = new URL(redirectUri); + if (DANGEROUS_REDIRECT_SCHEMES.has(url.protocol)) return false; + if (url.protocol === "http:") return isLocalhost(url.hostname); + + return true; +}; + +const normalize = (value: string) => value.trim().toLowerCase(); + +const getMcpClientName = (clientName: unknown) => { + if (typeof clientName !== "string") return "MCP client"; + const trimmed = clientName.trim(); + return trimmed || "MCP client"; +}; + +const classifyMcpClient = ({ + clientName, + redirectUris, +}: { + clientName: unknown; + redirectUris: string[]; +}): MpcClientInfo | null => { + const haystack = [ + typeof clientName === "string" ? clientName : "", + ...redirectUris, + ] + .join(" ") + .toLowerCase(); + + if (haystack.includes("cursor")) { + return MCP_OAUTH_CLIENTS.find((client) => client.type === "cursor") ?? null; + } + if (haystack.includes("claude")) { + return MCP_OAUTH_CLIENTS.find((client) => client.type === "claude") ?? null; + } + if ( + haystack.includes("opencode") || + haystack.includes("open-code") || + haystack.includes("open code") + ) { + return ( + MCP_OAUTH_CLIENTS.find((client) => client.type === "opencode") ?? null + ); + } + if (haystack.includes("codex")) { + return MCP_OAUTH_CLIENTS.find((client) => client.type === "codex") ?? null; + } + if (haystack.includes("slack")) { + return MCP_OAUTH_CLIENTS.find((client) => client.type === "slack") ?? null; + } + + return { + type: "dynamic", + name: getMcpClientName(clientName), + clientId: generateId("oauth_client"), + }; +}; + +export const getRequestedScopesForMcpClient = ({ + clientType: _clientType, + scope, +}: { + clientType: MpcClientType; + scope: unknown; +}) => { + if (typeof scope !== "string" || !scope.trim()) { + return getDefaultOAuthScopes(); + } + return getDefaultOAuthScopes(scope.split(" ")); +}; + +const mergeMetadata = ({ + client, + info, + redirectUris, +}: { + client: OAuthClientRecord | null; + info: MpcClientInfo; + redirectUris: string[]; +}) => { + const existing = parseMetadata(client?.metadata); + const redirectNames = { ...(existing.redirectNames ?? {}) }; + for (const redirectUri of redirectUris) { + redirectNames[redirectUri] = info.name; + } + + return { + ...existing, + kind: MCP_CLIENT_KIND, + mcpClientType: info.type, + redirectNames, + }; +}; + +const clientMatches = ({ + client, + info, + redirectUris, +}: { + client: OAuthClientRecord; + info: MpcClientInfo; + redirectUris: string[]; +}) => { + const metadata = parseMetadata(client.metadata); + if ( + info.type !== "dynamic" && + metadata.kind === MCP_CLIENT_KIND && + metadata.mcpClientType === info.type + ) { + return true; + } + if (client.clientId === info.clientId) return true; + + const requested = new Set(redirectUris); + const hasMatchingRedirectUri = client.redirectUris.some((redirectUri) => + requested.has(redirectUri), + ); + if (!hasMatchingRedirectUri) return false; + + if (normalize(client.name ?? "") === normalize(info.name)) return true; + return ( + classifyMcpClient({ + clientName: client.name, + redirectUris: client.redirectUris, + })?.type === info.type + ); +}; + +const getCachedRegistration = (cacheKey: string) => { + const cached = registerCache.get(cacheKey); + if (!cached || cached.expiresAt < Date.now()) { + registerCache.delete(cacheKey); + return null; + } + + return cached.body; +}; + +const setCachedRegistration = (cacheKey: string, body: unknown) => { + registerCache.set(cacheKey, { + expiresAt: Date.now() + REGISTER_CACHE_TTL_MS, + body, + }); +}; + +const getRegistrationResponse = ( + client: OAuthClientRecord, + status: 200 | 201, +): RegistrationResponse => ({ + body: { + client_id: client.clientId, + client_id_issued_at: client.createdAt + ? Math.floor(client.createdAt.getTime() / 1000) + : Math.floor(Date.now() / 1000), + client_name: client.name, + redirect_uris: client.redirectUris, + scope: client.scopes?.join(" ") ?? "", + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + public: true, + type: "native", + }, + status, +}); + +export const registerMcpOAuthClient = async ({ + db, + clientName, + redirectUris, + scope, +}: { + db: DrizzleCli; + clientName: unknown; + redirectUris: string[]; + scope: unknown; +}): Promise => { + if (redirectUris.length === 0) { + return { error: "redirect_uris is required", status: 400 }; + } + if (!redirectUris.every(isSafeOAuthRedirectUri)) { + return { error: "invalid_redirect_uri", status: 400 }; + } + + const info = classifyMcpClient({ clientName, redirectUris }); + if (!info) { + return { error: "unsupported_mcp_client", status: 400 }; + } + + const requestedScopes = getRequestedScopesForMcpClient({ + clientType: info.type, + scope, + }); + const scopeKey = [...requestedScopes].sort().join(" "); + const cacheKey = `${info.type}:${[...redirectUris].sort().join("|")}:${scopeKey}`; + const cached = getCachedRegistration(cacheKey); + if (cached) + return { body: cached as RegistrationResponse["body"], status: 200 }; + + const clients = await oauthClientRepo.list({ db }); + const existingClient = + clients.find((client) => clientMatches({ client, info, redirectUris })) ?? + null; + const now = new Date(); + + if (existingClient) { + const mergedRedirectUris = [ + ...new Set([...existingClient.redirectUris, ...redirectUris]), + ]; + + const updatedClient = await oauthClientRepo.updateById({ + db, + id: existingClient.id, + updates: { + name: info.name, + redirectUris: mergedRedirectUris, + scopes: requestedScopes, + tokenEndpointAuthMethod: "none", + grantTypes: ["authorization_code", "refresh_token"], + responseTypes: ["code"], + public: true, + type: "native", + metadata: mergeMetadata({ client: existingClient, info, redirectUris }), + updatedAt: now, + }, + }); + + const response = getRegistrationResponse(updatedClient!, 200); + setCachedRegistration(cacheKey, response.body); + return response; + } + + const client = await oauthClientRepo.upsert({ + db, + insert: { + id: generateId("oauth_client"), + clientId: info.clientId, + name: info.name, + redirectUris, + scopes: requestedScopes, + tokenEndpointAuthMethod: "none", + grantTypes: ["authorization_code", "refresh_token"], + responseTypes: ["code"], + public: true, + type: "native", + metadata: mergeMetadata({ client: null, info, redirectUris }), + createdAt: now, + updatedAt: now, + }, + update: { + name: info.name, + redirectUris, + scopes: requestedScopes, + tokenEndpointAuthMethod: "none", + grantTypes: ["authorization_code", "refresh_token"], + responseTypes: ["code"], + public: true, + type: "native", + metadata: mergeMetadata({ client: null, info, redirectUris }), + updatedAt: now, + }, + }); + + const response = getRegistrationResponse(client!, 201); + setCachedRegistration(cacheKey, response.body); + return response; +}; diff --git a/server/src/internal/auth/oauth/atmnOAuthClients.ts b/server/src/internal/auth/oauth/atmnOAuthClients.ts new file mode 100644 index 000000000..40673e395 --- /dev/null +++ b/server/src/internal/auth/oauth/atmnOAuthClients.ts @@ -0,0 +1,63 @@ +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { oauthClientRepo } from "../repos/index.js"; + +const ATMN_OAUTH_CLIENT_NAMES = new Set(["atmn", "autumn cli"]); + +const configuredAtmnClientIds = () => + new Set( + (process.env.ATMN_OAUTH_CLIENT_IDS ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean), + ); + +const metadataMarksAtmn = (metadata: unknown) => { + if (!metadata) return false; + let metadataObject = metadata; + if (typeof metadata === "string") { + try { + metadataObject = JSON.parse(metadata); + } catch { + return false; + } + } + + if (!metadataObject || typeof metadataObject !== "object") return false; + + const metadataRecord = metadataObject as Record; + return ( + metadataRecord.kind === "atmn" || + metadataRecord.client === "atmn" || + metadataRecord.clientType === "atmn" || + metadataRecord.client_type === "atmn" || + metadataRecord.source === "autumn-cli" + ); +}; + +export const isAtmnOAuthClientRecord = ({ + clientId, + name, + metadata, +}: { + clientId: string | null | undefined; + name: string | null | undefined; + metadata?: unknown; +}) => { + if (clientId && configuredAtmnClientIds().has(clientId)) return true; + if (metadataMarksAtmn(metadata)) return true; + + const normalizedName = name?.trim().toLowerCase(); + return !!normalizedName && ATMN_OAUTH_CLIENT_NAMES.has(normalizedName); +}; + +export const isAtmnOAuthClientId = async ({ + db, + clientId, +}: { + db: DrizzleCli; + clientId: string; +}) => { + const client = await oauthClientRepo.getByClientId({ db, clientId }); + + return isAtmnOAuthClientRecord(client ?? { clientId, name: null }); +}; diff --git a/server/src/internal/auth/oauth/handleGetOAuthClient.ts b/server/src/internal/auth/oauth/handleGetOAuthClient.ts new file mode 100644 index 000000000..57d21503a --- /dev/null +++ b/server/src/internal/auth/oauth/handleGetOAuthClient.ts @@ -0,0 +1,37 @@ +import type { Context } from "hono"; +import { db } from "@/db/initDrizzle.js"; +import { oauthClientRepo } from "../repos/index.js"; +import { isAtmnOAuthClientRecord } from "./atmnOAuthClients.js"; +import { + getInternalMcpDisplayName, + isInternalMcpOAuthClientRecord, +} from "./internalMcpOAuthClients.js"; + +export const handleGetOAuthClient = async (c: Context) => { + const clientId = c.req.param("client_id"); + const redirectUri = c.req.query("redirect_uri"); + if (!clientId) { + return c.json({ error: "client_id is required" }, 400); + } + + const client = await oauthClientRepo.getByClientId({ db, clientId }); + + if (!client) { + return c.json({ error: "Client not found" }, 404); + } + + const isInternalMcp = isInternalMcpOAuthClientRecord(client); + const internalMcpName = isInternalMcp + ? getInternalMcpDisplayName({ + metadata: client.metadata, + redirectUri, + }) + : null; + + return c.json({ + client_id: client.clientId, + name: internalMcpName || client.name || "Unknown Application", + is_atmn: isAtmnOAuthClientRecord(client), + is_internal_mcp: isInternalMcp, + }); +}; diff --git a/server/src/internal/auth/oauth/handleMcpOAuthRegistration.ts b/server/src/internal/auth/oauth/handleMcpOAuthRegistration.ts new file mode 100644 index 000000000..f6abd4b61 --- /dev/null +++ b/server/src/internal/auth/oauth/handleMcpOAuthRegistration.ts @@ -0,0 +1,35 @@ +import type { Context } from "hono"; +import { db } from "@/db/initDrizzle.js"; +import { registerMcpOAuthClient } from "../actions/index.js"; + +type RegisterBody = { + redirect_uris?: unknown; + client_name?: unknown; + scope?: unknown; +}; + +const parseJsonObject = async (request: Request) => { + const body = await request.json().catch(() => null); + return body && typeof body === "object" ? (body as RegisterBody) : {}; +}; + +const getRedirectUris = (value: unknown) => + Array.isArray(value) + ? value.filter((uri): uri is string => typeof uri === "string" && !!uri) + : []; + +export const handleMcpOAuthRegistration = async (c: Context) => { + const body = await parseJsonObject(c.req.raw); + const result = await registerMcpOAuthClient({ + db, + clientName: body.client_name, + redirectUris: getRedirectUris(body.redirect_uris), + scope: body.scope, + }); + + if ("error" in result) { + return c.json({ error: result.error }, result.status); + } + + return c.json(result.body, result.status); +}; diff --git a/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts new file mode 100644 index 000000000..5bff44cbb --- /dev/null +++ b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts @@ -0,0 +1,188 @@ +import { AppEnv, RecaseError } from "@autumn/shared"; +import type { Context } from "hono"; +import { db } from "@/db/initDrizzle.js"; +import { auth } from "@/utils/auth.js"; +import { oauthConsentRepo } from "../repos/index.js"; +import { isAtmnOAuthClientId } from "./atmnOAuthClients.js"; +import { getOAuthConsentScopeGrant } from "./oauthConsentScopes.js"; + +type RequestFields = Record; + +const parseRequestFields = async (request: Request) => { + const contentType = request.headers.get("content-type") ?? ""; + const rawBody = await request.text(); + if (!rawBody) return { contentType, fields: {}, rawBody }; + + if (contentType.includes("application/json")) { + try { + const body = JSON.parse(rawBody); + return { + contentType, + fields: body && typeof body === "object" ? (body as RequestFields) : {}, + rawBody, + }; + } catch { + return { contentType, fields: {}, rawBody }; + } + } + + const params = new URLSearchParams(rawBody); + return { contentType, fields: Object.fromEntries(params.entries()), rawBody }; +}; + +const getString = (value: unknown) => + typeof value === "string" && value.length > 0 ? value : null; + +const parseEnv = (value: unknown) => { + if (value === AppEnv.Live || value === AppEnv.Sandbox) return value; + return null; +}; + +const acceptedConsent = (value: unknown) => value === true || value === "true"; + +const getNestedOAuthField = (value: unknown, key: string) => { + if (!value) return null; + + if (typeof value === "string") { + try { + return getString(JSON.parse(value)?.[key]); + } catch { + return new URLSearchParams(value).get(key); + } + } + + if (typeof value === "object") { + return getString((value as Record)[key]); + } + + return null; +}; + +const getClientIdFromFields = (fields: RequestFields) => + getString(fields.client_id) ?? + getNestedOAuthField(fields.oauth_query, "client_id"); + +const getRedirectUriFromFields = (fields: RequestFields) => + getString(fields.redirect_uri) ?? + getString(fields.redirectUri) ?? + getNestedOAuthField(fields.oauth_query, "redirect_uri"); + +const getScopesFromFields = (fields: RequestFields) => { + const rawScope = getNestedOAuthField(fields.oauth_query, "scope"); + return rawScope?.split(/\s+/).filter(Boolean) ?? null; +}; + +const getFieldsWithScope = ({ + fields, + scope, +}: { + fields: RequestFields; + scope: string; +}) => { + return { ...fields, scope }; +}; + +const withScope = ({ + contentType, + request, + fields, + scope, +}: { + contentType: string; + request: Request; + fields: RequestFields; + scope: string; +}) => { + const scopedFields = getFieldsWithScope({ fields, scope }); + if (contentType.includes("application/json")) { + return new Request(request, { + body: JSON.stringify(scopedFields), + }); + } + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(scopedFields)) { + if (typeof value === "string") params.set(key, value); + } + + return new Request(request, { body: params }); +}; + +const jsonOAuthError = ({ error }: { error: RecaseError }) => + new Response( + JSON.stringify({ + error: "invalid_scope", + error_description: error.message, + }), + { + status: error.statusCode, + headers: { "Content-Type": "application/json" }, + }, + ); + +export const handleOAuthConsentWithEnv = async (c: Context) => { + const { contentType, fields } = await parseRequestFields(c.req.raw.clone()); + const clientId = getClientIdFromFields(fields); + const redirectUri = getRedirectUriFromFields(fields); + const env = parseEnv(fields.env); + + let request = c.req.raw; + let grantedScopes: string[] | undefined; + if (acceptedConsent(fields.accept) && clientId) { + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + const userId = session?.user?.id; + const orgId = session?.session?.activeOrganizationId; + if (userId && orgId) { + try { + const scopeGrant = await getOAuthConsentScopeGrant({ + db, + organizationId: orgId, + requestedScopes: getScopesFromFields(fields), + userId, + }); + grantedScopes = scopeGrant; + request = withScope({ + contentType, + request, + fields, + scope: scopeGrant.join(" "), + }); + } catch (error) { + if (error instanceof RecaseError) { + return jsonOAuthError({ error }); + } + throw error; + } + } + } + + const response = await auth.handler(request); + + if (!response.ok || !acceptedConsent(fields.accept)) { + return response; + } + + if (!clientId || !env || (await isAtmnOAuthClientId({ db, clientId }))) { + return response; + } + + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + const userId = session?.user?.id; + const orgId = session?.session?.activeOrganizationId; + if (!userId || !orgId) return response; + + await oauthConsentRepo.updateEnv({ + db, + clientId, + userId, + referenceId: orgId, + env, + redirectUri, + scopes: grantedScopes, + }); + + return response; +}; diff --git a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts new file mode 100644 index 000000000..7b933a553 --- /dev/null +++ b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts @@ -0,0 +1,210 @@ +import { prefixOAuthToken } from "@autumn/auth"; +import { + getResourceFromOAuthTokenRequest, + returnsOAuthAccessTokenForClientId, +} from "@autumn/auth/oauth"; +import { ErrCode, RecaseError } from "@autumn/shared"; +import type { Context } from "hono"; +import { db } from "@/db/initDrizzle.js"; +import { auth } from "@/utils/auth.js"; +import { oauthAccessTokenRepo, oauthRefreshTokenRepo } from "../repos/index.js"; +import { isMcpOAuthClient } from "./mcpOAuthScopes.js"; +import { + getExternalOAuthApiKeyForToken, + getOAuthAccessTokenRecord, + scopesFromOAuthScopeString, +} from "./oauthAccessTokenApiKey.js"; +import { getOAuthConsentScopeGrant } from "./oauthConsentScopes.js"; + +const getString = (value: unknown) => + typeof value === "string" && value.length > 0 ? value : null; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const getTokenPayload = (body: Record) => { + const response = body.response; + if (isRecord(response)) return response; + return body; +}; + +const rewriteTokenBody = ({ + apiKey, + body, + scopes, +}: { + apiKey: string; + body: Record; + scopes: string[]; +}) => { + const response = body.response; + if (isRecord(response)) { + return { + ...body, + response: { + ...response, + access_token: apiKey, + scope: scopes.join(" "), + }, + }; + } + + return { + ...body, + access_token: apiKey, + scope: scopes.join(" "), + }; +}; + +const rewriteOAuthAccessTokenBody = ({ + accessToken, + body, + scopes, +}: { + accessToken: string; + body: Record; + scopes: string[]; +}) => { + const response = body.response; + if (isRecord(response)) { + return { + ...body, + response: { + ...response, + access_token: accessToken, + scope: scopes.join(" "), + }, + }; + } + + return { + ...body, + access_token: accessToken, + scope: scopes.join(" "), + }; +}; + +const tokenResponseHeaders = (response?: Response) => { + const headers = new Headers(response?.headers); + headers.set("Content-Type", "application/json"); + headers.set("Cache-Control", "no-store"); + headers.set("Pragma", "no-cache"); + headers.delete("Content-Length"); + return headers; +}; + +const jsonTokenResponse = ({ + body, + response, + status, +}: { + body: unknown; + response?: Response; + status: number; +}) => + new Response(JSON.stringify(body), { + status, + headers: tokenResponseHeaders(response), + }); + +export const handleOAuthTokenWithApiKey = async (c: Context) => { + const resource = await getResourceFromOAuthTokenRequest(c.req.raw.clone()); + const response = await auth.handler(c.req.raw); + if (!response.ok) return response; + + let body: Record; + try { + body = (await response.clone().json()) as Record; + } catch { + return response; + } + + const tokenPayload = getTokenPayload(body); + const accessToken = getString(tokenPayload.access_token); + if (!accessToken) return response; + + const requestedScopes = scopesFromOAuthScopeString(tokenPayload.scope); + let apiKeyResult: Awaited>; + try { + const tokenRecord = await getOAuthAccessTokenRecord({ + db, + accessToken, + resource, + requestedScopes, + }); + if (tokenRecord.scopes.length === 0) { + throw new RecaseError({ + message: "OAuth token has no scopes", + code: ErrCode.InvalidRequest, + statusCode: 401, + }); + } + const issuedScopes = await getOAuthConsentScopeGrant({ + db, + organizationId: tokenRecord.referenceId, + requestedScopes: tokenRecord.scopes, + userId: tokenRecord.userId, + }); + tokenRecord.scopes = issuedScopes; + if (tokenRecord.id) { + await oauthAccessTokenRepo.updateScopes({ + db, + id: tokenRecord.id, + scopes: issuedScopes, + }); + } + if (tokenRecord.refreshId) { + await oauthRefreshTokenRepo.updateScopes({ + db, + id: tokenRecord.refreshId, + scopes: issuedScopes, + }); + } + const isMcpClient = await isMcpOAuthClient({ + clientId: tokenRecord.clientId, + db, + resource: resource ?? undefined, + }); + if ( + isMcpClient || + returnsOAuthAccessTokenForClientId({ clientId: tokenRecord.clientId }) + ) { + return jsonTokenResponse({ + body: rewriteOAuthAccessTokenBody({ + accessToken: prefixOAuthToken({ token: accessToken }), + body, + scopes: tokenRecord.scopes, + }), + response, + status: response.status, + }); + } + apiKeyResult = await getExternalOAuthApiKeyForToken({ + db, + tokenRecord, + requestedScopes, + }); + } catch (error) { + if (error instanceof RecaseError) { + return jsonTokenResponse({ + body: { + error: "invalid_grant", + error_description: error.message, + }, + status: error.statusCode, + }); + } + throw error; + } + if (!apiKeyResult) return response; + + return jsonTokenResponse({ + body: rewriteTokenBody({ + apiKey: apiKeyResult.apiKey, + body, + scopes: apiKeyResult.scopes, + }), + response, + status: response.status, + }); +}; diff --git a/server/src/internal/auth/oauth/internalMcpOAuthClients.ts b/server/src/internal/auth/oauth/internalMcpOAuthClients.ts new file mode 100644 index 000000000..f3fec8927 --- /dev/null +++ b/server/src/internal/auth/oauth/internalMcpOAuthClients.ts @@ -0,0 +1,103 @@ +import { MCP_CLIENT_KIND } from "@autumn/auth/oauth"; +import type { Context } from "hono"; +import { type DrizzleCli, db } from "@/db/initDrizzle.js"; +import { auth } from "@/utils/auth.js"; +import { oauthClientRepo } from "../repos/index.js"; + +const INTERNAL_MCP_CLIENT_ID = process.env.INTERNAL_MCP_OAUTH_CLIENT_ID; +const INTERNAL_MCP_CLIENT_NAME = "Autumn internal-mcp"; +const INTERNAL_MCP_CLIENT_NAME_NORMALIZED = + INTERNAL_MCP_CLIENT_NAME.toLowerCase(); +const INTERNAL_MCP_KIND = "internal_mcp"; + +type InternalMcpMetadata = { + kind?: string; + mcpClientType?: string; + redirectNames?: Record; +}; + +const parseMetadata = (metadata: unknown): InternalMcpMetadata => { + if (!metadata) return {}; + if (typeof metadata === "string") { + try { + const parsed = JSON.parse(metadata); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } + } + + return typeof metadata === "object" ? metadata : {}; +}; + +const inferClientNameFromRedirectUri = (redirectUri: string) => { + const normalized = redirectUri.toLowerCase(); + if (normalized.includes("cursor")) return "Cursor"; + if (normalized.includes("claude")) return "Claude"; + if (normalized.includes("opencode")) return "OpenCode"; + if (normalized.includes("open-code")) return "OpenCode"; + if (normalized.includes("slack")) return "Slack"; + if (normalized.includes("codex")) return "Codex"; + return "MCP client"; +}; + +export const isInternalMcpOAuthClientRecord = ({ + clientId, + name, + metadata, +}: { + clientId: string | null | undefined; + name: string | null | undefined; + metadata?: unknown; +}) => { + if (INTERNAL_MCP_CLIENT_ID && clientId === INTERNAL_MCP_CLIENT_ID) + return true; + if (name?.trim().toLowerCase() === INTERNAL_MCP_CLIENT_NAME_NORMALIZED) { + return true; + } + const parsedMetadata = parseMetadata(metadata); + return [INTERNAL_MCP_KIND, MCP_CLIENT_KIND].includes( + parsedMetadata.kind ?? "", + ); +}; + +export const getInternalMcpDisplayName = ({ + metadata, + redirectUri, +}: { + metadata: unknown; + redirectUri: string | null | undefined; +}) => { + if (!redirectUri) return null; + const metadataObject = parseMetadata(metadata); + return ( + metadataObject.redirectNames?.[redirectUri] ?? + inferClientNameFromRedirectUri(redirectUri) + ); +}; + +export const isInternalMcpOAuthClientId = async ({ + db, + clientId, +}: { + db: DrizzleCli; + clientId: string; +}) => { + const client = await oauthClientRepo.getByClientId({ db, clientId }); + + return isInternalMcpOAuthClientRecord(client ?? { clientId, name: null }); +}; + +export const handleInternalMcpOAuthAuthorize = async (c: Context) => { + const url = new URL(c.req.raw.url); + const clientId = url.searchParams.get("client_id"); + if (!clientId || !(await isInternalMcpOAuthClientId({ db, clientId }))) { + return auth.handler(c.req.raw); + } + + const prompts = new Set(url.searchParams.get("prompt")?.split(" ") ?? []); + prompts.add("consent"); + url.searchParams.set("prompt", [...prompts].filter(Boolean).join(" ")); + + return auth.handler(new Request(url, c.req.raw)); +}; diff --git a/server/src/internal/auth/oauth/mcpOAuthScopes.ts b/server/src/internal/auth/oauth/mcpOAuthScopes.ts new file mode 100644 index 000000000..3c494d955 --- /dev/null +++ b/server/src/internal/auth/oauth/mcpOAuthScopes.ts @@ -0,0 +1,95 @@ +import { + getDefaultOAuthScopes, + isKnownMcpOAuthClientId, + isMcpOAuthClientRecord, + isMcpOAuthResource, +} from "@autumn/auth/oauth"; +import { ErrCode, isScopeSubset, RecaseError } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js"; +import { oauthClientRepo } from "../repos/index.js"; + +export const isMcpOAuthClient = async ({ + clientId, + db, + resource, +}: { + clientId: string; + db: DrizzleCli; + resource?: string; +}) => { + if (isMcpOAuthResource(resource)) return true; + if (isKnownMcpOAuthClientId({ clientId })) return true; + + const client = await oauthClientRepo.getByClientId({ db, clientId }); + if (!client) return false; + + return isMcpOAuthClientRecord(client); +}; + +export const isMcpOAuthClientId = async ({ + clientId, + ctx, +}: { + clientId: string; + ctx: AutumnContext; +}) => + isMcpOAuthClient({ + clientId, + db: ctx.db, + resource: ctx.oauthResource, + }); + +export const getMcpOAuthScopeGrant = async ({ + clientId, + ctx, + requestedScopes, +}: { + clientId: string; + ctx: AutumnContext; + requestedScopes?: string[] | null; +}) => { + if (!(await isMcpOAuthClientId({ ctx, clientId }))) return null; + + const orgId = ctx.org?.id; + if (!ctx.userId || !orgId) { + throw new RecaseError({ + message: "MCP OAuth scope grant is missing user or organization context", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + const leafScopes = getDefaultOAuthScopes(requestedScopes); + const { scopes: userScopes } = await getScopesForUserInOrg({ + db: ctx.db, + userId: ctx.userId, + organizationId: orgId, + }); + + return leafScopes.filter((scope) => isScopeSubset([scope], userScopes)); +}; + +export const assertMcpOAuthScopeGrant = async ({ + clientId, + ctx, + requestedScopes, +}: { + clientId: string; + ctx: AutumnContext; + requestedScopes?: string[] | null; +}) => { + const scopes = await getMcpOAuthScopeGrant({ + clientId, + ctx, + requestedScopes, + }); + if (!scopes) return null; + if (scopes.length > 0) return scopes; + + throw new RecaseError({ + message: "No requested scopes can be granted to this MCP client", + code: ErrCode.InsufficientScopes, + statusCode: 403, + }); +}; diff --git a/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts b/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts new file mode 100644 index 000000000..cc356406b --- /dev/null +++ b/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts @@ -0,0 +1,176 @@ +import { stripOAuthTokenPrefix } from "@autumn/auth"; +import { + AppEnv, + checkScopes, + ErrCode, + RecaseError, + type ScopeString, +} from "@autumn/shared"; +import { verifyAccessToken } from "better-auth/oauth2"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { + parseRequestedScopes, + type ResourceAccessTokenRecord, + tokenRecordFromResourceToken, +} from "@/internal/dev/cli/oauthApiKeyUtils.js"; +import { hashOAuthToken } from "@/utils/oauthUtils.js"; +import { oauthAccessTokenRepo, oauthConsentRepo } from "../repos/index.js"; +import { isAtmnOAuthClientId } from "./atmnOAuthClients.js"; +import { rotateOAuthConsentApiKey } from "./oauthConsentApiKey.js"; + +const getOAuthIssuer = () => + `${process.env.BETTER_AUTH_URL?.replace(/\/$/, "") ?? ""}/api/auth`; + +const verifyResourceAccessToken = async ({ + accessToken, + resource, + requestedScopes, +}: { + accessToken: string; + resource: string | null; + requestedScopes: ScopeString[] | null; +}) => { + if (!resource) return null; + + const issuer = getOAuthIssuer(); + try { + const payload = await verifyAccessToken(accessToken, { + jwksUrl: `${issuer}/jwks`, + verifyOptions: { + audience: resource, + issuer, + }, + scopes: requestedScopes ?? undefined, + }); + + return tokenRecordFromResourceToken(payload as Record); + } catch { + return null; + } +}; + +export const getOAuthAccessTokenRecord = async ({ + db, + accessToken, + resource, + requestedScopes, +}: { + db: DrizzleCli; + accessToken: string; + resource: string | null; + requestedScopes: ScopeString[] | null; +}) => { + const rawAccessToken = stripOAuthTokenPrefix({ token: accessToken }); + const hashedToken = await hashOAuthToken(rawAccessToken); + const tokenValues = [...new Set([hashedToken, rawAccessToken])]; + const tokenRecord = + (await oauthAccessTokenRepo.getValidByTokenValues({ db, tokenValues })) ?? + (await verifyResourceAccessToken({ + accessToken: rawAccessToken, + resource, + requestedScopes, + })); + + if (!tokenRecord) { + throw new RecaseError({ + message: "Invalid or expired access token", + code: ErrCode.InvalidRequest, + statusCode: 401, + }); + } + + if (requestedScopes) { + const { allowed, missing } = checkScopes( + requestedScopes, + tokenRecord.scopes, + ); + if (!allowed) { + throw new RecaseError({ + message: `Insufficient scopes. Missing: ${missing.join(", ")}`, + code: ErrCode.InsufficientScopes, + statusCode: 403, + }); + } + } + + const userId = tokenRecord.userId; + if (!userId) { + throw new RecaseError({ + message: "Token missing user information", + code: ErrCode.InvalidRequest, + statusCode: 401, + }); + } + + const orgId = tokenRecord.referenceId; + if (!orgId) { + throw new RecaseError({ + message: "No organization found. Please select an organization.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + return tokenRecord as ResourceAccessTokenRecord & { + userId: string; + referenceId: string; + }; +}; + +export const getExternalOAuthApiKeyForToken = async ({ + db, + tokenRecord, + requestedScopes, +}: { + db: DrizzleCli; + tokenRecord: ResourceAccessTokenRecord & { + userId: string; + referenceId: string; + }; + requestedScopes: string[] | null; +}) => { + const isAtmnClient = await isAtmnOAuthClientId({ + db, + clientId: tokenRecord.clientId, + }); + if (isAtmnClient) return null; + + const consent = await oauthConsentRepo.getForClientUserOrg({ + db, + clientId: tokenRecord.clientId, + userId: tokenRecord.userId, + referenceId: tokenRecord.referenceId, + }); + + if (!consent) { + throw new RecaseError({ + message: "OAuth consent not found", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + const env = consent.env ?? AppEnv.Sandbox; + const scopes = requestedScopes ?? tokenRecord.scopes; + const apiKey = await rotateOAuthConsentApiKey({ + db, + consent, + tokenRecord, + env, + scopes, + }); + + return { + apiKey, + env, + orgId: tokenRecord.referenceId, + userId: tokenRecord.userId, + clientId: tokenRecord.clientId, + scopes, + }; +}; + +export const scopesFromOAuthScopeString = (scope: unknown) => { + if (typeof scope !== "string") return null; + return parseRequestedScopes(scope.split(/\s+/).filter(Boolean)); +}; diff --git a/server/src/internal/auth/oauth/oauthConsentApiKey.ts b/server/src/internal/auth/oauth/oauthConsentApiKey.ts new file mode 100644 index 000000000..a9933c016 --- /dev/null +++ b/server/src/internal/auth/oauth/oauthConsentApiKey.ts @@ -0,0 +1,121 @@ +import { AppEnv } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { + ApiKeyPrefix, + createKey, + hashApiKey, +} from "@/internal/dev/api-keys/apiKeyUtils.js"; +import type { ResourceAccessTokenRecord } from "@/internal/dev/cli/oauthApiKeyUtils.js"; +import { + type OAuthConsentApiKeyRecord, + oauthApiKeyRepo, + oauthClientRepo, + oauthConsentRepo, +} from "../repos/index.js"; + +type OAuthApiKeyTokenRecord = ResourceAccessTokenRecord & { + userId: string; + referenceId: string; +}; + +const getOAuthClientApiKeyName = async ({ + db, + clientId, +}: { + db: DrizzleCli; + clientId: string; +}) => { + const client = await oauthClientRepo.getByClientId({ db, clientId }); + + return `OAuth Key - ${client?.name || clientId.slice(0, 8)}`; +}; + +const createConsentApiKey = async ({ + db, + consent, + tokenRecord, + env, + scopes, +}: { + db: DrizzleCli; + consent: OAuthConsentApiKeyRecord; + tokenRecord: OAuthApiKeyTokenRecord; + env: AppEnv; + scopes: string[]; +}) => { + const prefix = env === AppEnv.Live ? ApiKeyPrefix.Live : ApiKeyPrefix.Sandbox; + const keyName = await getOAuthClientApiKeyName({ + db, + clientId: tokenRecord.clientId, + }); + const apiKey = await createKey({ + db, + env, + name: keyName, + orgId: tokenRecord.referenceId, + userId: tokenRecord.userId ?? undefined, + prefix, + meta: { + oauth_consent_id: consent.id, + oauth_client_id: tokenRecord.clientId, + oauth_redirect_uri: consent.redirectUri, + created_via: "oauth", + generatedAt: new Date().toISOString(), + env, + }, + scopes, + }); + + const hashedKey = hashApiKey(apiKey); + const apiKeyId = await oauthApiKeyRepo.getIdByHashedKey({ db, hashedKey }); + if (!apiKeyId) { + throw new Error("OAuth API key was not persisted"); + } + + await oauthConsentRepo.updateApiKey({ + db, + consentId: consent.id, + env, + oauthApiKeyId: apiKeyId, + }); + + return { apiKey, apiKeyId }; +}; + +export const rotateOAuthConsentApiKey = async ({ + db, + consent, + tokenRecord, + env, + scopes, +}: { + db: DrizzleCli; + consent: OAuthConsentApiKeyRecord; + tokenRecord: OAuthApiKeyTokenRecord; + env: AppEnv; + scopes: string[]; +}) => { + const previousApiKeyId = consent.oauthApiKeyId; + const { apiKey, apiKeyId } = await createConsentApiKey({ + db, + consent, + tokenRecord, + env, + scopes, + }); + + if (previousApiKeyId && previousApiKeyId !== apiKeyId) { + await oauthApiKeyRepo.deleteConsentLinked({ + db, + apiKeyId: previousApiKeyId, + consentId: consent.id, + clientId: tokenRecord.clientId, + redirectUri: consent.redirectUri, + orgId: tokenRecord.referenceId, + userId: tokenRecord.userId, + env, + }); + } + + return apiKey; +}; diff --git a/server/src/internal/auth/oauth/oauthConsentScopes.ts b/server/src/internal/auth/oauth/oauthConsentScopes.ts new file mode 100644 index 000000000..eaa21f06d --- /dev/null +++ b/server/src/internal/auth/oauth/oauthConsentScopes.ts @@ -0,0 +1,34 @@ +import { getDefaultOAuthScopes } from "@autumn/auth/oauth"; +import { ErrCode, isScopeSubset, RecaseError } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js"; + +export const getOAuthConsentScopeGrant = async ({ + db, + organizationId, + requestedScopes, + userId, +}: { + db: DrizzleCli; + organizationId: string; + requestedScopes?: string[] | null; + userId: string; +}) => { + const finalRequestedScopes = getDefaultOAuthScopes(requestedScopes); + const { scopes: userScopes } = await getScopesForUserInOrg({ + db, + userId, + organizationId, + }); + + const grant = finalRequestedScopes.filter((scope) => + isScopeSubset([scope], userScopes), + ); + if (grant.length > 0) return grant; + + throw new RecaseError({ + message: "No requested scopes can be granted to this OAuth client", + code: ErrCode.InsufficientScopes, + statusCode: 403, + }); +}; diff --git a/server/src/internal/auth/oauth/oauthRouter.ts b/server/src/internal/auth/oauth/oauthRouter.ts new file mode 100644 index 000000000..6e514e2fa --- /dev/null +++ b/server/src/internal/auth/oauth/oauthRouter.ts @@ -0,0 +1,55 @@ +import { + oauthProviderAuthServerMetadata, + oauthProviderOpenIdConfigMetadata, +} from "@better-auth/oauth-provider"; +import { type Context, Hono } from "hono"; +import { rateLimiter } from "hono-rate-limiter"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { auth } from "@/utils/auth.js"; +import { handleGetOAuthClient } from "./handleGetOAuthClient.js"; +import { handleMcpOAuthRegistration } from "./handleMcpOAuthRegistration.js"; +import { handleOAuthConsentWithEnv } from "./handleOAuthConsentWithEnv.js"; +import { handleOAuthTokenWithApiKey } from "./handleOAuthTokenWithApiKey.js"; +import { handleInternalMcpOAuthAuthorize } from "./internalMcpOAuthClients.js"; + +export const oauthRouter = new Hono(); + +const getClientLookupRateLimitKey = (c: Context) => + c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? + c.req.header("x-real-ip") ?? + c.req.header("cf-connecting-ip") ?? + "unknown"; + +const oauthClientLookupLimiter = rateLimiter({ + windowMs: 60 * 1000, + limit: process.env.NODE_ENV === "development" ? 1000 : 60, + standardHeaders: "draft-6", + keyGenerator: getClientLookupRateLimitKey, +}); + +oauthRouter.get("/api/auth/.well-known/openid-configuration", (c) => { + return oauthProviderOpenIdConfigMetadata(auth)(c.req.raw); +}); + +oauthRouter.get("/.well-known/oauth-authorization-server", (c) => { + return oauthProviderAuthServerMetadata(auth)(c.req.raw); +}); + +oauthRouter.get("/api/auth/.well-known/oauth-authorization-server", (c) => { + return oauthProviderAuthServerMetadata(auth)(c.req.raw); +}); + +oauthRouter.get("/.well-known/oauth-authorization-server/api/auth", (c) => { + return oauthProviderAuthServerMetadata(auth)(c.req.raw); +}); + +oauthRouter.post("/api/auth/oauth2/consent", handleOAuthConsentWithEnv); +oauthRouter.post("/api/auth/oauth2/token", handleOAuthTokenWithApiKey); +oauthRouter.get("/api/auth/oauth2/authorize", handleInternalMcpOAuthAuthorize); +oauthRouter.post("/api/auth/oauth2/register", handleMcpOAuthRegistration); + +oauthRouter.get( + "/oauth/client/:client_id", + oauthClientLookupLimiter, + handleGetOAuthClient, +); diff --git a/server/src/internal/auth/repos/index.ts b/server/src/internal/auth/repos/index.ts new file mode 100644 index 000000000..39aa72a56 --- /dev/null +++ b/server/src/internal/auth/repos/index.ts @@ -0,0 +1,11 @@ +export { oauthAccessTokenRepo } from "./oauthAccessTokenRepo.js"; +export { oauthApiKeyRepo } from "./oauthApiKeyRepo.js"; +export { + type OAuthClientRecord, + oauthClientRepo, +} from "./oauthClientRepo.js"; +export { + type OAuthConsentApiKeyRecord, + oauthConsentRepo, +} from "./oauthConsentRepo.js"; +export { oauthRefreshTokenRepo } from "./oauthRefreshTokenRepo.js"; diff --git a/server/src/internal/auth/repos/oauthAccessTokenRepo.ts b/server/src/internal/auth/repos/oauthAccessTokenRepo.ts new file mode 100644 index 000000000..51772147b --- /dev/null +++ b/server/src/internal/auth/repos/oauthAccessTokenRepo.ts @@ -0,0 +1,64 @@ +import { oauthAccessToken } from "@autumn/shared"; +import { and, eq, gt, inArray, isNull } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; + +export const getValidOAuthAccessTokenByTokenValues = async ({ + db, + tokenValues, +}: { + db: DrizzleCli; + tokenValues: string[]; +}) => { + const [token] = await db + .select() + .from(oauthAccessToken) + .where( + and( + inArray(oauthAccessToken.token, tokenValues), + gt(oauthAccessToken.expiresAt, new Date()), + ), + ) + .limit(1); + + return token ?? null; +}; + +export const deleteOAuthAccessTokensByClientAndReference = async ({ + db, + clientId, + referenceId, +}: { + db: DrizzleCli; + clientId: string; + referenceId: string | null; +}) => + db + .delete(oauthAccessToken) + .where( + and( + eq(oauthAccessToken.clientId, clientId), + referenceId + ? eq(oauthAccessToken.referenceId, referenceId) + : isNull(oauthAccessToken.referenceId), + ), + ); + +export const updateOAuthAccessTokenScopes = async ({ + db, + id, + scopes, +}: { + db: DrizzleCli; + id: string; + scopes: string[]; +}) => + db + .update(oauthAccessToken) + .set({ scopes }) + .where(eq(oauthAccessToken.id, id)); + +export const oauthAccessTokenRepo = { + getValidByTokenValues: getValidOAuthAccessTokenByTokenValues, + deleteByClientAndReference: deleteOAuthAccessTokensByClientAndReference, + updateScopes: updateOAuthAccessTokenScopes, +}; diff --git a/server/src/internal/auth/repos/oauthApiKeyRepo.ts b/server/src/internal/auth/repos/oauthApiKeyRepo.ts new file mode 100644 index 000000000..dd74ba4e2 --- /dev/null +++ b/server/src/internal/auth/repos/oauthApiKeyRepo.ts @@ -0,0 +1,156 @@ +import { type AppEnv, apiKeys } from "@autumn/shared"; +import { eq, sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { clearSecretKeyCache } from "@/internal/dev/api-keys/cacheApiKeyUtils.js"; + +type OAuthApiKeyRecord = { + id: string; + orgId: string | null; + userId: string | null; + env: string | null; + hashedKey: string | null; + meta: unknown; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const isOAuthConsentLinkedApiKey = ({ + apiKey, + consentId, + clientId, + redirectUri, + orgId, + userId, + env, +}: { + apiKey: OAuthApiKeyRecord; + consentId: string; + clientId: string; + redirectUri: string | null; + orgId: string; + userId: string; + env: AppEnv; +}) => { + if ( + apiKey.orgId !== orgId || + apiKey.userId !== userId || + apiKey.env !== env || + !isRecord(apiKey.meta) + ) { + return false; + } + + return ( + apiKey.meta.created_via === "oauth" && + apiKey.meta.oauth_consent_id === consentId && + apiKey.meta.oauth_client_id === clientId && + apiKey.meta.oauth_redirect_uri === redirectUri && + apiKey.meta.env === env + ); +}; + +export const deleteOAuthConsentLinkedApiKey = async ({ + db, + apiKeyId, + consentId, + clientId, + redirectUri, + orgId, + userId, + env, +}: { + db: DrizzleCli; + apiKeyId: string; + consentId: string; + clientId: string; + redirectUri: string | null; + orgId: string; + userId: string; + env: AppEnv; +}) => { + const [apiKey] = await db + .select({ + id: apiKeys.id, + orgId: apiKeys.org_id, + userId: apiKeys.user_id, + env: apiKeys.env, + hashedKey: apiKeys.hashed_key, + meta: apiKeys.meta, + }) + .from(apiKeys) + .where(eq(apiKeys.id, apiKeyId)) + .limit(1); + + if (!apiKey) return { deleted: false, reason: "not_found" as const }; + + if ( + !isOAuthConsentLinkedApiKey({ + apiKey, + consentId, + clientId, + redirectUri, + orgId, + userId, + env, + }) + ) { + return { deleted: false, reason: "guard_failed" as const }; + } + + await db.delete(apiKeys).where(eq(apiKeys.id, apiKeyId)); + + if (apiKey.hashedKey) + await clearSecretKeyCache({ hashedKey: apiKey.hashedKey }); + + return { deleted: true, reason: null }; +}; + +export const listOAuthApiKeysByConsentId = async ({ + db, + consentId, +}: { + db: DrizzleCli; + consentId: string; +}) => + db + .select({ + id: apiKeys.id, + prefix: apiKeys.prefix, + env: apiKeys.env, + name: apiKeys.name, + hashed_key: apiKeys.hashed_key, + }) + .from(apiKeys) + .where(sql`${apiKeys.meta}->>'oauth_consent_id' = ${consentId}`); + +export const deleteOAuthApiKeyById = async ({ + db, + apiKeyId, +}: { + db: DrizzleCli; + apiKeyId: string; +}) => db.delete(apiKeys).where(eq(apiKeys.id, apiKeyId)); + +export const getApiKeyIdByHashedKey = async ({ + db, + hashedKey, +}: { + db: DrizzleCli; + hashedKey: string; +}) => { + const [keyRecord] = await db + .select({ id: apiKeys.id }) + .from(apiKeys) + .where(eq(apiKeys.hashed_key, hashedKey)) + .limit(1); + + return keyRecord?.id ?? null; +}; + +export const oauthApiKeyRepo = { + listByConsentId: listOAuthApiKeysByConsentId, + deleteById: deleteOAuthApiKeyById, + deleteConsentLinked: deleteOAuthConsentLinkedApiKey, + getIdByHashedKey: getApiKeyIdByHashedKey, +}; diff --git a/server/src/internal/auth/repos/oauthClientRepo.ts b/server/src/internal/auth/repos/oauthClientRepo.ts new file mode 100644 index 000000000..95ddccc9a --- /dev/null +++ b/server/src/internal/auth/repos/oauthClientRepo.ts @@ -0,0 +1,141 @@ +import { oauthClient } from "@autumn/shared"; +import { desc, eq } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; + +export type OAuthClientRecord = { + id: string; + clientId: string; + name: string | null; + redirectUris: string[]; + scopes: string[] | null; + metadata: unknown; + createdAt: Date | null; +}; + +const oauthClientSelect = { + id: oauthClient.id, + clientId: oauthClient.clientId, + name: oauthClient.name, + redirectUris: oauthClient.redirectUris, + scopes: oauthClient.scopes, + metadata: oauthClient.metadata, + createdAt: oauthClient.createdAt, +}; + +export const listOAuthClients = async ({ db }: { db: DrizzleCli }) => + db.select(oauthClientSelect).from(oauthClient); + +export const listOAuthClientsForAdmin = async ({ db }: { db: DrizzleCli }) => + db + .select({ + id: oauthClient.id, + clientId: oauthClient.clientId, + name: oauthClient.name, + redirectUris: oauthClient.redirectUris, + public: oauthClient.public, + disabled: oauthClient.disabled, + skipConsent: oauthClient.skipConsent, + scopes: oauthClient.scopes, + tokenEndpointAuthMethod: oauthClient.tokenEndpointAuthMethod, + grantTypes: oauthClient.grantTypes, + responseTypes: oauthClient.responseTypes, + createdAt: oauthClient.createdAt, + updatedAt: oauthClient.updatedAt, + }) + .from(oauthClient) + .orderBy(desc(oauthClient.createdAt)); + +export const getOAuthClientByClientId = async ({ + db, + clientId, +}: { + db: DrizzleCli; + clientId: string; +}) => { + const [client] = await db + .select(oauthClientSelect) + .from(oauthClient) + .where(eq(oauthClient.clientId, clientId)) + .limit(1); + + return client ?? null; +}; + +export const updateOAuthClientById = async ({ + db, + id, + updates, +}: { + db: DrizzleCli; + id: string; + updates: { + name: string; + redirectUris: string[]; + scopes: string[]; + tokenEndpointAuthMethod: string; + grantTypes: string[]; + responseTypes: string[]; + public: boolean; + type: string; + metadata: unknown; + updatedAt: Date; + }; +}) => { + const [client] = await db + .update(oauthClient) + .set(updates) + .where(eq(oauthClient.id, id)) + .returning(oauthClientSelect); + + return client ?? null; +}; + +export const upsertOAuthClient = async ({ + db, + insert, + update, +}: { + db: DrizzleCli; + insert: { + id: string; + clientId: string; + name: string; + redirectUris: string[]; + scopes: string[]; + tokenEndpointAuthMethod: string; + grantTypes: string[]; + responseTypes: string[]; + public: boolean; + type: string; + metadata: unknown; + createdAt: Date; + updatedAt: Date; + }; + update: { + name: string; + redirectUris: string[]; + scopes: string[]; + tokenEndpointAuthMethod: string; + grantTypes: string[]; + responseTypes: string[]; + public: boolean; + type: string; + metadata: unknown; + updatedAt: Date; + }; +}) => { + await db.insert(oauthClient).values(insert).onConflictDoUpdate({ + target: oauthClient.clientId, + set: update, + }); + + return getOAuthClientByClientId({ db, clientId: insert.clientId }); +}; + +export const oauthClientRepo = { + list: listOAuthClients, + listForAdmin: listOAuthClientsForAdmin, + getByClientId: getOAuthClientByClientId, + updateById: updateOAuthClientById, + upsert: upsertOAuthClient, +}; diff --git a/server/src/internal/auth/repos/oauthConsentRepo.ts b/server/src/internal/auth/repos/oauthConsentRepo.ts new file mode 100644 index 000000000..603a911ef --- /dev/null +++ b/server/src/internal/auth/repos/oauthConsentRepo.ts @@ -0,0 +1,173 @@ +import { AUTUMN_ADMIN_OAUTH_CLIENT_ID } from "@autumn/auth/oauth"; +import { type AppEnv, oauthConsent } from "@autumn/shared"; +import { and, eq, isNull, ne, or, sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; + +export type OAuthConsentApiKeyRecord = { + id: string; + env: AppEnv | null; + oauthApiKeyId: string | null; + redirectUri: string | null; +}; + +export const listOAuthConsentsByReferenceId = async ({ + db, + referenceId, + env, + includeInternal = false, +}: { + db: DrizzleCli; + referenceId: string; + env?: AppEnv; + includeInternal?: boolean; +}) => + db + .select({ + id: oauthConsent.id, + clientId: oauthConsent.clientId, + userId: oauthConsent.userId, + referenceId: oauthConsent.referenceId, + scopes: oauthConsent.scopes, + createdAt: oauthConsent.createdAt, + updatedAt: oauthConsent.updatedAt, + }) + .from(oauthConsent) + .where( + and( + eq(oauthConsent.referenceId, referenceId), + env + ? or(isNull(oauthConsent.env), eq(oauthConsent.env, env)) + : undefined, + includeInternal + ? undefined + : and( + ne(oauthConsent.clientId, AUTUMN_ADMIN_OAUTH_CLIENT_ID), + sql`COALESCE(${oauthConsent.metadata}->>'kind', '') != 'slack_admin'`, + ), + ), + ); + +export const getOAuthConsentOwner = async ({ + db, + consentId, +}: { + db: DrizzleCli; + consentId: string; +}) => { + const [consent] = await db + .select({ + id: oauthConsent.id, + clientId: oauthConsent.clientId, + referenceId: oauthConsent.referenceId, + }) + .from(oauthConsent) + .where(eq(oauthConsent.id, consentId)) + .limit(1); + + return consent ?? null; +}; + +export const updateOAuthConsentEnv = async ({ + db, + clientId, + userId, + referenceId, + env, + redirectUri, + scopes, +}: { + db: DrizzleCli; + clientId: string; + userId: string; + referenceId: string; + env: AppEnv; + redirectUri: string | null; + scopes?: string[]; +}) => + db + .update(oauthConsent) + .set({ + env, + redirectUri, + ...(scopes ? { scopes } : {}), + updatedAt: new Date(), + }) + .where( + and( + eq(oauthConsent.clientId, clientId), + eq(oauthConsent.userId, userId), + eq(oauthConsent.referenceId, referenceId), + ), + ); + +export const getOAuthConsentForClientUserOrg = async ({ + db, + clientId, + userId, + referenceId, + env, +}: { + db: DrizzleCli; + clientId: string; + userId: string; + referenceId: string; + env?: AppEnv; +}) => { + const [consent] = await db + .select({ + id: oauthConsent.id, + env: oauthConsent.env, + oauthApiKeyId: oauthConsent.oauthApiKeyId, + redirectUri: oauthConsent.redirectUri, + scopes: oauthConsent.scopes, + }) + .from(oauthConsent) + .where( + and( + eq(oauthConsent.clientId, clientId), + eq(oauthConsent.userId, userId), + eq(oauthConsent.referenceId, referenceId), + ...(env ? [eq(oauthConsent.env, env)] : []), + ), + ) + .limit(1); + + return consent ?? null; +}; + +export const updateOAuthConsentApiKey = async ({ + db, + consentId, + env, + oauthApiKeyId, +}: { + db: DrizzleCli; + consentId: string; + env: AppEnv; + oauthApiKeyId: string | null; +}) => + db + .update(oauthConsent) + .set({ + env, + oauthApiKeyId, + updatedAt: new Date(), + }) + .where(eq(oauthConsent.id, consentId)); + +export const deleteOAuthConsentById = async ({ + db, + consentId, +}: { + db: DrizzleCli; + consentId: string; +}) => db.delete(oauthConsent).where(eq(oauthConsent.id, consentId)); + +export const oauthConsentRepo = { + listByReferenceId: listOAuthConsentsByReferenceId, + getOwner: getOAuthConsentOwner, + updateEnv: updateOAuthConsentEnv, + getForClientUserOrg: getOAuthConsentForClientUserOrg, + updateApiKey: updateOAuthConsentApiKey, + deleteById: deleteOAuthConsentById, +}; diff --git a/server/src/internal/auth/repos/oauthRefreshTokenRepo.ts b/server/src/internal/auth/repos/oauthRefreshTokenRepo.ts new file mode 100644 index 000000000..6ff1db70a --- /dev/null +++ b/server/src/internal/auth/repos/oauthRefreshTokenRepo.ts @@ -0,0 +1,42 @@ +import { oauthRefreshToken } from "@autumn/shared"; +import { and, eq, isNull } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; + +export const deleteOAuthRefreshTokensByClientAndReference = async ({ + db, + clientId, + referenceId, +}: { + db: DrizzleCli; + clientId: string; + referenceId: string | null; +}) => + db + .delete(oauthRefreshToken) + .where( + and( + eq(oauthRefreshToken.clientId, clientId), + referenceId + ? eq(oauthRefreshToken.referenceId, referenceId) + : isNull(oauthRefreshToken.referenceId), + ), + ); + +export const updateOAuthRefreshTokenScopes = async ({ + db, + id, + scopes, +}: { + db: DrizzleCli; + id: string; + scopes: string[]; +}) => + db + .update(oauthRefreshToken) + .set({ scopes }) + .where(eq(oauthRefreshToken.id, id)); + +export const oauthRefreshTokenRepo = { + deleteByClientAndReference: deleteOAuthRefreshTokensByClientAndReference, + updateScopes: updateOAuthRefreshTokenScopes, +}; diff --git a/server/src/internal/balances/balancesRouter.ts b/server/src/internal/balances/balancesRouter.ts index abe6fcf90..fce9219fc 100644 --- a/server/src/internal/balances/balancesRouter.ts +++ b/server/src/internal/balances/balancesRouter.ts @@ -6,6 +6,8 @@ import { handleCreateBalance } from "./handlers/handleCreateBalance.js"; import { handleDeleteBalance } from "./handlers/handleDeleteBalance.js"; import { handleFinalizeLock } from "./handlers/handleFinalizeLock.js"; import { handleListBalances } from "./handlers/handleListBalances.js"; +import { handleRecalculateBalance } from "./handlers/handleRecalculateBalance.js"; +import { handleRecalculateBalancePreview } from "./handlers/handleRecalculateBalancePreview.js"; import { handleSetUsage } from "./handlers/handleSetUsage.js"; import { handleTrack } from "./handlers/handleTrack.js"; import { handleTrackTokens } from "./handlers/handleTrackTokens.js"; @@ -17,6 +19,11 @@ export const balancesRouter = new Hono(); balancesRouter.post("/balances/create", ...handleCreateBalance); balancesRouter.get("/balances/list", ...handleListBalances); balancesRouter.post("/balances/update", ...handleUpdateBalance); +balancesRouter.post("/balances/recalculate", ...handleRecalculateBalance); +balancesRouter.post( + "/balances/preview_recalculate", + ...handleRecalculateBalancePreview, +); // Track balancesRouter.post("/events", ...handleTrack); @@ -34,6 +41,11 @@ export const balancesRpcRouter = new Hono(); balancesRpcRouter.post("/balances.create", ...handleCreateBalance); balancesRpcRouter.post("/balances.update", ...handleUpdateBalance); balancesRpcRouter.post("/balances.delete", ...handleDeleteBalance); +balancesRpcRouter.post("/balances.recalculate", ...handleRecalculateBalance); +balancesRpcRouter.post( + "/balances.preview_recalculate", + ...handleRecalculateBalancePreview, +); balancesRpcRouter.post("/balances.track", ...handleTrack); balancesRpcRouter.post("/balances.track_tokens", ...handleTrackTokens); diff --git a/server/src/internal/balances/deleteBalance/deleteBalance.ts b/server/src/internal/balances/deleteBalance/deleteBalance.ts index 145c9960b..57f107c98 100644 --- a/server/src/internal/balances/deleteBalance/deleteBalance.ts +++ b/server/src/internal/balances/deleteBalance/deleteBalance.ts @@ -1,18 +1,17 @@ import { cusEntsToUsage, type DeleteBalanceParamsV0, - findFeatureById, fullCustomerToCustomerEntitlements, isPaidCustomerEntitlement, RecaseError, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { executePostgresDeduction } from "@/internal/balances/utils/deduction/executePostgresDeduction"; import { CusService } from "@/internal/customers/CusService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; import { buildCustomerEntitlementFilters } from "../utils/buildCustomerEntitlementFilters"; +import { reapplyFeatureUsageDeduction } from "../utils/reapplyFeatureUsageDeduction"; export const deleteBalance = async ({ ctx, @@ -93,39 +92,16 @@ export const deleteBalance = async ({ return; } - const survivingFullCustomer = await CusService.getFull({ - ctx, - idOrInternalId: customer_id, - entityId: entity_id, - withEntities: true, - withSubs: true, - }); - const targetFeatureId = feature_id ?? customerEntitlements[0]?.feature_id; if (!targetFeatureId) { return; } - const feature = findFeatureById({ - features: ctx.features, - featureId: targetFeatureId, - errorOnNotFound: true, - }); - - await executePostgresDeduction({ + await reapplyFeatureUsageDeduction({ ctx, - fullCustomer: survivingFullCustomer, - customerId: survivingFullCustomer.id ?? customer_id, + customerId: customer_id, entityId: entity_id, - deductions: [ - { - feature, - deduction: usageToRecalculate, - }, - ], - options: { - alterGrantedBalance: false, - overageBehaviour: "allow", - }, + featureId: targetFeatureId, + usage: usageToRecalculate, }); }; diff --git a/server/src/internal/balances/handlers/handleRecalculateBalance.ts b/server/src/internal/balances/handlers/handleRecalculateBalance.ts new file mode 100644 index 000000000..735bc8d3b --- /dev/null +++ b/server/src/internal/balances/handlers/handleRecalculateBalance.ts @@ -0,0 +1,19 @@ +import { RecalculateBalanceParamsV0Schema, Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { recalculateBalance } from "../recalculateBalance/recalculateBalance"; + +export const handleRecalculateBalance = createRoute({ + scopes: [Scopes.Balances.Write], + body: RecalculateBalanceParamsV0Schema, + handler: async (c) => { + const ctx = c.get("ctx"); + const params = c.req.valid("json"); + + await recalculateBalance({ + ctx, + params, + }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/balances/handlers/handleRecalculateBalancePreview.ts b/server/src/internal/balances/handlers/handleRecalculateBalancePreview.ts new file mode 100644 index 000000000..6b2e067f7 --- /dev/null +++ b/server/src/internal/balances/handlers/handleRecalculateBalancePreview.ts @@ -0,0 +1,19 @@ +import { RecalculateBalanceParamsV0Schema, Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { recalculateBalancePreview } from "../recalculateBalance/recalculateBalancePreview"; + +export const handleRecalculateBalancePreview = createRoute({ + scopes: [Scopes.Balances.Read], + body: RecalculateBalanceParamsV0Schema, + handler: async (c) => { + const ctx = c.get("ctx"); + const params = c.req.valid("json"); + + const preview = await recalculateBalancePreview({ + ctx, + params, + }); + + return c.json(preview); + }, +}); diff --git a/server/src/internal/balances/recalculateBalance/computeRecalculateBalance.ts b/server/src/internal/balances/recalculateBalance/computeRecalculateBalance.ts new file mode 100644 index 000000000..eb9a028d1 --- /dev/null +++ b/server/src/internal/balances/recalculateBalance/computeRecalculateBalance.ts @@ -0,0 +1,108 @@ +import { + cusEntsToUsage, + cusEntToRecalculateScopeKey, + cusEntToStartingBalance, + type FullCusEntWithFullCusProduct, + type FullCustomer, + fullCustomerToCustomerEntitlements, + getRecalculableScopeKeys, + type RecalculateBalanceParamsV0, + RecaseError, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { deductFromCusEntsTypescript } from "@/internal/balances/track/deductUtils/deductFromCusEntsTypescript"; +import { CusService } from "@/internal/customers/CusService"; +import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils"; +import { buildCustomerEntitlementFilters } from "../utils/buildCustomerEntitlementFilters"; + +const resetCusEntInPlace = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}): void => { + const resetUpdate = getResetBalancesUpdate({ + cusEnt, + allowance: cusEntToStartingBalance({ cusEnt }) ?? undefined, + }); + if ("entities" in resetUpdate) { + cusEnt.entities = resetUpdate.entities; + } else { + cusEnt.balance = resetUpdate.balance; + cusEnt.additional_balance = resetUpdate.additional_balance; + } + cusEnt.adjustment = 0; +}; + +/** + * Computes (without persisting) the result of recalculating a customer's + * balances for a feature: every matching entitlement is reset to its starting + * balance and the total usage is re-applied across them in priority order + * (allowing overage). Returns the original entitlements (`before`) and the + * recalculated clones (`after`) so callers can persist them or preview the diff. + */ +export const computeRecalculateBalance = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: RecalculateBalanceParamsV0; +}): Promise<{ + fullCustomer: FullCustomer; + entityId: string | undefined; + before: FullCusEntWithFullCusProduct[]; + after: FullCusEntWithFullCusProduct[]; + totalUsage: number; +}> => { + const { customer_id, entity_id, feature_id } = params; + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customer_id, + entityId: entity_id, + withEntities: true, + withSubs: true, + }); + const before = fullCustomerToCustomerEntitlements({ + fullCustomer, + featureId: feature_id, + entity: fullCustomer.entity, + customerEntitlementFilters: buildCustomerEntitlementFilters({ params }), + }); + if (before.length === 0) { + throw new RecaseError({ + message: `Balance not found for feature ${feature_id} and customer ${customer_id}`, + }); + } + const entityId = fullCustomer.entity?.id ?? undefined; + const totalUsage = cusEntsToUsage({ cusEnts: before, entityId }); + const after = before.map((cusEnt) => structuredClone(cusEnt)); + const recalculableScopes = getRecalculableScopeKeys({ + cusEnts: after, + entityId, + }); + const scopeGroups = new Map(); + for (const cusEnt of after) { + const key = cusEntToRecalculateScopeKey({ cusEnt }); + const group = scopeGroups.get(key); + if (group) { + group.push(cusEnt); + } else { + scopeGroups.set(key, [cusEnt]); + } + } + for (const [key, group] of scopeGroups) { + if (!recalculableScopes.has(key)) { + continue; + } + const scopeUsage = cusEntsToUsage({ cusEnts: group, entityId }); + for (const cusEnt of group) { + resetCusEntInPlace({ cusEnt }); + } + deductFromCusEntsTypescript({ + cusEnts: group, + amountToDeduct: scopeUsage, + targetEntityId: entityId, + allowOverage: true, + }); + } + return { fullCustomer, entityId, before, after, totalUsage }; +}; diff --git a/server/src/internal/balances/recalculateBalance/recalculateBalance.ts b/server/src/internal/balances/recalculateBalance/recalculateBalance.ts new file mode 100644 index 000000000..ea772cc97 --- /dev/null +++ b/server/src/internal/balances/recalculateBalance/recalculateBalance.ts @@ -0,0 +1,49 @@ +import type { RecalculateBalanceParamsV0 } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; +import { computeRecalculateBalance } from "./computeRecalculateBalance"; + +/** + * Recalculates a customer's balances for a feature by resetting every matching + * entitlement to its starting balance and re-applying the total usage across + * them in priority order. This redistributes usage so a positive balance + * absorbs the overage of a negative one, without deleting any balance. The + * aggregate balance is unchanged; only the per-entitlement distribution moves. + */ +export const recalculateBalance = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: RecalculateBalanceParamsV0; +}): Promise => { + const { fullCustomer, before, after } = await computeRecalculateBalance({ + ctx, + params, + }); + const afterById = new Map(after.map((cusEnt) => [cusEnt.id, cusEnt])); + await ctx.db.transaction(async (tx) => { + const txCtx = { ...ctx, db: tx as unknown as typeof ctx.db }; + for (const cusEnt of before) { + const updated = afterById.get(cusEnt.id); + if (!updated) { + continue; + } + await CusEntService.update({ + ctx: txCtx, + id: cusEnt.id, + updates: { + balance: updated.balance ?? 0, + additional_balance: updated.additional_balance ?? 0, + entities: updated.entities, + adjustment: updated.adjustment ?? 0, + }, + }); + } + }); + await deleteCachedFullCustomer({ + ctx, + customerId: fullCustomer.id ?? "", + }); +}; diff --git a/server/src/internal/balances/recalculateBalance/recalculateBalancePreview.ts b/server/src/internal/balances/recalculateBalance/recalculateBalancePreview.ts new file mode 100644 index 000000000..01de45fcf --- /dev/null +++ b/server/src/internal/balances/recalculateBalance/recalculateBalancePreview.ts @@ -0,0 +1,40 @@ +import { + cusEntsToBalance, + type RecalculateBalanceParamsV0, + type RecalculateBalancePreview, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { computeRecalculateBalance } from "./computeRecalculateBalance"; + +/** + * Computes a preview of a balance recalculation without persisting anything, + * returning the remaining balance per entitlement before and after. + */ +export const recalculateBalancePreview = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: RecalculateBalanceParamsV0; +}): Promise => { + const { before, after, entityId, totalUsage } = + await computeRecalculateBalance({ ctx, params }); + const afterById = new Map(after.map((cusEnt) => [cusEnt.id, cusEnt])); + const entitlements = before.map((cusEnt) => { + const updated = afterById.get(cusEnt.id) ?? cusEnt; + return { + customer_entitlement_id: cusEnt.id, + before_remaining: cusEntsToBalance({ + cusEnts: [cusEnt], + entityId, + withRollovers: true, + }), + after_remaining: cusEntsToBalance({ + cusEnts: [updated], + entityId, + withRollovers: true, + }), + }; + }); + return { total_usage: totalUsage, entitlements }; +}; diff --git a/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts b/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts index 66156e522..673225f5d 100644 --- a/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts +++ b/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts @@ -14,7 +14,7 @@ export const buildAiCreditCostProperty = ({ featureDeductions: FeatureDeduction[]; entries: Array<{ featureId: string; amount: number }>; }): Record | undefined => { - const aiDeduction = featureDeductions.find((d) => d.tokenUsage); + const aiDeduction = featureDeductions.find((d) => d.tokens); if (!aiDeduction) return; const creditCost: Record = {}; diff --git a/server/src/internal/balances/track/utils/getTokenTrackParams.ts b/server/src/internal/balances/track/utils/getTokenTrackParams.ts index 97a2996c1..e75950bbd 100644 --- a/server/src/internal/balances/track/utils/getTokenTrackParams.ts +++ b/server/src/internal/balances/track/utils/getTokenTrackParams.ts @@ -11,7 +11,7 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getOrSetCachedFullSubject } from "@/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.js"; import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; -import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { getModelCreditCostBreakdown } from "@/internal/features/aiCreditSystemUtils.js"; import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; @@ -77,9 +77,7 @@ const resolveAiCreditFeatureFromEntitlements = async ({ const aiCreditFeatures = [ ...new Map( cusEnts - .filter( - (ce) => isAiCreditSystem(ce.entitlement.feature.type), - ) + .filter((ce) => isAiCreditSystem(ce.entitlement.feature.type)) .map((ce) => [ce.entitlement.feature.id, ce.entitlement.feature]), ).values(), ]; @@ -120,31 +118,31 @@ export const getTokenTrackParams = async ({ entityId: input.entity_id, }); - const cost = await getCreditCost({ - featureId: aiCreditFeature.id, - creditSystem: aiCreditFeature, + const pricing = await getModelCreditCostBreakdown({ modelName: input.model_id, - tokens: { - input: input.input_tokens, - output: input.output_tokens, - cacheRead: input.cache_read_tokens, - cacheWrite: input.cache_write_tokens, - audioInput: input.audio_input_tokens, - audioOutput: input.audio_output_tokens, - reasoning: input.reasoning_tokens, - }, + creditSystem: aiCreditFeature, + input: input.input_tokens, + output: input.output_tokens, + cacheRead: input.cache_read_tokens, + cacheWrite: input.cache_write_tokens, + audioInput: input.audio_input_tokens, + audioOutput: input.audio_output_tokens, + reasoning: input.reasoning_tokens, }); + const cost = pricing.cost; const featureDeductions: FeatureDeduction[] = [ { feature: aiCreditFeature, deduction: 1, - tokenUsage: { - modelName: input.model_id, - inputTokens: input.input_tokens, - outputTokens: input.output_tokens, + tokens: { + usage: { + modelName: input.model_id, + inputTokens: input.input_tokens, + outputTokens: input.output_tokens, + }, + cost, }, - precomputedCreditCost: cost, }, ]; @@ -164,6 +162,19 @@ export const getTokenTrackParams = async ({ audio_output_tokens: input.audio_output_tokens, reasoning_tokens: input.reasoning_tokens, cost, + base_cost: pricing.baseCost, + markup: pricing.markup, + markup_source: pricing.markupSource, + tier_applied: pricing.tierApplied, + rates: { + input: pricing.rates.input, + output: pricing.rates.output, + cache_read: pricing.rates.cacheRead, + cache_write: pricing.rates.cacheWrite, + audio_input: pricing.rates.audioInput, + audio_output: pricing.rates.audioOutput, + reasoning: pricing.rates.reasoning, + }, }, idempotency_key: input.idempotency_key, overage_behavior: input.overage_behavior, diff --git a/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts b/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts index 9d34c37fd..40edfee92 100644 --- a/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts +++ b/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts @@ -15,7 +15,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; type AlertScope = "customer" | "entity" | "org"; -const wasThresholdCrossed = ({ +export const wasThresholdCrossed = ({ alert, oldApiBalance, newApiBalance, @@ -26,8 +26,8 @@ const wasThresholdCrossed = ({ }) => { if (alert.threshold_type === "usage") { const shldAlert = - oldApiBalance.usage <= alert.threshold && - newApiBalance.usage > alert.threshold; + oldApiBalance.usage < alert.threshold && + newApiBalance.usage >= alert.threshold; return shldAlert; } @@ -73,7 +73,7 @@ const wasThresholdCrossed = ({ .mul(100) .toNumber(); - return oldPercentage <= alert.threshold && newPercentage > alert.threshold; + return oldPercentage < alert.threshold && newPercentage >= alert.threshold; } return false; @@ -207,7 +207,7 @@ export const checkUsageAlerts = async ({ scope: "customer", }); - // 2. Org-level alerts (apply to all customers; evaluated against customer-level balance). + // 2. Org-level alerts apply to all customers and use the tracked subject. // Env-scoped: sandbox reads sandbox_usage_alerts, live reads usage_alerts. const orgAlerts = ctx.env === AppEnv.Sandbox @@ -219,6 +219,7 @@ export const checkUsageAlerts = async ({ oldFullCus, newFullCus, feature, + entityId, alerts: orgAlerts, scope: "org", }); diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts index bd331e2d3..c29f3b685 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts @@ -1,6 +1,8 @@ import { + BILLING_AMOUNT_EPSILON, cusEntToCusPrice, InternalError, + type LineItem, type LineItemContext, orgToCurrency, priceToProrationConfig, @@ -10,6 +12,7 @@ import { import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { getLineItemBillingPeriod } from "@/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod"; +import { getRefundLineItemsForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice"; import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext"; import { allocatedInvoiceIsUpgrade } from "./allocatedInvoiceIsUpgrade"; @@ -66,7 +69,7 @@ export const computeAllocatedInvoiceLineItems = ({ customerProduct, }; - const previousLIneItem = usagePriceToLineItem({ + const catalogRefundLineItem = usagePriceToLineItem({ cusEnt: previousCustomerEntitlement, context: { ...lineItemContext, @@ -78,6 +81,14 @@ export const computeAllocatedInvoiceLineItems = ({ }, }); + const previousLineItems = getRefundLineItemsForPrice({ + ctx, + customerProduct, + billingContext, + priceId: customerPrice.price.id, + catalogFallback: catalogRefundLineItem, + }); + const newLineItem = usagePriceToLineItem({ cusEnt: billingContext.updatedCustomerEntitlement, context: lineItemContext, @@ -87,15 +98,16 @@ export const computeAllocatedInvoiceLineItems = ({ }, }); - // Don't return line items if they sum to 0 - if ( + const netAmount = Math.abs( sumValues([ - previousLIneItem?.amountAfterDiscounts ?? 0, + ...previousLineItems.map((li) => li.amountAfterDiscounts ?? 0), newLineItem?.amountAfterDiscounts ?? 0, - ]) === 0 - ) { - return []; - } + ]), + ); - return [previousLIneItem, newLineItem]; + if (netAmount < BILLING_AMOUNT_EPSILON) return []; + + return [...previousLineItems, newLineItem].filter( + (li): li is LineItem => li !== undefined, + ); }; diff --git a/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts index 70d92d83b..dfd562ab5 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts @@ -10,6 +10,7 @@ import { } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.js"; +import { fetchStoredLineItemsForBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForBilling.js"; import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext.js"; import { applyDeductionUpdateToCustomerEntitlement } from "../deduction/applyDeductionUpdateToCustomerEntitlement.js"; import { applyDeductionUpdateToFullCustomer } from "../deduction/applyDeductionUpdateToFullCustomer.js"; @@ -108,6 +109,12 @@ export const setupAllocatedInvoiceContext = async ({ cusEnt: newCustomerEntitlement, }); + const { storedChargeLineItems, storedRefundLineItems } = + await fetchStoredLineItemsForBilling({ + db: ctx.db, + customerProductIds: [cusProduct.id], + }); + return { // BillingContext fields fullCustomer, @@ -120,6 +127,8 @@ export const setupAllocatedInvoiceContext = async ({ stripeSubscription, stripeSubscriptionSchedule, stripeDiscounts, + storedChargeLineItems, + storedRefundLineItems, paymentMethod, billingVersion: BillingVersion.V2, diff --git a/server/src/internal/balances/utils/deduction/computeCreditCosts.ts b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts index 0ffe954a9..998f12a97 100644 --- a/server/src/internal/balances/utils/deduction/computeCreditCosts.ts +++ b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts @@ -1,4 +1,5 @@ import type { FullCusEntWithFullCusProduct } from "@autumn/shared"; +import { logger } from "@/external/logtail/logtailUtils.js"; import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; import type { FeatureDeduction } from "../types/featureDeduction.js"; @@ -6,51 +7,47 @@ const DEFAULT_CREDIT_COST = 1; export type CreditCostLookup = (entitlementId: string) => number; -/** - * Computes the credit cost for each customer entitlement and returns a lookup - * function. Uses precomputedCreditCost when available (token tracking), - * otherwise calls getCreditCost per entitlement (credit system schema lookups). - */ -export const computeCreditCosts = async ({ +/** Per-entitlement credit cost lookup. Pure schema math — no I/O. */ +export const computeCreditCosts = ({ cusEnts, deduction, }: { cusEnts: FullCusEntWithFullCusProduct[]; deduction: FeatureDeduction; -}): Promise => { +}): CreditCostLookup => { const costMap = new Map(); - const tokens = deduction.tokenUsage - ? { - input: deduction.tokenUsage.inputTokens, - output: deduction.tokenUsage.outputTokens, - } - : undefined; + for (const ce of cusEnts) { + // Token cost is USD: 1:1 on its own ent; parents apply their ratio to it. + if ( + deduction.tokens && + ce.entitlement.feature.id === deduction.feature.id + ) { + costMap.set(ce.id, deduction.tokens.cost); + continue; + } - await Promise.all( - cusEnts.map(async (ce) => { - // Precomputed cost (from /track/tokens) is in the AI credit feature's - // native unit (USD). It applies 1:1 to that feature's own entitlement, - // but parent credit systems still need their schema ratio applied — - // fall through to getCreditCost with amount = precomputed cost. - if ( - deduction.precomputedCreditCost != null && - ce.entitlement.feature.id === deduction.feature.id - ) { - costMap.set(ce.id, deduction.precomputedCreditCost); - return; - } - - const creditCost = await getCreditCost({ - featureId: deduction.feature.id, - creditSystem: ce.entitlement.feature, - amount: deduction.precomputedCreditCost, - modelName: deduction.tokenUsage?.modelName, - tokens, + try { + costMap.set( + ce.id, + getCreditCost({ + featureId: deduction.feature.id, + creditSystem: ce.entitlement.feature, + amount: deduction.tokens?.cost, + }), + ); + } catch (error) { + // Cached cusEnt schemas can briefly trail a feature update; deduct at + // 1:1 rather than failing the track. + logger.warn("[computeCreditCosts] falling back to credit cost 1", { + feature_id: deduction.feature.id, + credit_system_id: ce.entitlement.feature.id, + customer_entitlement_id: ce.id, + error: String(error), }); - costMap.set(ce.id, creditCost); - }), - ); + costMap.set(ce.id, DEFAULT_CREDIT_COST); + } + } return (entitlementId) => costMap.get(entitlementId) ?? DEFAULT_CREDIT_COST; }; diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index 05c015f1e..d0f3a84ac 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -111,7 +111,7 @@ export const executePostgresDeduction = async ({ customerEntitlements, unlimitedFeatureIds, lock: preparedLock, - } = await prepareFeatureDeduction({ + } = prepareFeatureDeduction({ ctx, fullCustomer, deduction, diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index 43a5040b7..9ac4e797a 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -109,7 +109,7 @@ export const executeRedisDeduction = async ({ customerEntitlements, unlimitedFeatureIds, lock: preparedLock, - } = await prepareFeatureDeduction({ + } = prepareFeatureDeduction({ ctx, fullCustomer, deduction, diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index a03e6f3b5..bdb599fbf 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -27,7 +27,7 @@ import type { FeatureDeduction } from "../types/featureDeduction.js"; * Prepares all the inputs needed to execute a deduction for a single feature. * Shared by both Redis (Lua) and Postgres (SQL) deduction paths. */ -export const prepareFeatureDeduction = async ({ +export const prepareFeatureDeduction = ({ ctx, fullCustomer, deduction, @@ -37,7 +37,7 @@ export const prepareFeatureDeduction = async ({ fullCustomer: FullCustomer; deduction: FeatureDeduction; options?: DeductionOptions; -}): Promise => { +}): PreparedFeatureDeduction => { const { org } = ctx; const { env } = ctx; const { feature, lock, targetBalance } = deduction; @@ -101,7 +101,7 @@ export const prepareFeatureDeduction = async ({ .map((ce) => ce.entitlement.feature.id), ); - const getCreditCostForEnt = await computeCreditCosts({ cusEnts, deduction }); + const getCreditCostForEnt = computeCreditCosts({ cusEnts, deduction }); // Build input for each customer entitlement const customerEntitlementDeductions: CustomerEntitlementDeduction[] = diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index 894076631..99ebecc17 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -112,7 +112,7 @@ export const executePostgresDeductionV2 = async ({ unlimitedFeatureIds, unlimitedCusEnt, lock: preparedLock, - } = await prepareFeatureDeductionV2({ + } = prepareFeatureDeductionV2({ ctx, fullSubject, deduction, diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index d40a508dc..8d1d2f11e 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -123,7 +123,7 @@ export const executeRedisDeductionV2 = async ({ unlimitedFeatureIds, unlimitedCusEnt, lock: preparedLock, - } = await prepareFeatureDeductionV2({ + } = prepareFeatureDeductionV2({ ctx, fullSubject, deduction, diff --git a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts index 1d09dd5a7..e7c2502ac 100644 --- a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts @@ -29,7 +29,7 @@ import type { FeatureDeduction } from "../types/featureDeduction.js"; * Prepares all the inputs needed to execute a deduction for a single feature. * Mirrors the legacy helper, but reads from FullSubject. */ -export const prepareFeatureDeductionV2 = async ({ +export const prepareFeatureDeductionV2 = ({ ctx, fullSubject, deduction, @@ -39,7 +39,7 @@ export const prepareFeatureDeductionV2 = async ({ fullSubject: FullSubject; deduction: FeatureDeduction; options?: DeductionOptions; -}): Promise => { +}): PreparedFeatureDeduction => { const { org, env } = ctx; const { feature, lock, targetBalance } = deduction; const { overageBehaviour = "cap", customerEntitlementFilters } = options; @@ -115,7 +115,7 @@ export const prepareFeatureDeductionV2 = async ({ .map((customerEntitlement) => customerEntitlement.entitlement.feature.id), ); - const getCreditCostForEnt = await computeCreditCosts({ + const getCreditCostForEnt = computeCreditCosts({ cusEnts: customerEntitlements, deduction, }); diff --git a/server/src/internal/balances/utils/reapplyFeatureUsageDeduction.ts b/server/src/internal/balances/utils/reapplyFeatureUsageDeduction.ts new file mode 100644 index 000000000..d7ccf3466 --- /dev/null +++ b/server/src/internal/balances/utils/reapplyFeatureUsageDeduction.ts @@ -0,0 +1,51 @@ +import { findFeatureById } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { executePostgresDeduction } from "@/internal/balances/utils/deduction/executePostgresDeduction"; +import { CusService } from "@/internal/customers/CusService"; + +/** + * Re-applies a feature's total usage across a customer's balances after those + * balances have been mutated (e.g. a balance was deleted or reset). Reloads the + * customer to capture the mutated state, then redistributes the usage across the + * remaining entitlements in priority order, allowing overage. + */ +export const reapplyFeatureUsageDeduction = async ({ + ctx, + customerId, + entityId, + featureId, + usage, +}: { + ctx: AutumnContext; + customerId: string; + entityId?: string; + featureId: string; + usage: number; +}): Promise => { + if (usage === 0) { + return; + } + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + entityId, + withEntities: true, + withSubs: true, + }); + const feature = findFeatureById({ + features: ctx.features, + featureId, + errorOnNotFound: true, + }); + await executePostgresDeduction({ + ctx, + fullCustomer, + customerId: fullCustomer.id ?? customerId, + entityId, + deductions: [{ feature, deduction: usage }], + options: { + alterGrantedBalance: false, + overageBehaviour: "allow", + }, + }); +}; diff --git a/server/src/internal/balances/utils/types/featureDeduction.ts b/server/src/internal/balances/utils/types/featureDeduction.ts index ae6b7755d..8448112a5 100644 --- a/server/src/internal/balances/utils/types/featureDeduction.ts +++ b/server/src/internal/balances/utils/types/featureDeduction.ts @@ -7,13 +7,18 @@ export type TokenUsage = { outputTokens: number; }; +/** Token usage and its USD cost are priced together at the API layer — one cannot exist without the other. */ +export type TokenDeduction = { + usage: TokenUsage; + cost: number; +}; + export type FeatureDeduction = { feature: Feature; deduction: number; targetBalance?: number; - tokenUsage?: TokenUsage; - /** Pre-computed dollar cost; if set, the deduction layer skips its own getCreditCost call. */ - precomputedCreditCost?: number; + /** Present only for track_tokens deductions; standard deductions omit it. */ + tokens?: TokenDeduction; lock?: LockParams; lockReceipt?: LockReceipt; lockReceiptKey?: string; diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index 874ec8a31..1df3ccec9 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -77,6 +77,7 @@ export async function attach({ billingContext, billingPlan, params, + preview, }); if (preview) { diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts index 158fdd052..99b99eaf7 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts @@ -10,6 +10,10 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { carryOverUsagesToExistingUsagesConfig } from "@/internal/billing/v2/utils/handleCarryOvers/carryOverUtils"; import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; +type NewCustomerProductParams = Partial< + Pick +>; + const getScheduledBillingCycleAnchorResetAt = ({ requestedBillingCycleAnchor, currentEpochMs, @@ -36,11 +40,11 @@ const getScheduledBillingCycleAnchorResetAt = ({ export const computeAttachNewCustomerProduct = ({ ctx, attachBillingContext, - params = {} as AttachParamsV1, + params = {}, }: { ctx: AutumnContext; attachBillingContext: AttachBillingContext; - params?: AttachParamsV1; + params?: NewCustomerProductParams; }): FullCusProduct => { const { attachProduct, @@ -60,7 +64,9 @@ export const computeAttachNewCustomerProduct = ({ requestedBillingCycleAnchor, resetCycleAnchorMs, accessStartsAt, + billingStartsAt, paymentMethod, + processorTypeOverride, } = attachBillingContext; const currentCustomerEntitlements = @@ -77,7 +83,7 @@ export const computeAttachNewCustomerProduct = ({ ); const isScheduled = planTiming === "end_of_cycle"; - const startsAt = params.starts_at ?? (isScheduled ? endOfCycleMs : undefined); + const startsAt = billingStartsAt ?? (isScheduled ? endOfCycleMs : undefined); const hasAutoChargePaymentMethod = paymentMethod !== undefined && paymentMethod.type !== "custom"; const shouldSendInvoiceForFutureStart = @@ -137,6 +143,7 @@ export const computeAttachNewCustomerProduct = ({ accessStartsAt, collectionMethod, externalId, + processorType: processorTypeOverride, billingCycleAnchorResetsAt: getScheduledBillingCycleAnchorResetAt({ requestedBillingCycleAnchor, currentEpochMs, diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts index 3b7e4aace..92aa3fdcd 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts @@ -28,20 +28,26 @@ export const handleAttachV2Errors = async ({ billingContext, billingPlan, params, + preview = false, }: { ctx: AutumnContext; billingContext: AttachBillingContext; billingPlan: BillingPlan; params: AttachParamsV1; + preview?: boolean; }) => { const { autumn: autumnBillingPlan } = billingPlan; // 1.1. External PSP errors (RevenueCat) - handleExternalPSPErrors({ - customerProducts: billingContext.fullCustomer.customer_products, - attachProduct: billingContext.attachProduct, - action: "attach", - }); + // Skipped when the caller IS the external PSP origin (e.g. the RevenueCat + // webhook handler attaching onto its own RC-managed customer). + if (!billingContext.skipExternalPSPGuard) { + handleExternalPSPErrors({ + customerProducts: billingContext.fullCustomer.customer_products, + attachProduct: billingContext.attachProduct, + action: "attach", + }); + } // 1.2. Custom Payment Method errors (Vercel) handleCustomPaymentMethodErrorsV2({ billingContext }); @@ -61,7 +67,7 @@ export const handleAttachV2Errors = async ({ // 6. Scheduled switch with one-off prepaid quantities handleScheduledSwitchOneOffErrors({ ctx, billingContext }); handleBillingCycleAnchorErrors({ billingContext }); - handleStartDateErrors({ billingContext, params }); + handleStartDateErrors({ billingContext, params, preview }); handleEndDateErrors({ billingContext, params }); // 7. Transition config errors (reset_after_trial_end on allocated features) diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors.ts index 6503f7dae..eceb02f34 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors.ts @@ -10,8 +10,12 @@ export const handleCurrentCustomerProductErrors = ({ }: { billingContext: AttachBillingContext; }) => { - const { currentCustomerProduct, attachProduct, stripeSubscription } = - billingContext; + const { + currentCustomerProduct, + attachProduct, + stripeSubscription, + skipExternalPSPGuard, + } = billingContext; if (currentCustomerProduct?.product.id === attachProduct.id) { throw new RecaseError({ @@ -21,7 +25,17 @@ export const handleCurrentCustomerProductErrors = ({ }); } - if (isCustomerProductPaid(currentCustomerProduct) && !stripeSubscription) { + // The "paid but no Stripe sub" guard catches broken Stripe linkage. + // External-PSP origin callers (e.g. RevenueCat) legitimately have paid + // current products with no Stripe subscription — they opt out via + // `skipExternalPSPGuard`. Stripe-origin cus_products with `processor: null` + // must still be checked, so this is gated on the explicit flag rather than + // on `cusProductToProcessorType`. + if ( + !skipExternalPSPGuard && + isCustomerProductPaid(currentCustomerProduct) && + !stripeSubscription + ) { throw new RecaseError({ message: `Cannot attach because the customer's current product '${currentCustomerProduct?.product.name}' is paid but no stripe subscription is linked to it`, }); diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleStartDateErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleStartDateErrors.ts index d7b3026b9..547fa437f 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleStartDateErrors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleStartDateErrors.ts @@ -9,25 +9,20 @@ import { RecaseError, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; +import { assertNoBackdateWithExistingSubscription } from "@/internal/billing/v2/utils/backdate/assertNoBackdateWithExistingSubscription"; +import { assertStripeBackdateInvoiceLineItemLimit } from "@/internal/billing/v2/utils/backdate/stripeBackdateInvoiceLimit"; export const handleStartDateErrors = ({ billingContext, params, + preview = false, }: { billingContext: AttachBillingContext; params: AttachParamsV1; + preview?: boolean; }) => { if (params.starts_at === undefined) return; - if (isPastStartDate(params.starts_at, billingContext.currentEpochMs)) { - throw new RecaseError({ - message: - "starts_at cannot be set to a past timestamp. Use now or a future Unix timestamp in milliseconds.", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - if (params.plan_schedule === "end_of_cycle") { throw new RecaseError({ message: @@ -37,6 +32,49 @@ export const handleStartDateErrors = ({ }); } + const prices = billingContext.attachProduct.prices; + const isPaidRecurring = + !isFreeProduct({ prices }) && !isOneOffProduct({ prices }); + + if (isPastStartDate(params.starts_at, billingContext.currentEpochMs)) { + if (!isPaidRecurring) { + throw new RecaseError({ + message: "Past starts_at is only supported for paid recurring plans.", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + assertNoBackdateWithExistingSubscription({ billingContext }); + + // Previews don't know whether the caller will settle via invoice (supports + // backdating) or Stripe Checkout (doesn't), so only block checkout on execute. + if (!preview && billingContext.checkoutMode === "stripe_checkout") { + throw new RecaseError({ + message: + "Past starts_at cannot be used when Stripe Checkout is required.", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + if (billingContext.trialContext?.trialEndsAt) { + throw new RecaseError({ + message: "Past starts_at cannot be used together with a free trial.", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + assertStripeBackdateInvoiceLineItemLimit({ + products: [billingContext.attachProduct], + startsAt: params.starts_at, + currentEpochMs: billingContext.currentEpochMs, + }); + + return; + } + if (!isFutureStartDate(params.starts_at, billingContext.currentEpochMs)) { return; } @@ -49,9 +87,6 @@ export const handleStartDateErrors = ({ }); } - const prices = billingContext.attachProduct.prices; - const isPaidRecurring = - !isFreeProduct({ prices }) && !isOneOffProduct({ prices }); if (!isPaidRecurring) { throw new RecaseError({ message: "Future starts_at is only supported for paid recurring plans.", diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index bf779f6c7..d627b6080 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -10,6 +10,7 @@ import { isFreeProduct, isFutureStartDate, isOneOffProduct, + isPastStartDate, notNullish, orgDisableStripeWrites, orgToReturnUrl, @@ -25,6 +26,7 @@ import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoic import { setupPaymentBehaviorIntent } from "@/internal/billing/v2/setup/setupPaymentBehaviorIntent"; import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor"; import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs"; +import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling"; import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities"; import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund"; import { setupIgnoreProrationBehavior } from "../../../setup/setupIgnoreProrationBehavior"; @@ -125,7 +127,11 @@ export const setupAttachBillingContext = async ({ // no_billing_changes blocks WRITES but should still allow reading the // existing Stripe sub when one is linked — needed so the new cusProduct // inherits subscription_ids and the paid-product guard doesn't misfire. - const skipBillingFetching = orgDisableStripeWrites({ ctx }); + // External-PSP origin callers (e.g. RevenueCat) opt out of fetching + // entirely via `contextOverride.skipBillingFetching`. + const skipBillingFetching = + orgDisableStripeWrites({ ctx }) || + contextOverride.skipBillingFetching === true; const skipBillingChangesBase = skipBillingFetching || @@ -163,7 +169,7 @@ export const setupAttachBillingContext = async ({ contextOverride, }); - const invoiceMode = setupInvoiceModeContext({ params }); + const invoiceMode = await setupInvoiceModeContext({ ctx, params }); const paymentBehaviorIntent = setupPaymentBehaviorIntent({ contextOverride, paymentMethod, @@ -200,6 +206,7 @@ export const setupAttachBillingContext = async ({ trialContext, currentEpochMs, requestedBillingCycleAnchor: params.billing_cycle_anchor, + billingStartsAt: params.starts_at, }); // Trial ends at overrides billing cycle anchor @@ -220,10 +227,18 @@ export const setupAttachBillingContext = async ({ const billingStartsAt = params.starts_at ?? (planTiming === "end_of_cycle" ? endOfCycleMs : undefined); + const hasFutureStartDate = isFutureStartDate( params.starts_at, currentEpochMs, ); + + const subscriptionBackdateStartMs = + params.starts_at !== undefined && + isPastStartDate(params.starts_at, currentEpochMs) + ? params.starts_at + : undefined; + const accessStartsAt = getAttachAccessStartsAt({ params, currentEpochMs, @@ -251,6 +266,17 @@ export const setupAttachBillingContext = async ({ contextOverride, }); + const outgoingCusProductIds = currentCustomerProduct + ? [currentCustomerProduct.id] + : []; + const { storedChargeLineItems, storedRefundLineItems } = + await fetchStoredLineItemsForSubscriptionBilling({ + db: ctx.db, + fullCustomer, + stripeSubscription, + outgoingCusProductIds, + }); + return { fullCustomer, fullProducts: [attachProduct], @@ -275,6 +301,8 @@ export const setupAttachBillingContext = async ({ currentEpochMs, billingCycleAnchorMs, resetCycleAnchorMs, + billingStartsAt, + subscriptionBackdateStartMs, requestedBillingCycleAnchor: params.billing_cycle_anchor, requestedProrationBehavior: setupIgnoreProrationBehavior({ isOneOffAttach: isOneOffProduct({ prices: attachProduct.prices }), @@ -286,6 +314,8 @@ export const setupAttachBillingContext = async ({ paymentBehaviorIntent, shouldFinalizeFirstInvoice, skipCustomPaymentMethodGuard: contextOverride.skipCustomPaymentMethodGuard, + skipExternalPSPGuard: contextOverride.skipExternalPSPGuard, + processorTypeOverride: contextOverride.processorTypeOverride, enablePlanImmediately: params.enable_plan_immediately ?? false, accessStartsAt, @@ -309,6 +339,9 @@ export const setupAttachBillingContext = async ({ skipBillingChanges, dryRunStripe: preview, + storedChargeLineItems, + storedRefundLineItems, + anchorResetRefund: setupAnchorResetRefund({ billingCycleAnchor: params.billing_cycle_anchor, prorationBehavior: params.proration_behavior, diff --git a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts index 2e034f662..b2102ab76 100644 --- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts +++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts @@ -15,6 +15,7 @@ import { setupAttachProductContext } from "@/internal/billing/v2/actions/attach/ import { setupAttachTransitionContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext"; import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; +import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling"; import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext"; @@ -107,10 +108,12 @@ export const setupImmediateMultiProductBillingContext = async ({ ctx, params, preview = false, + billingStartsAt, }: { ctx: AutumnContext; params: MultiAttachParamsV0; preview?: boolean; + billingStartsAt?: number; }): Promise => { const fullCustomer = await setupFullCustomerContext({ ctx, @@ -185,7 +188,7 @@ export const setupImmediateMultiProductBillingContext = async ({ createStripeCustomerIfMissing: !preview, }); - const invoiceMode = setupInvoiceModeContext({ params }); + const invoiceMode = await setupInvoiceModeContext({ ctx, params }); const currentEpochMs = testClockFrozenTime ?? Date.now(); const trialContext = await setupImmediateMultiProductTrialContext({ ctx, @@ -202,12 +205,15 @@ export const setupImmediateMultiProductBillingContext = async ({ newFullProduct: firstProduct, trialContext, currentEpochMs, + billingStartsAt, }); if (trialContext?.trialEndsAt) { billingCycleAnchorMs = trialContext.trialEndsAt; } + // Reset anchor derives from billingCycleAnchorMs, which setupBillingCycleAnchor + // already aligns to a backdated start. const resetCycleAnchorMs = setupResetCycleAnchor({ billingCycleAnchorMs, customerProduct: undefined, @@ -221,6 +227,17 @@ export const setupImmediateMultiProductBillingContext = async ({ (productContext) => productContext.customEnts, ); + const outgoingCusProductIds = productContexts + .map((pc) => pc.currentCustomerProduct?.id) + .filter((id): id is string => id != null); + const { storedChargeLineItems, storedRefundLineItems } = + await fetchStoredLineItemsForSubscriptionBilling({ + db: ctx.db, + fullCustomer, + stripeSubscription, + outgoingCusProductIds, + }); + return { fullCustomer, fullProducts, @@ -260,5 +277,7 @@ export const setupImmediateMultiProductBillingContext = async ({ params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }), checkoutSessionParams: params.checkout_session_params, dryRunStripe: preview, + storedChargeLineItems, + storedRefundLineItems, }; }; diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts index 469c42f2d..06699d4c7 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts @@ -34,6 +34,9 @@ export const computeScheduledCustomerProducts = ({ endsAt: phaseContext.endsAt, currentEpochMs: billingContext.currentEpochMs, externalId: productContext.externalId, + isCustom: + productContext.customPrices.length > 0 || + productContext.customEntitlements.length > 0, }); insertCustomerProducts.push(customerProduct); phaseCustomerProductIds.push(customerProduct.id); diff --git a/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts index 99b4d43c7..b5ecb2959 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts @@ -56,7 +56,11 @@ export const createSchedule = async ({ params, }); - await handleCreateScheduleErrors({ db: ctx.db, billingContext }); + await handleCreateScheduleErrors({ + db: ctx.db, + billingContext, + preview: false, + }); const { autumnBillingPlan, phases } = computeCreateSchedulePlan({ ctx, diff --git a/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts b/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts index 257c4d0a9..7ec688e4a 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts @@ -2,23 +2,20 @@ import { type CreateScheduleBillingContext, ErrCode, isFreeProduct, - ms, RecaseError, } from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle"; - -const FIRST_PHASE_TOLERANCE_MS = ms.minutes(15); +import { handleFirstPhaseStartDateErrors } from "./handleFirstPhaseStartDateErrors"; export const handleCreateScheduleErrors = async ({ db, billingContext, + preview = false, }: { db: DrizzleCli; billingContext: CreateScheduleBillingContext; + preview?: boolean; }) => { - const { currentEpochMs, immediatePhase, stripeSubscriptionSchedule } = - billingContext; - if ( billingContext.checkoutMode === "stripe_checkout" && billingContext.enablePlanImmediately && @@ -32,20 +29,7 @@ export const handleCreateScheduleErrors = async ({ }); } - // Updates reuse the existing schedule's current-phase start_date downstream - // (see executeStripeSubscriptionScheduleAction.buildAnchoredPhases), so the - // caller-supplied starts_at for phase 0 is effectively ignored. The - // immediate-start guard only makes sense on creation. - if ( - !stripeSubscriptionSchedule && - (immediatePhase.starts_at < currentEpochMs - FIRST_PHASE_TOLERANCE_MS || - immediatePhase.starts_at > currentEpochMs + FIRST_PHASE_TOLERANCE_MS) - ) { - throw new RecaseError({ - message: "The first phase must start immediately", - statusCode: 400, - }); - } + handleFirstPhaseStartDateErrors({ billingContext, preview }); const allImmediateProductsFree = billingContext.fullProducts.every( (product) => isFreeProduct({ prices: product.prices }), @@ -54,10 +38,9 @@ export const handleCreateScheduleErrors = async ({ if (allImmediateProductsFree && billingContext.stripeSubscription) { const subId = billingContext.stripeSubscription.id; - const productsOnSub = - billingContext.fullCustomer.customer_products.filter((cp) => - cp.subscription_ids?.includes(subId), - ); + const productsOnSub = billingContext.fullCustomer.customer_products.filter( + (cp) => cp.subscription_ids?.includes(subId), + ); const transitioningOutIds = new Set( billingContext.productContexts diff --git a/server/src/internal/billing/v2/actions/createSchedule/errors/handleFirstPhaseStartDateErrors.ts b/server/src/internal/billing/v2/actions/createSchedule/errors/handleFirstPhaseStartDateErrors.ts new file mode 100644 index 000000000..3d9ce3859 --- /dev/null +++ b/server/src/internal/billing/v2/actions/createSchedule/errors/handleFirstPhaseStartDateErrors.ts @@ -0,0 +1,86 @@ +import { + type CreateScheduleBillingContext, + ErrCode, + isProductPaidAndRecurring, + ms, + RecaseError, +} from "@autumn/shared"; +import { assertNoBackdateWithExistingSubscription } from "@/internal/billing/v2/utils/backdate/assertNoBackdateWithExistingSubscription"; +import { assertStripeBackdateInvoiceLineItemLimit } from "@/internal/billing/v2/utils/backdate/stripeBackdateInvoiceLimit"; + +const FIRST_PHASE_TOLERANCE_MS = ms.minutes(15); + +export const handleFirstPhaseStartDateErrors = ({ + billingContext, + preview = false, +}: { + billingContext: CreateScheduleBillingContext; + preview?: boolean; +}) => { + const { currentEpochMs, immediatePhase, stripeSubscriptionSchedule } = + billingContext; + + // Updates reuse the existing schedule's current-phase start_date downstream + // (see executeStripeSubscriptionScheduleAction.buildAnchoredPhases), so the + // caller-supplied starts_at for phase 0 is effectively ignored. The + // immediate-start guard only makes sense on creation. + const firstPhaseStartsInPast = + immediatePhase.starts_at < currentEpochMs - FIRST_PHASE_TOLERANCE_MS; + const firstPhaseStartsInFuture = + immediatePhase.starts_at > currentEpochMs + FIRST_PHASE_TOLERANCE_MS; + + if (!stripeSubscriptionSchedule && firstPhaseStartsInFuture) { + throw new RecaseError({ + message: "The first phase must start immediately", + statusCode: 400, + }); + } + + if (!stripeSubscriptionSchedule && firstPhaseStartsInPast) { + const allImmediateProductsPaidRecurring = + billingContext.fullProducts.length > 0 && + billingContext.fullProducts.every(isProductPaidAndRecurring); + + if (!allImmediateProductsPaidRecurring) { + throw new RecaseError({ + message: + "Past first phase starts_at is only supported for paid recurring plans.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + assertNoBackdateWithExistingSubscription({ + billingContext, + subject: "Past first phase starts_at", + }); + + // Previews don't yet know whether the caller will settle via invoice + // (which supports backdating) or Stripe Checkout (which doesn't), so only + // block the checkout path at execution time. + if (!preview && billingContext.checkoutMode === "stripe_checkout") { + throw new RecaseError({ + message: + "Past first phase starts_at cannot be used when Stripe Checkout is required.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + if (billingContext.trialContext?.trialEndsAt) { + throw new RecaseError({ + message: + "Past first phase starts_at cannot be used together with a free trial.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + assertStripeBackdateInvoiceLineItemLimit({ + products: billingContext.fullProducts, + startsAt: immediatePhase.starts_at, + currentEpochMs, + subject: "Past first phase starts_at", + }); + } +}; diff --git a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts index 481cfbf9c..26e787fe6 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts @@ -33,6 +33,7 @@ export const previewCreateScheduleWithContext = async ({ await handleCreateScheduleErrors({ db: ctx.db, billingContext, + preview: true, }); const { autumnBillingPlan } = computeCreateSchedulePlan({ diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts index 3d38fea39..165b4d389 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts @@ -3,6 +3,7 @@ import { type CreateScheduleBillingContext, type CreateScheduleParamsV0, isOneOffProduct, + isPastStartDate, isProductPaidAndRecurring, type MultiAttachParamsV0, } from "@autumn/shared"; @@ -92,6 +93,7 @@ export const setupCreateScheduleBillingContext = async ({ subscription_id: plan.subscription_id, })), invoice_mode: params.invoice_mode, + discounts: params.discounts, success_url: params.success_url, checkout_session_params: params.checkout_session_params, redirect_mode: params.redirect_mode ?? "if_required", @@ -102,6 +104,7 @@ export const setupCreateScheduleBillingContext = async ({ ctx, params: immediateParams, preview, + billingStartsAt: immediatePhase.starts_at, }); validateCreateSchedulePhasePlans({ @@ -144,6 +147,13 @@ export const setupCreateScheduleBillingContext = async ({ scheduledCustomEntitlements.length > 0, requestedProrationBehavior: params.billing_behavior, requestedBillingCycleAnchor: params.billing_cycle_anchor, + billingStartsAt: immediatePhase.starts_at, + subscriptionBackdateStartMs: isPastStartDate( + immediatePhase.starts_at, + billingContext.currentEpochMs, + ) + ? immediatePhase.starts_at + : undefined, immediatePhase, futurePhases, scheduledPhaseContexts, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts index ea5bb39c5..9333efb4e 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -9,6 +9,7 @@ import { computeDeleteCustomerProduct } from "@/internal/billing/v2/actions/upda import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; import { computePatchCustomerProductPlan } from "@/internal/billing/v2/compute/computePatchPlan"; +import { computeSchedulePhaseReplacements } from "@/internal/billing/v2/compute/computeSchedulePhaseReplacements"; import { applyOneOffPrepaidCarryOvers } from "@/internal/billing/v2/utils/handleOneOffPrepaidCarryOvers/applyOneOffPrepaidCarryOvers"; export const computeCustomPlan = async ({ @@ -57,6 +58,8 @@ export const computeCustomPlan = async ({ newCustomerProduct: newFullCustomerProduct, fullCustomer, }); + const isUpdatingScheduledProduct = + customerProduct.status === CusProductStatus.Scheduled; const { allLineItems } = buildAutumnLineItems({ ctx, @@ -77,13 +80,21 @@ export const computeCustomPlan = async ({ return { customerId: fullCustomer?.id ?? "", insertCustomerProducts: [newFullCustomerProduct], - updateCustomerProduct: { - customerProduct, - updates: { - status: CusProductStatus.Expired, - }, - }, - deleteCustomerProduct, + updateCustomerProduct: isUpdatingScheduledProduct + ? undefined + : { + customerProduct, + updates: { + status: CusProductStatus.Expired, + }, + }, + deleteCustomerProduct: isUpdatingScheduledProduct + ? customerProduct + : deleteCustomerProduct, + schedulePhaseCustomerProductReplacements: computeSchedulePhaseReplacements({ + oldCustomerProduct: customerProduct, + newCustomerProduct: newFullCustomerProduct, + }), customPrices, customEntitlements: [ ...(customEnts ?? []), diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts index 745cfb392..5be8355a6 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts @@ -81,7 +81,8 @@ export const computeCustomPlanNewCustomerProduct = ({ initOptions: { isCustom: updateSubscriptionContext.isCustom, subscriptionId: stripeSubscription?.id, // don't populate if it's starting in the future. - subscriptionScheduleId: stripeSubscriptionSchedule?.id, + subscriptionScheduleId: + stripeSubscriptionSchedule?.id ?? currentCustomerProduct.scheduled_ids?.[0], externalId: currentCustomerProduct.external_id ?? undefined, startsAt: currentCustomerProduct.starts_at ?? undefined, ...cancelFields, @@ -90,7 +91,7 @@ export const computeCustomPlanNewCustomerProduct = ({ ? { subscriptionId: params.processor_subscription_id } : {}), - ...(params.status ? { status: params.status } : {}), + status: params.status ?? currentCustomerProduct.status, onTrialEnd: trialContext?.onEnd ?? diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts index 7024f0c88..e646256dc 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts @@ -1,5 +1,6 @@ import type { BillingContext } from "@autumn/shared"; import { + BILLING_AMOUNT_EPSILON, type BillingPeriod, cloneEntitlementWithUpdatedQuantity, cusEntToCusPrice, @@ -8,6 +9,7 @@ import { type FullCusProduct, findPrepaidCustomerEntitlement, InternalError, + type LineItem, type LineItemContext, orgToCurrency, priceToProrationConfig, @@ -15,6 +17,7 @@ import { usagePriceToLineItem, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { getRefundLineItemsForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice"; export const computeUpdateQuantityLineItems = ({ ctx, @@ -88,7 +91,7 @@ export const computeUpdateQuantityLineItems = ({ customerProduct, }; - const refundLineItem = usagePriceToLineItem({ + const catalogRefundLineItem = usagePriceToLineItem({ cusEnt: prepaidCustomerEntitlement, context: { ...lineItemContext, @@ -100,6 +103,14 @@ export const computeUpdateQuantityLineItems = ({ }, }); + const refundLineItems = getRefundLineItemsForPrice({ + ctx, + customerProduct, + billingContext, + priceId: customerPrice.price.id, + catalogFallback: catalogRefundLineItem, + }); + const chargeLineItem = usagePriceToLineItem({ cusEnt: newCustomerEntitlement, context: lineItemContext, @@ -109,15 +120,16 @@ export const computeUpdateQuantityLineItems = ({ }, }); - // Don't return line items if they sum to 0 - if ( + const netAmount = Math.abs( sumValues([ - refundLineItem?.amountAfterDiscounts ?? 0, + ...refundLineItems.map((li) => li.amountAfterDiscounts ?? 0), chargeLineItem?.amountAfterDiscounts ?? 0, - ]) === 0 - ) { - return []; - } + ]), + ); - return [refundLineItem, chargeLineItem]; + if (netAmount < BILLING_AMOUNT_EPSILON) return []; + + return [...refundLineItems, chargeLineItem].filter( + (li): li is LineItem => li !== undefined, + ); }; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 1220e0b87..90754fea0 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -9,7 +9,9 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { setupDefaultProductContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext"; import { setupUpdateSubscriptionProductContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext"; +import { fetchStripeTaxRateForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeTaxRateForBilling"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext"; +import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling"; import { setupAdjustableQuantities } from "@/internal/billing/v2/setup/setupAdjustableQuantities"; import { setupAnchorResetRefund } from "@/internal/billing/v2/setup/setupAnchorResetRefund"; import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; @@ -112,6 +114,19 @@ export const setupUpdateSubscriptionBillingContext = async ({ createStripeCustomerIfMissing: !preview, }); + const subscriptionTaxRate = stripeSubscription?.default_tax_rates?.[0]; + const inheritedTaxRateId = + typeof subscriptionTaxRate === "string" + ? subscriptionTaxRate + : subscriptionTaxRate?.id; + const inheritedStripeTaxRate = + typeof subscriptionTaxRate === "string" + ? await fetchStripeTaxRateForBilling({ + ctx, + taxRateId: subscriptionTaxRate, + }) + : subscriptionTaxRate; + const currentEpochMs = testClockFrozenTime ?? Date.now(); // 1. Setup trial context first @@ -144,7 +159,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ newFullProduct: fullProduct, }); - const invoiceMode = setupInvoiceModeContext({ params }); + const invoiceMode = await setupInvoiceModeContext({ ctx, params }); const isCustom = contextOverride.forceIsCustom !== undefined ? contextOverride.forceIsCustom @@ -176,6 +191,14 @@ export const setupUpdateSubscriptionBillingContext = async ({ customerProduct, }); + const { storedChargeLineItems, storedRefundLineItems } = + await fetchStoredLineItemsForSubscriptionBilling({ + db: ctx.db, + fullCustomer, + stripeSubscription, + outgoingCusProductIds: [customerProduct.id], + }); + return { intent, fullCustomer, @@ -190,8 +213,9 @@ export const setupUpdateSubscriptionBillingContext = async ({ stripeSubscriptionSchedule, stripeDiscounts, stripeCustomer, - stripeTaxRate, + stripeTaxRate: stripeTaxRate ?? inheritedStripeTaxRate, paymentMethod, + taxRateId: inheritedTaxRateId, currentEpochMs, billingCycleAnchorMs, @@ -217,6 +241,9 @@ export const setupUpdateSubscriptionBillingContext = async ({ skipBillingChanges, dryRunStripe: preview, + storedChargeLineItems, + storedRefundLineItems, + checkoutMode, anchorResetRefund: setupAnchorResetRefund({ diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts index 6bf9e850e..1f52f6813 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts @@ -3,7 +3,6 @@ import { type FullCustomer, isCustomerProductFree, isFreeProduct, - notNullish, type UpdateSubscriptionBillingContextOverride, type UpdateSubscriptionV1Params, } from "@autumn/shared"; @@ -22,12 +21,14 @@ export const setupUpdateSubscriptionProductContext = async ({ params, contextOverride = {}, reusePricesAndEntitlements, + resetToCatalogVersion = false, }: { ctx: AutumnContext; fullCustomer: FullCustomer; params: UpdateSubscriptionV1Params; contextOverride?: UpdateSubscriptionBillingContextOverride; reusePricesAndEntitlements?: ReusePricesAndEntitlements; + resetToCatalogVersion?: boolean; }) => { const { productContext } = contextOverride; @@ -50,17 +51,22 @@ export const setupUpdateSubscriptionProductContext = async ({ }); let fullProduct = cusProductToProduct({ cusProduct: targetCustomerProduct }); + const requestedVersion = params.version; + const targetVersion = targetCustomerProduct.product.version; + const hasRequestedVersion = typeof requestedVersion === "number"; + const changesVersion = + hasRequestedVersion && + (requestedVersion < targetVersion || requestedVersion > targetVersion); + const shouldLoadCatalogVersion = + hasRequestedVersion && (resetToCatalogVersion || changesVersion); - if ( - notNullish(params.version) && - params.version !== targetCustomerProduct.product.version - ) { + if (shouldLoadCatalogVersion) { fullProduct = await ProductService.getFull({ db: ctx.db, idOrInternalId: targetCustomerProduct.product.id, orgId: ctx.org.id, env: ctx.env, - version: params.version, + version: requestedVersion, }); } diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts index 44a586912..c698ca2cb 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts @@ -5,6 +5,7 @@ import type { UpdateCustomerEntitlement, } from "@autumn/shared"; import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems"; +import { getRefundLineItems } from "@/internal/billing/v2/utils/lineItems/getRefundLineItems"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; import { customerProductToLineItems } from "../../utils/lineItems/customerProductToLineItems"; import { logBuildAutumnLineItems } from "./logBuildAutumnLineItems"; @@ -55,11 +56,10 @@ export const buildAutumnLineItems = ({ // Get line items for ongoing cus product const deletedLineItems = customerProductsToDelete.flatMap((customerProduct) => - customerProductToLineItems({ + getRefundLineItems({ ctx, customerProduct, billingContext, - direction: "refund", priceFilters: { excludeOneOffPrices: true }, }), ); diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts index f93ba10af..a8f9f3a9d 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts @@ -13,7 +13,7 @@ import { getDeleteCustomerProducts, getUpdateCustomerProducts, } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; -import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems"; +import { getLineItemsForDirection } from "@/internal/billing/v2/utils/lineItems/getLineItemsForDirection"; const formatLineItem = (item: LineItem) => ({ description: item.description, @@ -141,9 +141,9 @@ export const buildSharedSubscriptionTrialLineItems = ({ const lineItems: LineItem[] = []; for (const customerProduct of siblingCustomerProducts) { lineItems.push( - ...customerProductToLineItems({ + ...getLineItemsForDirection({ ctx, - customerProduct: customerProduct, + customerProduct, billingContext, direction, priceFilters: { excludeOneOffPrices: true }, diff --git a/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts index 906ed69e9..d5d18e2fd 100644 --- a/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts +++ b/server/src/internal/billing/v2/compute/computePatchPlan/computePatchCustomerProductPlan.ts @@ -1,10 +1,14 @@ import { type AutumnBillingPlan, CusProductStatus, + EntInterval, + getCycleEnd, + isBooleanEntitlement, type UpdateSubscriptionBillingContext, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; +import { computeSchedulePhaseReplacements } from "@/internal/billing/v2/compute/computeSchedulePhaseReplacements"; import { initPatchCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct"; export const computePatchCustomerProductPlan = ({ @@ -21,7 +25,11 @@ export const computePatchCustomerProductPlan = ({ throw new Error("Patch context is required to compute patch customer plan"); } - const { finalCustomerProduct, customerProductUpdates } = + const { + finalCustomerProduct, + customerProductUpdates, + oneOffPrepaidCarryOverCustomerEntitlements, + } = initPatchCustomerProduct({ ctx, billingContext: updateSubscriptionContext, @@ -43,21 +51,39 @@ export const computePatchCustomerProductPlan = ({ customEntitlements: patchContext.customEntitlements, customFreeTrial: trialContext?.customFreeTrial, lineItems: allLineItems, + insertCustomerEntitlements: oneOffPrepaidCarryOverCustomerEntitlements, + updateCustomerEntitlements: computeAnchorResetEntitlementUpdates({ + updateSubscriptionContext, + finalCustomerProduct, + }), } satisfies Partial; if (patchContext.mode === "new") { + const isUpdatingScheduledProduct = + patchContext.originalCustomerProduct.status === CusProductStatus.Scheduled; + return { ...basePlan, insertCustomerProducts: [finalCustomerProduct], - updateCustomerProduct: { - customerProduct: patchContext.originalCustomerProduct, - updates: { - status: CusProductStatus.Expired, - ended_at: Date.now(), - canceled: true, - canceled_at: Date.now(), - }, - }, + updateCustomerProduct: isUpdatingScheduledProduct + ? undefined + : { + customerProduct: patchContext.originalCustomerProduct, + updates: { + status: CusProductStatus.Expired, + ended_at: Date.now(), + canceled: true, + canceled_at: Date.now(), + }, + }, + deleteCustomerProduct: isUpdatingScheduledProduct + ? patchContext.originalCustomerProduct + : undefined, + schedulePhaseCustomerProductReplacements: + computeSchedulePhaseReplacements({ + oldCustomerProduct: patchContext.originalCustomerProduct, + newCustomerProduct: finalCustomerProduct, + }), } satisfies AutumnBillingPlan; } @@ -84,3 +110,34 @@ export const computePatchCustomerProductPlan = ({ ], } satisfies AutumnBillingPlan; }; + +const computeAnchorResetEntitlementUpdates = ({ + updateSubscriptionContext, + finalCustomerProduct, +}: { + updateSubscriptionContext: UpdateSubscriptionBillingContext; + finalCustomerProduct: UpdateSubscriptionBillingContext["customerProduct"]; +}): AutumnBillingPlan["updateCustomerEntitlements"] => { + if (updateSubscriptionContext.requestedBillingCycleAnchor !== "now") return []; + + return finalCustomerProduct.customer_entitlements + .filter((customerEntitlement) => { + const { entitlement } = customerEntitlement; + return ( + !isBooleanEntitlement({ entitlement }) && + entitlement.allowance !== null + ); + }) + .map((customerEntitlement) => ({ + customerEntitlement, + updates: { + next_reset_at: getCycleEnd({ + anchor: updateSubscriptionContext.resetCycleAnchorMs, + interval: + customerEntitlement.entitlement.interval ?? EntInterval.Month, + intervalCount: customerEntitlement.entitlement.interval_count, + now: updateSubscriptionContext.currentEpochMs, + }), + }, + })); +}; diff --git a/server/src/internal/billing/v2/compute/computeSchedulePhaseReplacements.ts b/server/src/internal/billing/v2/compute/computeSchedulePhaseReplacements.ts new file mode 100644 index 000000000..933175255 --- /dev/null +++ b/server/src/internal/billing/v2/compute/computeSchedulePhaseReplacements.ts @@ -0,0 +1,24 @@ +import { + type AutumnBillingPlan, + CusProductStatus, + type FullCusProduct, +} from "@autumn/shared"; + +export const computeSchedulePhaseReplacements = ({ + oldCustomerProduct, + newCustomerProduct, +}: { + oldCustomerProduct: FullCusProduct; + newCustomerProduct: FullCusProduct; +}): AutumnBillingPlan["schedulePhaseCustomerProductReplacements"] => { + if (oldCustomerProduct.status !== CusProductStatus.Scheduled) return undefined; + + return [ + { + oldCustomerProductId: oldCustomerProduct.id, + newCustomerProductId: newCustomerProduct.id, + internalCustomerId: oldCustomerProduct.internal_customer_id, + internalEntityId: oldCustomerProduct.internal_entity_id, + }, + ]; +}; diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index 780890629..3cf1073eb 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -11,6 +11,7 @@ import { } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { replaceScheduledPhaseCustomerProductIds } from "@/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds"; import { invoiceActions } from "@/internal/invoices/actions"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService"; @@ -92,6 +93,11 @@ export const executeAutumnBillingPlan = async ({ newCusProducts: insertCustomerProducts, }); + await replaceScheduledPhaseCustomerProductIds({ + ctx, + replacements: autumnBillingPlan.schedulePhaseCustomerProductReplacements, + }); + // 3. Update customer product (DB only) for (const { customerProduct, updates } of updateCustomerProducts) { // Skip empty updates — drizzle throws "No values to set" on empty SET. diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts index 6d1806fb3..7d213acb9 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts @@ -12,6 +12,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { buildCustomerProductsForStripe } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildCustomerProductsForStripe"; import { buildStripeRefundAction } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildStripeRefundAction.js"; import { buildStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction"; +import { validateStripeSubscriptionActionOwnership } from "@/internal/billing/v2/providers/stripe/utils/connect/validateStripeSubscriptionActionOwnership"; import { shouldCreateManualStripeInvoice } from "@/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice"; import { autumnBillingPlanToFinalFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer"; import { buildStripeCheckoutSessionAction } from "../../../providers/stripe/actionBuilders/buildStripeCheckoutSessionAction"; @@ -74,6 +75,12 @@ export const evaluateStripeBillingPlan = async ({ subscriptionStartsAt, }); + validateStripeSubscriptionActionOwnership({ + ctx, + billingContext, + stripeSubscriptionAction, + }); + const stripeRefundAction = await buildStripeRefundAction({ ctx, autumnBillingPlan, diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts index e436f3fdb..14b5e8fde 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts @@ -4,13 +4,14 @@ import type { Invoice, StripeBillingPlanResult, } from "@autumn/shared"; -import { ms, StripeBillingStage } from "@autumn/shared"; +import { StripeBillingStage } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { shouldDeferBillingPlan } from "@/internal/billing/v2/providers/stripe/utils/common/shouldDeferBillingPlan"; import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling"; import { isDeferredInvoiceMode } from "@/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode"; import { invoiceActions } from "@/internal/invoices/actions"; import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan"; +import { getDeferredBillingMetadataExpiresAt } from "./getDeferredBillingMetadataExpiresAt"; export const executeStripeInvoiceAction = async ({ ctx, @@ -59,10 +60,10 @@ export const executeStripeInvoiceAction = async ({ billingPlan, billingContext, stripeInvoice: invoice, - expiresAt: - deferredInvoiceMode || billingContext.paymentMethod?.type === "custom" - ? Date.now() + ms.days(10) - : Date.now() + ms.minutes(10), + expiresAt: getDeferredBillingMetadataExpiresAt({ + deferredInvoiceMode, + paymentMethod: billingContext.paymentMethod, + }), resumeAfter: StripeBillingStage.InvoiceAction, }); diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts index 76676dcd8..e3dc3ada6 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts @@ -4,7 +4,7 @@ import type { Invoice, StripeBillingPlanResult, } from "@autumn/shared"; -import { ms, StripeBillingStage, tryCatch } from "@autumn/shared"; +import { StripeBillingStage, tryCatch } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli"; import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import { setStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils"; @@ -12,6 +12,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan"; import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan"; import { shouldDeferBillingPlan } from "@/internal/billing/v2/providers/stripe/utils/common/shouldDeferBillingPlan"; +import { applyTemplateToDraft } from "@/internal/billing/v2/providers/stripe/utils/invoices/applyTemplateToDraft"; import { finalizeStripeInvoice } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps"; import { executeStripeSubscriptionOperation } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation"; import { getLatestInvoiceFromSubscriptionAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/getLatestInvoiceFromSubscriptionAction"; @@ -20,6 +21,7 @@ import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upser import { invoiceActions } from "@/internal/invoices/actions"; import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan"; import { isDeferredInvoiceMode } from "../../../utils/billingContext/isDeferredInvoiceMode"; +import { getDeferredBillingMetadataExpiresAt } from "./getDeferredBillingMetadataExpiresAt"; export const executeStripeSubscriptionAction = async ({ ctx, @@ -66,6 +68,14 @@ export const executeStripeSubscriptionAction = async ({ billingContext, }); + latestStripeInvoice = await applyTemplateToDraft({ + ctx, + stripeCli, + invoice: latestStripeInvoice, + footer: billingContext.invoiceMode?.footer, + memo: billingContext.invoiceMode?.memo, + }); + // Honor either the new internal flag (set by attach via setupFinalizeFirstInvoice) // or the public invoice_mode.finalize for the other actions (updateSubscription, // multiAttach, createSchedule) that don't wire setupFinalizeFirstInvoice. @@ -147,9 +157,10 @@ export const executeStripeSubscriptionAction = async ({ billingPlan, billingContext: deferredBillingContext, stripeInvoice: latestStripeInvoice, - expiresAt: deferredInvoiceMode - ? Date.now() + ms.days(10) - : Date.now() + ms.minutes(10), + expiresAt: getDeferredBillingMetadataExpiresAt({ + deferredInvoiceMode, + paymentMethod: billingContext.paymentMethod, + }), resumeAfter: StripeBillingStage.SubscriptionAction, }); diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts index eab92010f..7c46d271f 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts @@ -21,6 +21,7 @@ const toCreatePhase = ( ...(item.metadata && { metadata: item.metadata }), })), end_date: typeof phase.end_date === "number" ? phase.end_date : undefined, + proration_behavior: phase.proration_behavior, discounts: phase.discounts as | Stripe.SubscriptionScheduleCreateParams.Phase.Discount[] | undefined, diff --git a/server/src/internal/billing/v2/providers/stripe/execute/getDeferredBillingMetadataExpiresAt.ts b/server/src/internal/billing/v2/providers/stripe/execute/getDeferredBillingMetadataExpiresAt.ts new file mode 100644 index 000000000..27b0a6396 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/execute/getDeferredBillingMetadataExpiresAt.ts @@ -0,0 +1,17 @@ +import { ms } from "@autumn/shared"; + +export const getDeferredBillingMetadataExpiresAt = ({ + deferredInvoiceMode, + paymentMethod, + now = Date.now(), +}: { + deferredInvoiceMode: boolean; + paymentMethod?: { type?: string } | null; + now?: number; +}) => { + if (deferredInvoiceMode || paymentMethod?.type === "custom") { + return null; + } + + return now + ms.minutes(10); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/connect/validateStripeSubscriptionActionOwnership.ts b/server/src/internal/billing/v2/providers/stripe/utils/connect/validateStripeSubscriptionActionOwnership.ts new file mode 100644 index 000000000..e6f05fe24 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/connect/validateStripeSubscriptionActionOwnership.ts @@ -0,0 +1,68 @@ +import type { BillingContext, StripeSubscriptionAction } from "@autumn/shared"; +import { AppEnv, ErrCode, InternalError } from "@autumn/shared"; +import { stripeSubscriptionToApplication } from "@/external/stripe/subscriptions/utils/convertStripeSubscription"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { isStripeConnected } from "@/internal/orgs/orgUtils"; + +const expectedStripeApplicationId = ({ ctx }: { ctx: AutumnContext }) => + ctx.env === AppEnv.Live + ? process.env.STRIPE_LIVE_CLIENT_ID + : process.env.STRIPE_SANDBOX_CLIENT_ID; + +const shouldValidateStripeApplicationOwnership = ({ + ctx, +}: { + ctx: AutumnContext; +}) => + isStripeConnected({ + org: ctx.org, + env: ctx.env, + throughAccountId: true, + }) && + !isStripeConnected({ + org: ctx.org, + env: ctx.env, + throughSecretKey: true, + }); + +export const validateStripeSubscriptionActionOwnership = ({ + ctx, + billingContext, + stripeSubscriptionAction, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + stripeSubscriptionAction?: StripeSubscriptionAction; +}) => { + if (!shouldValidateStripeApplicationOwnership({ ctx })) return; + + if ( + stripeSubscriptionAction?.type !== "update" && + stripeSubscriptionAction?.type !== "cancel" + ) { + return; + } + + const applicationId = stripeSubscriptionToApplication({ + stripeSubscription: billingContext.stripeSubscription, + }); + if (!applicationId) return; + + const expectedApplicationId = expectedStripeApplicationId({ ctx }); + if (!expectedApplicationId) return; + + if (applicationId === expectedApplicationId) { + return; + } + + throw new InternalError({ + message: + "Cannot update subscription because it was not created by Autumn. Import or relink the subscription with no_billing_changes before changing billing.", + code: ErrCode.InvalidRequest, + statusCode: 400, + data: { + stripe_subscription_id: billingContext.stripeSubscription?.id, + stripe_application_id: applicationId, + }, + }); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts index b3ee0d989..0a6831bd5 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyAmountOffDiscountToLineItems.ts @@ -8,6 +8,7 @@ import { import { Decimal } from "decimal.js"; import { addDiscountTagToDescription } from "./addDiscountTagToDescription"; import { discountAppliesToLineItem } from "./discountAppliesToLineItem"; +import { getBackdatedDiscountCycleCount } from "./getBackdatedDiscountCycleCount"; /** * Applies an amount_off discount to line items. @@ -32,7 +33,7 @@ export const applyAmountOffDiscountToLineItems = ({ } // Convert from Stripe cents to Autumn dollars - const discountAmountOff = stripeToAtmnAmount({ + const baseDiscountAmountOff = stripeToAtmnAmount({ amount: amountOffCents, currency: coupon.currency ?? "usd", }); @@ -45,6 +46,15 @@ export const applyAmountOffDiscountToLineItems = ({ if (applicableChargeItems.length === 0) return lineItems; + const eligibleCycleCount = Math.max( + ...applicableChargeItems.map((item) => + getBackdatedDiscountCycleCount({ lineItem: item, coupon }), + ), + ); + if (eligibleCycleCount <= 0) return lineItems; + + const discountAmountOff = baseDiscountAmountOff; + // Build a map of line item -> discount amount const discountMap = new Map(); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts index 7711af0d4..594d0e984 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/applyPercentOffDiscountToLineItems.ts @@ -6,6 +6,7 @@ import type { import { Decimal } from "decimal.js"; import { addDiscountTagToDescription } from "./addDiscountTagToDescription"; import { discountAppliesToLineItem } from "./discountAppliesToLineItem"; +import { getBackdatedDiscountCycleCount } from "./getBackdatedDiscountCycleCount"; /** * Applies a percent_off discount to line items. @@ -38,12 +39,23 @@ export const applyPercentOffDiscountToLineItems = ({ // Use current amountAfterDiscounts as base for multiplicative stacking // If no previous discounts, amountAfterDiscounts equals amount const currentAmount = item.amountAfterDiscounts ?? item.amount; + const eligibleCycles = getBackdatedDiscountCycleCount({ + lineItem: item, + coupon, + }); + if (eligibleCycles <= 0) return item; + + const discountableAmount = item.context.backdate + ? new Decimal(Math.abs(currentAmount)) + .div(item.context.backdate.cycleCount) + .mul(eligibleCycles) + .toNumber() + : Math.abs(currentAmount); // Calculate discount amount: |currentAmount| * (percentOff / 100) - const itemDiscount = new Decimal(Math.abs(currentAmount)) + const itemDiscount = new Decimal(discountableAmount) .times(percentOff) .dividedBy(100) - .round() .toNumber(); if (itemDiscount === 0) return item; @@ -64,9 +76,10 @@ export const applyPercentOffDiscountToLineItems = ({ 0, ); - const description = item.context.discountable - ? item.description // if discountable, stripe applies discount, don't need our own tag - : addDiscountTagToDescription({ description: item.description }); + const description = + item.context.discountable || options.skipDescriptionTag + ? item.description + : addDiscountTagToDescription({ description: item.description }); return { ...item, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/filterStripeDiscountsForNextCycle.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/filterStripeDiscountsForNextCycle.ts index 8bf3764c5..9402f8a4a 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/filterStripeDiscountsForNextCycle.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/filterStripeDiscountsForNextCycle.ts @@ -1,9 +1,4 @@ -import { - formatMs, - formatSeconds, - type StripeDiscountWithCoupon, - secondsToMs, -} from "@autumn/shared"; +import { type StripeDiscountWithCoupon, secondsToMs } from "@autumn/shared"; import { addMonths } from "date-fns"; /** @@ -13,16 +8,19 @@ export const filterStripeDiscountsForNextCycle = ({ stripeDiscounts, currentEpochMs, nextCycleStart, + discountStartMs, }: { stripeDiscounts: StripeDiscountWithCoupon[]; currentEpochMs: number; nextCycleStart: number; + discountStartMs?: number; }) => { return stripeDiscounts.filter((discount) => stripeDiscountAppliesToNextCycle({ discount, currentEpochMs, nextCycleStart, + discountStartMs, }), ); }; @@ -34,16 +32,13 @@ const stripeDiscountAppliesToNextCycle = ({ discount, currentEpochMs, nextCycleStart, + discountStartMs, }: { discount: StripeDiscountWithCoupon; currentEpochMs: number; nextCycleStart: number; + discountStartMs?: number; }) => { - console.log("Discount:", { - id: discount.id, - end: formatSeconds(discount.end), - }); - console.log("Next cycle start:", formatMs(nextCycleStart)); if (discount.id) { if (discount.end == null) return true; return secondsToMs(discount.end) > nextCycleStart; @@ -64,7 +59,7 @@ const stripeDiscountAppliesToNextCycle = ({ if (durationInMonths <= 0) return false; const freshDiscountEndsAt = addMonths( - new Date(currentEpochMs), + new Date(discountStartMs ?? currentEpochMs), durationInMonths, ).getTime(); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/getBackdatedDiscountCycleCount.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/getBackdatedDiscountCycleCount.ts new file mode 100644 index 000000000..558d7b2ec --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/getBackdatedDiscountCycleCount.ts @@ -0,0 +1,48 @@ +import { + addInterval, + BillingInterval, + type LineItem, +} from "@autumn/shared"; +import type Stripe from "stripe"; + +export const getBackdatedDiscountCycleCount = ({ + lineItem, + coupon, +}: { + lineItem: LineItem; + coupon: Stripe.Coupon; +}): number => { + const backdate = lineItem.context.backdate; + if (!backdate) return 1; + + const { startsAt, cycleCount } = backdate; + if (coupon.duration === "forever") return cycleCount; + if (coupon.duration === "once") return 1; + + if (coupon.duration !== "repeating") return 1; + + const durationInMonths = coupon.duration_in_months ?? 0; + if (durationInMonths <= 0) return 0; + + const discountEndsAt = addInterval({ + from: startsAt, + interval: BillingInterval.Month, + intervalCount: durationInMonths, + }); + + let eligibleCycles = 0; + let cycleStart = startsAt; + + while (eligibleCycles < cycleCount && cycleStart < discountEndsAt) { + eligibleCycles += 1; + const nextCycleStart = addInterval({ + from: cycleStart, + interval: lineItem.context.price.config.interval, + intervalCount: lineItem.context.price.config.interval_count ?? 1, + }); + if (nextCycleStart <= cycleStart) break; + cycleStart = nextCycleStart; + } + + return Math.min(eligibleCycles, cycleCount); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts index 2921e340d..5bbf3b75d 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts @@ -133,8 +133,11 @@ const mergeStripeAndBillingLineItems = ({ // amount to Stripe. So stripeLineItem.amount is already discounted and discount_amounts is empty. // In this case, use Autumn's original pre-discount amount and discount breakdown. const autumnDiscountable = context.discountable ?? true; + const isBackdatedAggregate = context.backdate !== undefined; const hasAutumnDiscounts = - !autumnDiscountable && primaryLineItem.discounts.length > 0; + !isBackdatedAggregate && + !autumnDiscountable && + primaryLineItem.discounts.length > 0; let amount: number; let amountAfterDiscounts: number; @@ -222,7 +225,8 @@ const mergeStripeAndBillingLineItems = ({ // For multi-item groups, use Stripe description (has tier info); otherwise use Autumn const useStripeDescription = - isMultiItemGroup && stripeLineItem.description !== null; + (isMultiItemGroup || isBackdatedAggregate) && + stripeLineItem.description !== null; const description = useStripeDescription ? (stripeLineItem.description as string) : (primaryLineItem.description ?? ""); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/applyTemplateToDraft.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/applyTemplateToDraft.ts new file mode 100644 index 000000000..7d1189a4f --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/applyTemplateToDraft.ts @@ -0,0 +1,30 @@ +import type Stripe from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { updateStripeInvoice } from "./stripeInvoiceOps"; + +export const applyTemplateToDraft = async ({ + ctx, + stripeCli, + invoice, + footer, + memo, +}: { + ctx: AutumnContext; + stripeCli: Stripe; + invoice: Stripe.Invoice | undefined; + footer: string | undefined; + memo: string | undefined; +}): Promise => { + if (!invoice || invoice.status !== "draft" || (!footer && !memo)) { + return invoice; + } + ctx.logger.debug(`[execSubAction] Applying invoice template fields`); + return updateStripeInvoice({ + stripeCli, + invoiceId: invoice.id, + params: { + ...(footer ? { footer } : {}), + ...(memo ? { description: memo } : {}), + }, + }); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts index fcf02bc64..0e0066b1f 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts @@ -81,8 +81,7 @@ export const createInvoiceForBilling = async ({ ...(vercelInstallationId ? { vercel_installation_id: vercelInstallationId, - vercel_billing_plan_id: - billingContext.fullProducts?.[0]?.id ?? "", + vercel_billing_plan_id: billingContext.fullProducts?.[0]?.id ?? "", } : {}), }, @@ -100,6 +99,9 @@ export const createInvoiceForBilling = async ({ ? undefined : billingContext.stripeSubscription?.id, collectionMethod, + daysUntilDue: invoiceMode?.daysUntilDue, + footer: invoiceMode?.footer, + description: invoiceMode?.memo, metadata: invoiceMetadata, discounts: stripeDiscountsToInvoiceParams({ stripeDiscounts: invoiceEligibleStripeDiscounts, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts index dffef79ee..6953934fc 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts @@ -15,6 +15,7 @@ type CreateInvoiceParams = { collectionMethod?: "charge_automatically" | "send_invoice"; daysUntilDue?: number; description?: string; + footer?: string; metadata?: Stripe.InvoiceCreateParams["metadata"]; automaticTax?: boolean; }; @@ -27,6 +28,7 @@ export const createStripeInvoice = async ({ collectionMethod = "charge_automatically", daysUntilDue, description, + footer, metadata, discounts, automaticTax, @@ -37,6 +39,7 @@ export const createStripeInvoice = async ({ ...(stripeSubId ? { subscription: stripeSubId } : {}), ...(currency ? { currency } : {}), ...(description ? { description } : {}), + ...(footer ? { footer } : {}), ...(metadata ? { metadata } : {}), collection_method: collectionMethod, days_until_due: @@ -70,6 +73,26 @@ export const addStripeInvoiceLines = async ({ return invoice; }; +// ============================================ +// Update Invoice +// ============================================ + +type UpdateInvoiceParams = { + stripeCli: Stripe; + invoiceId: string; + params: Stripe.InvoiceUpdateParams; +}; + +export const updateStripeInvoice = async ({ + stripeCli, + invoiceId, + params, +}: UpdateInvoiceParams): Promise => { + const invoice = await stripeCli.invoices.update(invoiceId, params); + + return invoice; +}; + // ============================================ // Finalize Invoice // ============================================ diff --git a/server/src/internal/billing/v2/providers/stripe/utils/matchUtils/stripePriceShape.ts b/server/src/internal/billing/v2/providers/stripe/utils/matchUtils/stripePriceShape.ts index ef2baebb0..5dd503003 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/matchUtils/stripePriceShape.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/matchUtils/stripePriceShape.ts @@ -3,18 +3,28 @@ import type Stripe from "stripe"; type InlinePriceLike = { product?: string; currency?: string; + billing_scheme?: string; + tax_behavior?: string | null; recurring?: { interval?: string; interval_count?: number; }; + transform_quantity?: { + divide_by?: number; + round?: string; + } | null; unit_amount_decimal?: string | number | null; }; export type StripePriceShape = { product?: string; currency?: string; + billingScheme?: string; + taxBehavior?: string | null; interval?: string; intervalCount?: number; + tiersMode?: string | null; + transformQuantity?: string; unitAmountDecimal?: string; }; @@ -30,6 +40,18 @@ const decimalAmount = (amount: string | number | null | undefined) => { return String(amount); }; +const taxBehavior = (value?: string | null) => value ?? "unspecified"; + +const transformQuantityKey = ( + transformQuantity?: { + divide_by?: number | null; + round?: string | null; + } | null, +) => { + if (!transformQuantity) return undefined; + return `${transformQuantity.divide_by ?? ""}:${transformQuantity.round ?? ""}`; +}; + export const stripePriceToShape = ({ price, }: { @@ -37,8 +59,12 @@ export const stripePriceToShape = ({ }): StripePriceShape => ({ product: stripeProductId(price.product), currency: price.currency, + billingScheme: price.billing_scheme ?? "per_unit", + taxBehavior: taxBehavior(price.tax_behavior), interval: price.recurring?.interval, intervalCount: price.recurring?.interval_count, + tiersMode: price.tiers_mode ?? undefined, + transformQuantity: transformQuantityKey(price.transform_quantity), unitAmountDecimal: decimalAmount(price.unit_amount_decimal), }); @@ -49,8 +75,11 @@ export const inlinePriceToShape = ({ }): StripePriceShape => ({ product: price.product, currency: price.currency, + billingScheme: price.billing_scheme ?? "per_unit", + taxBehavior: taxBehavior(price.tax_behavior), interval: price.recurring?.interval, intervalCount: price.recurring?.interval_count, + transformQuantity: transformQuantityKey(price.transform_quantity), unitAmountDecimal: decimalAmount(price.unit_amount_decimal), }); @@ -60,6 +89,10 @@ export const stripePriceShapesEqual = ( ) => left.product === right.product && left.currency === right.currency && + left.billingScheme === right.billingScheme && + left.taxBehavior === right.taxBehavior && left.interval === right.interval && left.intervalCount === right.intervalCount && + left.tiersMode === right.tiersMode && + left.transformQuantity === right.transformQuantity && left.unitAmountDecimal === right.unitAmountDecimal; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts index 6cdd992b5..8b3d1f8e2 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts @@ -13,26 +13,7 @@ import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/provider import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs"; import { buildTransitionPoints } from "./buildTransitionPoints"; import { logTransitionPoints } from "./logBuildPhaseHelpers"; - -/** - * Normalizes customer product timestamps to second-level precision. - * This ensures consistency with Stripe's second-based timestamps. - */ -const normalizeCustomerProductTimestamps = ( - customerProduct: FullCusProduct, -): FullCusProduct => ({ - ...customerProduct, - starts_at: truncateMsToSecondPrecision(customerProduct.starts_at), - ended_at: customerProduct.ended_at - ? truncateMsToSecondPrecision(customerProduct.ended_at) - : undefined, - billing_cycle_anchor_resets_at: - customerProduct.billing_cycle_anchor_resets_at - ? truncateMsToSecondPrecision( - customerProduct.billing_cycle_anchor_resets_at, - ) - : customerProduct.billing_cycle_anchor_resets_at, -}); +import { normalizeCustomerProductTimestamps } from "./normalizeCustomerProductTimestamps"; /** * Converts customer products to Stripe schedule phase items. @@ -250,6 +231,10 @@ export const buildStripePhasesUpdate = ({ const phaseStartDateSeconds = msToSeconds(startMs); const isBillingCycleAnchorResetPhase = billingCycleAnchorResetAt === startMs; + const shouldInvoicePhaseTransition = + phaseIndex > 0 && phaseItems.length > 0; + const shouldAlwaysInvoice = + shouldInvoicePhaseTransition || isBillingCycleAnchorResetPhase; const phase: Stripe.SubscriptionScheduleUpdateParams.Phase = { items: phaseItems, start_date: phaseStartDateSeconds, @@ -258,9 +243,7 @@ export const buildStripePhasesUpdate = ({ billing_cycle_anchor: isBillingCycleAnchorResetPhase ? "phase_start" : undefined, - proration_behavior: isBillingCycleAnchorResetPhase - ? "always_invoice" - : undefined, + proration_behavior: shouldAlwaysInvoice ? "always_invoice" : undefined, discounts: stripeDiscountsToPhaseDiscounts({ stripeDiscounts: billingContext.stripeDiscounts, phaseStartDateSeconds, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/normalizeCustomerProductTimestamps.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/normalizeCustomerProductTimestamps.ts new file mode 100644 index 000000000..0e555ba15 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/normalizeCustomerProductTimestamps.ts @@ -0,0 +1,22 @@ +import { + type FullCusProduct, + truncateMsToSecondPrecision, +} from "@autumn/shared"; + +export const normalizeCustomerProductTimestamps = ( + customerProduct: FullCusProduct, +): FullCusProduct => ({ + ...customerProduct, + starts_at: truncateMsToSecondPrecision(customerProduct.starts_at), + ended_at: customerProduct.ended_at + ? truncateMsToSecondPrecision(customerProduct.ended_at) + : undefined, + billing_cycle_anchor_resets_at: customerProduct.billing_cycle_anchor_resets_at + ? truncateMsToSecondPrecision( + customerProduct.billing_cycle_anchor_resets_at, + ) + : customerProduct.billing_cycle_anchor_resets_at, + trial_ends_at: customerProduct.trial_ends_at + ? truncateMsToSecondPrecision(customerProduct.trial_ends_at) + : customerProduct.trial_ends_at, +}); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts index 55adcabe9..9e93e88c1 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts @@ -46,6 +46,10 @@ export const buildStripeSubscriptionCreateAction = ({ billing_mode: { type: "flexible" }, + backdate_start_date: billingContext.subscriptionBackdateStartMs + ? msToSeconds(billingContext.subscriptionBackdateStartMs) + : undefined, + collection_method: "charge_automatically", payment_behavior: billingContext.paymentBehaviorIntent ?? diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts index 4f6c9a478..90bfe19fd 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts @@ -23,7 +23,7 @@ export const executeStripeSubscriptionOperation = async ({ const invoiceModeParams = billingContext.invoiceMode ? { collection_method: "send_invoice" as const, - days_until_due: 30, + days_until_due: billingContext.invoiceMode.daysUntilDue ?? 30, } : {}; diff --git a/server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts new file mode 100644 index 000000000..3f865f512 --- /dev/null +++ b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts @@ -0,0 +1,41 @@ +import type { DbInvoiceLineItem } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos"; + +const deduplicateById = (rows: DbInvoiceLineItem[]): DbInvoiceLineItem[] => { + const seen = new Set(); + return rows.filter((row) => { + if (seen.has(row.id)) return false; + seen.add(row.id); + return true; + }); +}; + +export const fetchStoredLineItemsForBilling = async ({ + db, + customerProductIds, +}: { + db: DrizzleCli; + customerProductIds: string[]; +}): Promise<{ + storedChargeLineItems: DbInvoiceLineItem[]; + storedRefundLineItems: DbInvoiceLineItem[]; +}> => { + if (customerProductIds.length === 0) { + return { storedChargeLineItems: [], storedRefundLineItems: [] }; + } + + const allRows = await invoiceLineItemRepo.getByCustomerProductIds({ + db, + customerProductIds, + }); + + return { + storedChargeLineItems: deduplicateById( + allRows.filter((row) => row.direction === "charge"), + ), + storedRefundLineItems: deduplicateById( + allRows.filter((row) => row.direction === "refund"), + ), + }; +}; diff --git a/server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts new file mode 100644 index 000000000..2ee3e06aa --- /dev/null +++ b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts @@ -0,0 +1,27 @@ +import type { FullCustomer } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import { fetchStoredLineItemsForBilling } from "./fetchStoredLineItemsForBilling"; +import { getSiblingCusProductIds } from "./getSiblingCusProductIds"; + +export const fetchStoredLineItemsForSubscriptionBilling = async ({ + db, + fullCustomer, + stripeSubscription, + outgoingCusProductIds, +}: { + db: DrizzleCli; + fullCustomer: FullCustomer; + stripeSubscription?: Stripe.Subscription; + outgoingCusProductIds: string[]; +}) => { + const siblingIds = getSiblingCusProductIds({ + fullCustomer, + stripeSubscription, + excludeIds: outgoingCusProductIds, + }); + return fetchStoredLineItemsForBilling({ + db, + customerProductIds: [...outgoingCusProductIds, ...siblingIds], + }); +}; diff --git a/server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts b/server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts new file mode 100644 index 000000000..b6daf5906 --- /dev/null +++ b/server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts @@ -0,0 +1,26 @@ +import { cp, type FullCustomer } from "@autumn/shared"; +import type Stripe from "stripe"; + +export const getSiblingCusProductIds = ({ + fullCustomer, + stripeSubscription, + excludeIds = [], +}: { + fullCustomer: FullCustomer; + stripeSubscription?: Stripe.Subscription; + excludeIds?: string[]; +}): string[] => { + if (!stripeSubscription) return []; + + const excluded = new Set(excludeIds); + + return fullCustomer.customer_products + .filter( + (cusProduct) => + !excluded.has(cusProduct.id) && + cp(cusProduct).paid().recurring().onStripeSubscription({ + stripeSubscriptionId: stripeSubscription.id, + }).valid, + ) + .map((cusProduct) => cusProduct.id); +}; diff --git a/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts b/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts index fd08629ba..a929778d8 100644 --- a/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts +++ b/server/src/internal/billing/v2/setup/patch/handleCustomizeUpdateItems.ts @@ -1,3 +1,9 @@ +import { + ErrCode, + RecaseError, + ResetInterval, + resetIntvToEntIntv, +} from "@autumn/shared"; import type { CustomizePlanV1, Entitlement, @@ -12,6 +18,7 @@ import type { import { planItemFilterMatchesCustomerPair } from "@shared/api/products/items/utils/match"; import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; import { customerPriceToCustomerEntitlement } from "@shared/utils/cusPriceUtils/convertCustomerPrice/customerPriceToCustomerEntitlement"; +import { StatusCodes } from "http-status-codes"; import { generateId } from "@/utils/genUtils"; type CustomerProductItemPair = { @@ -49,20 +56,47 @@ const getCustomerProductItemPairs = ({ return pairs; }; +const assertAllowedIntervalUpdate = ({ + customerPrice, + overrides, +}: { + customerPrice?: FullCustomerPrice; + overrides: UpdatePlanItemParamsV1; +}) => { + if (overrides.interval === undefined || !customerPrice) return; + + throw new RecaseError({ + message: + "update_items cannot change intervals for paid items. Use remove_items and add_items instead.", + code: ErrCode.InvalidProductItem, + statusCode: StatusCodes.BAD_REQUEST, + }); +}; + const applyOverridesToEntitlement = ({ source, + customerPrice, overrides, }: { source: Entitlement; + customerPrice?: FullCustomerPrice; overrides: UpdatePlanItemParamsV1; -}): Entitlement => ({ - ...source, - id: generateId("ent"), - is_custom: true, - created_at: Date.now(), - allowance: - overrides.included !== undefined ? overrides.included : source.allowance, -}); +}): Entitlement => { + assertAllowedIntervalUpdate({ customerPrice, overrides }); + + return { + ...source, + id: generateId("ent"), + is_custom: true, + created_at: Date.now(), + allowance: + overrides.included !== undefined ? overrides.included : source.allowance, + interval: + overrides.interval !== undefined + ? resetIntvToEntIntv({ resetIntv: overrides.interval }) + : source.interval, + }; +}; const applyOverridesToPrice = ({ source, @@ -78,13 +112,8 @@ const applyOverridesToPrice = ({ entitlement_id: newEntitlementId, }); -/** - * Patch existing items in place. For each `update_items[i]`, find matching - * customer-entitlement / customer-price pairs on the target customer product, - * clone the underlying entitlement (and price, if any) with the overrides - * applied, and emit them as delete + add buckets. Existing usage and rollovers - * carry forward via the shared patch carry plumbing. - */ +/** Patch existing items in place by emitting matched items as delete + add buckets. + * Existing usage and rollovers carry forward via patch carry links. */ export const handleCustomizeUpdateItems = ({ customize, targetCustomerProduct, @@ -98,6 +127,10 @@ export const handleCustomizeUpdateItems = ({ customerEntitlements: FullCustomerEntitlement[]; prices: Price[]; entitlements: Entitlement[]; + carryLinks: { + fromCustomerEntitlementId: string; + toEntitlementId: string; + }[]; } => { const updateItems = customize.update_items ?? []; if (updateItems.length === 0) { @@ -106,6 +139,7 @@ export const handleCustomizeUpdateItems = ({ customerEntitlements: [], prices: [], entitlements: [], + carryLinks: [], }; } @@ -115,6 +149,10 @@ export const handleCustomizeUpdateItems = ({ const deleteCustomerEntitlements: FullCustomerEntitlement[] = []; const newPrices: Price[] = []; const newEntitlements: Entitlement[] = []; + const carryLinks: { + fromCustomerEntitlementId: string; + toEntitlementId: string; + }[] = []; const pairs = getCustomerProductItemPairs({ targetCustomerProduct }); @@ -134,9 +172,14 @@ export const handleCustomizeUpdateItems = ({ const newEntitlement = applyOverridesToEntitlement({ source: pair.customerEntitlement.entitlement, + customerPrice: pair.customerPrice, overrides: update, }); newEntitlements.push(newEntitlement); + carryLinks.push({ + fromCustomerEntitlementId: pair.customerEntitlement.id, + toEntitlementId: newEntitlement.id, + }); deleteCustomerEntitlements.push(pair.customerEntitlement); deleteCustomerEntitlementIds.add(pair.customerEntitlement.id); @@ -182,5 +225,6 @@ export const handleCustomizeUpdateItems = ({ customerEntitlements: deleteCustomerEntitlements, prices: newPrices, entitlements: newEntitlements, + carryLinks, }; }; diff --git a/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts index 5222fff55..81ff89758 100644 --- a/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts +++ b/server/src/internal/billing/v2/setup/patch/setupPatchContext.ts @@ -129,6 +129,7 @@ export const setupPatchContext = ({ customerEntitlements: updateDeleteCustomerEntitlements, prices: updateNewPrices, entitlements: updateNewEntitlements, + carryLinks: updateItemCarryLinks, } = handleCustomizeUpdateItems({ customize: params.customize ?? {}, targetCustomerProduct: finalCustomerProduct, @@ -198,6 +199,7 @@ export const setupPatchContext = ({ ...customItemPrices, ], customEntitlements: [...updateNewEntitlements, ...customEntitlements], + updateItemCarryLinks, }; return patchContext; diff --git a/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts b/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts index f7c957490..d8aaed637 100644 --- a/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts +++ b/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts @@ -6,6 +6,8 @@ import { isCustomerProductOneOff, isFreeProduct, isOneOffProduct, + isPastStartDate, + isProductPaidAndRecurring, secondsToMs, } from "@autumn/shared"; import type Stripe from "stripe"; @@ -21,6 +23,7 @@ export const setupBillingCycleAnchor = ({ trialContext, currentEpochMs, requestedBillingCycleAnchor, + billingStartsAt, }: { stripeSubscription?: Stripe.Subscription; customerProduct?: FullCusProduct; @@ -28,11 +31,25 @@ export const setupBillingCycleAnchor = ({ trialContext?: TrialContext; currentEpochMs: number; requestedBillingCycleAnchor?: number | "now"; + billingStartsAt?: number; }): number | "now" => { if (requestedBillingCycleAnchor !== undefined) { return requestedBillingCycleAnchor; } + // A new backdated subscription anchors its cycle to the past starts_at + // (Stripe's backdate_start_date anchors there too). Only for a new paid + // recurring line — backdating an existing line is rejected upstream, and + // free/one-off products have no recurring cycle to anchor. + if ( + billingStartsAt !== undefined && + isPastStartDate(billingStartsAt, currentEpochMs) && + !customerProduct && + isProductPaidAndRecurring(newFullProduct) + ) { + return billingStartsAt; + } + const currentIsFree = isCustomerProductFree(customerProduct); const newIsFree = isFreeProduct({ prices: newFullProduct.prices }); diff --git a/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts b/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts index 91c3a305b..b5d1314e4 100644 --- a/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts +++ b/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts @@ -1,20 +1,35 @@ import type { AttachParamsV1, + InvoiceMode, MultiAttachParamsV0, UpdateSubscriptionV1Params, } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { InvoiceTemplateService } from "@/internal/orgs/invoiceTemplates/InvoiceTemplateService"; -export const setupInvoiceModeContext = ({ +export const setupInvoiceModeContext = async ({ + ctx, params, }: { + ctx: AutumnContext; params: UpdateSubscriptionV1Params | AttachParamsV1 | MultiAttachParamsV0; -}) => { +}): Promise => { if (params?.invoice_mode?.enabled !== true) { return undefined; } - + const { invoice_template_id, net_terms_days } = params.invoice_mode; + const template = invoice_template_id + ? await InvoiceTemplateService.getById({ + db: ctx.db, + orgId: ctx.org.id, + id: invoice_template_id, + }) + : undefined; return { - finalizeInvoice: params.invoice_mode?.finalize, - enableProductImmediately: params.invoice_mode?.enable_plan_immediately, + finalizeInvoice: params.invoice_mode.finalize, + enableProductImmediately: params.invoice_mode.enable_plan_immediately, + footer: template?.footer, + memo: template?.memo, + daysUntilDue: net_terms_days ?? template?.net_terms_days, }; }; diff --git a/server/src/internal/billing/v2/utils/backdate/assertNoBackdateWithExistingSubscription.ts b/server/src/internal/billing/v2/utils/backdate/assertNoBackdateWithExistingSubscription.ts new file mode 100644 index 000000000..28993cca9 --- /dev/null +++ b/server/src/internal/billing/v2/utils/backdate/assertNoBackdateWithExistingSubscription.ts @@ -0,0 +1,32 @@ +import { ErrCode, type FullCusProduct, RecaseError } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import type Stripe from "stripe"; + +/** + * Backdating creates a brand-new Stripe subscription with backdate_start_date. + * Any existing subscription/schedule/scheduled product means Stripe would prorate + * instead, so a past starts_at can't be honored — reject before billing runs. + */ +export const assertNoBackdateWithExistingSubscription = ({ + billingContext, + subject = "Past starts_at", +}: { + billingContext: { + stripeSubscription?: Stripe.Subscription; + stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule; + scheduledCustomerProduct?: FullCusProduct; + }; + subject?: string; +}) => { + if ( + billingContext.stripeSubscription || + billingContext.stripeSubscriptionSchedule || + billingContext.scheduledCustomerProduct + ) { + throw new RecaseError({ + message: `${subject} is only supported when creating a new Stripe subscription.`, + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } +}; diff --git a/server/src/internal/billing/v2/utils/backdate/countBackdatedPeriods.ts b/server/src/internal/billing/v2/utils/backdate/countBackdatedPeriods.ts new file mode 100644 index 000000000..261866d47 --- /dev/null +++ b/server/src/internal/billing/v2/utils/backdate/countBackdatedPeriods.ts @@ -0,0 +1,59 @@ +import { addInterval, BillingInterval, type Price } from "@autumn/shared"; + +// Stripe flexible billing creates one line item per backdated billing period +// and does not support backdated invoices with more than 250 line items. +export const STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT = 250; + +export const countBackdatedPeriodsForPrice = ({ + price, + startsAt, + currentEpochMs, +}: { + price: Price; + startsAt: number; + currentEpochMs: number; +}) => { + const interval = price.config.interval; + if (interval === BillingInterval.OneOff) return 0; + + let periods = 0; + let periodStart = startsAt; + + while (periodStart < currentEpochMs) { + periods += 1; + if (periods > STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT) { + return periods; + } + + const nextPeriodStart = addInterval({ + from: periodStart, + interval, + intervalCount: price.config.interval_count ?? 1, + }); + + if (nextPeriodStart <= periodStart) { + return Number.POSITIVE_INFINITY; + } + + periodStart = nextPeriodStart; + } + + return periods; +}; + +// Number of in-advance billing periods a backdated price spans, floored at 1 +// so non-recurring / not-yet-elapsed prices act as a no-op multiplier. +export const getBackdatedCycleCountForPrice = ({ + price, + startsAt, + currentEpochMs, +}: { + price: Price; + startsAt: number; + currentEpochMs: number; +}): number => { + return Math.max( + countBackdatedPeriodsForPrice({ price, startsAt, currentEpochMs }), + 1, + ); +}; diff --git a/server/src/internal/billing/v2/utils/backdate/getBackdatedImmediatePeriod.ts b/server/src/internal/billing/v2/utils/backdate/getBackdatedImmediatePeriod.ts new file mode 100644 index 000000000..fb8cceaf8 --- /dev/null +++ b/server/src/internal/billing/v2/utils/backdate/getBackdatedImmediatePeriod.ts @@ -0,0 +1,48 @@ +import { + type BillingContext, + type BillingPeriod, + getCycleEnd, + isOneOffPrice, + type Price, +} from "@autumn/shared"; +import { getBackdatedCycleCountForPrice } from "./countBackdatedPeriods"; + +/** + * The period the first invoice of a backdated subscription covers: from the + * backdated start to the upcoming cycle boundary (e.g. Apr 1 -> Jun 1 on May 29), + * plus how many in-advance cycles that span charges. + */ +export const getBackdatedImmediatePeriod = ({ + price, + billingContext, +}: { + price: Price; + billingContext: BillingContext; +}): (BillingPeriod & { cycleCount: number }) | undefined => { + const { subscriptionBackdateStartMs, currentEpochMs, billingCycleAnchorMs } = + billingContext; + + if (subscriptionBackdateStartMs === undefined) return undefined; + if (isOneOffPrice(price)) return undefined; + + const cycleCount = getBackdatedCycleCountForPrice({ + price, + startsAt: subscriptionBackdateStartMs, + currentEpochMs, + }); + + const anchor = + typeof billingCycleAnchorMs === "number" + ? billingCycleAnchorMs + : subscriptionBackdateStartMs; + + const end = getCycleEnd({ + anchor, + interval: price.config.interval, + intervalCount: price.config.interval_count ?? 1, + now: currentEpochMs, + floor: anchor, + }); + + return { start: subscriptionBackdateStartMs, end, cycleCount }; +}; diff --git a/server/src/internal/billing/v2/utils/backdate/stripeBackdateInvoiceLimit.ts b/server/src/internal/billing/v2/utils/backdate/stripeBackdateInvoiceLimit.ts new file mode 100644 index 000000000..baa4a9f4d --- /dev/null +++ b/server/src/internal/billing/v2/utils/backdate/stripeBackdateInvoiceLimit.ts @@ -0,0 +1,60 @@ +import { ErrCode, type FullProduct, RecaseError } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import { + countBackdatedPeriodsForPrice, + STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT, +} from "./countBackdatedPeriods"; + +export const countStripeBackdateInvoiceLineItems = ({ + products, + startsAt, + currentEpochMs, +}: { + products: FullProduct[]; + startsAt: number; + currentEpochMs: number; +}) => { + if (startsAt >= currentEpochMs) return 0; + + return products.reduce((count, product) => { + return ( + count + + product.prices.reduce((priceCount, price) => { + return ( + priceCount + + countBackdatedPeriodsForPrice({ + price, + startsAt, + currentEpochMs, + }) + ); + }, 0) + ); + }, 0); +}; + +export const assertStripeBackdateInvoiceLineItemLimit = ({ + products, + startsAt, + currentEpochMs, + subject = "Past starts_at", +}: { + products: FullProduct[]; + startsAt: number; + currentEpochMs: number; + subject?: string; +}) => { + const lineItemCount = countStripeBackdateInvoiceLineItems({ + products, + startsAt, + currentEpochMs, + }); + + if (lineItemCount <= STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT) return; + + throw new RecaseError({ + message: `${subject} is too far in the past. Stripe supports backdating only when the first invoice has at most ${STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT} line items.`, + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts index 8292521fd..054c51ca5 100644 --- a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts @@ -2,15 +2,100 @@ import { type AutumnBillingPlan, CusProductStatus, type CustomerPlanChange, + customerEntitlementToFeatureId, + type FullCusProduct, } from "@autumn/shared"; import { buildPlanItemChanges } from "./buildPlanItemChanges"; import { buildPreviousAttributes } from "./buildPreviousAttributes"; import { cusProductStatusToPublicStatus } from "./cusProductStatusMapping"; import { toCustomerPlanSnapshot } from "./toCustomerPlanSnapshot"; +type PlanChangeEntry = { + change: CustomerPlanChange; + customerProduct?: FullCusProduct; +}; + const getChangePlanId = (change: CustomerPlanChange): string | undefined => change.subscription?.plan_id ?? change.purchase?.plan_id; +const getUpdatedChangeMergeKey = ( + change: CustomerPlanChange, +): string | undefined => { + if (change.subscription) { + const subscription = change.subscription; + return [ + "subscription", + subscription.plan_id, + subscription.status, + subscription.started_at, + subscription.expires_at, + subscription.canceled_at, + subscription.trial_ends_at, + ].join(":"); + } + + if (change.purchase) { + const purchase = change.purchase; + return [ + "purchase", + purchase.plan_id, + purchase.status, + purchase.expires_at, + ].join(":"); + } +}; + +const entitlementFeatureIds = (customerProduct: FullCusProduct) => + new Set( + customerProduct.customer_entitlements.map((customerEntitlement) => + customerEntitlementToFeatureId(customerEntitlement), + ), + ); + +const buildReplacementItemChanges = ({ + activated, + expired, +}: { + activated: PlanChangeEntry; + expired: PlanChangeEntry; +}): CustomerPlanChange["item_changes"] => { + const activatedProduct = activated.customerProduct; + const expiredProduct = expired.customerProduct; + if (activatedProduct === undefined || expiredProduct === undefined) { + return [ + ...(activated.change.item_changes ?? []), + ...(expired.change.item_changes ?? []), + ]; + } + + const activatedFeatureIds = entitlementFeatureIds(activatedProduct); + const expiredFeatureIds = entitlementFeatureIds(expiredProduct); + + return [ + ...buildPlanItemChanges({ + customerProduct: activatedProduct, + insertCustomerEntitlements: + activatedProduct.customer_entitlements.filter( + (customerEntitlement) => + expiredFeatureIds.has( + customerEntitlementToFeatureId(customerEntitlement), + ) === false, + ), + insertCustomerPrices: activatedProduct.customer_prices, + }), + ...buildPlanItemChanges({ + customerProduct: expiredProduct, + deleteCustomerEntitlements: expiredProduct.customer_entitlements.filter( + (customerEntitlement) => + activatedFeatureIds.has( + customerEntitlementToFeatureId(customerEntitlement), + ) === false, + ), + deleteCustomerPrices: expiredProduct.customer_prices, + }), + ]; +}; + /** * When a billing action updates a plan in-place, Autumn often creates a new * customer product (insertCustomerProducts) and expires the old one @@ -20,17 +105,20 @@ const getChangePlanId = (change: CustomerPlanChange): string | undefined => * reflects the logical operation. */ const collapseSamePlanIdPairs = ( - changes: CustomerPlanChange[], -): CustomerPlanChange[] => { + entries: PlanChangeEntry[], +): PlanChangeEntry[] => { const consumed = new Set(); - const result: CustomerPlanChange[] = []; + const result: PlanChangeEntry[] = []; - for (let i = 0; i < changes.length; i++) { + for (let i = 0; i < entries.length; i++) { if (consumed.has(i)) continue; - const change = changes[i]; + const entry = entries[i]; + const { change } = entry; - if (change.action !== "activated" && change.action !== "expired") { - result.push(change); + const canCollapse = + change.action === "activated" || change.action === "expired"; + if (canCollapse === false) { + result.push(entry); continue; } @@ -38,16 +126,17 @@ const collapseSamePlanIdPairs = ( const counterpartAction = change.action === "activated" ? "expired" : "activated"; - const pairIdx = changes.findIndex( - (other, j) => - j !== i && - !consumed.has(j) && - other.action === counterpartAction && - getChangePlanId(other) === planId, - ); + const pairIdx = entries.findIndex((other, j) => { + if (j === i) return false; + if (consumed.has(j)) return false; + return ( + other.change.action === counterpartAction && + getChangePlanId(other.change) === planId + ); + }); if (pairIdx < 0) { - result.push(change); + result.push(entry); continue; } @@ -57,38 +146,85 @@ const collapseSamePlanIdPairs = ( // the iterator, not as a pairing candidate). consumed.add(i); consumed.add(pairIdx); - const activatedChange = change.action === "activated" ? change : changes[pairIdx]; - const expiredChange = change.action === "expired" ? change : changes[pairIdx]; + const pair = entries[pairIdx]; + const activated = change.action === "activated" ? entry : pair; + const expired = change.action === "expired" ? entry : pair; result.push({ - action: "updated", - subscription: activatedChange.subscription, - purchase: activatedChange.purchase, - previous_attributes: expiredChange.previous_attributes, - item_changes: activatedChange.item_changes, + customerProduct: activated.customerProduct, + change: { + action: "updated", + subscription: activated.change.subscription, + purchase: activated.change.purchase, + previous_attributes: expired.change.previous_attributes, + item_changes: buildReplacementItemChanges({ + activated, + expired, + }), + }, }); } return result; }; +const mergeUpdatedPlanChanges = ( + entries: PlanChangeEntry[], +): PlanChangeEntry[] => { + const merged = new Map(); + const result: PlanChangeEntry[] = []; + + for (const entry of entries) { + const { change } = entry; + const mergeKey = getUpdatedChangeMergeKey(change); + if (change.action === "updated" && mergeKey) { + const existing = merged.get(mergeKey); + if (existing) { + existing.change.subscription = + existing.change.subscription ?? change.subscription; + existing.change.purchase = existing.change.purchase ?? change.purchase; + existing.change.previous_attributes = { + ...(existing.change.previous_attributes ?? {}), + ...(change.previous_attributes ?? {}), + }; + existing.change.item_changes = [ + ...(existing.change.item_changes ?? []), + ...(change.item_changes ?? []), + ]; + continue; + } + + merged.set(mergeKey, entry); + result.push(entry); + continue; + } + + result.push(entry); + } + + return result; +}; + export const buildPlanChanges = ({ autumnBillingPlan, }: { autumnBillingPlan: AutumnBillingPlan; }): CustomerPlanChange[] => { - const changes: CustomerPlanChange[] = []; + const entries: PlanChangeEntry[] = []; for (const cusProduct of autumnBillingPlan.insertCustomerProducts ?? []) { const action = cusProduct.status === CusProductStatus.Scheduled ? "scheduled" : "activated"; - changes.push({ - action, - ...toCustomerPlanSnapshot({ cusProduct }), - previous_attributes: null, - item_changes: [], + entries.push({ + customerProduct: cusProduct, + change: { + action, + ...toCustomerPlanSnapshot({ cusProduct }), + previous_attributes: null, + item_changes: [], + }, }); } @@ -126,33 +262,44 @@ export const buildPlanChanges = ({ action = "updated"; } - changes.push({ - action, - ...toCustomerPlanSnapshot({ - cusProduct: originalCusProduct, - overrides: { - status: update.updates.status, - canceled_at: update.updates.canceled_at, - ended_at: update.updates.ended_at, - trial_ends_at: update.updates.trial_ends_at, - }, - }), - previous_attributes: previousAttributes, - item_changes: [], + entries.push({ + customerProduct: originalCusProduct, + change: { + action, + ...toCustomerPlanSnapshot({ + cusProduct: originalCusProduct, + overrides: { + status: update.updates.status, + canceled_at: update.updates.canceled_at, + ended_at: update.updates.ended_at, + trial_ends_at: update.updates.trial_ends_at, + }, + }), + previous_attributes: previousAttributes, + item_changes: [], + }, }); } for (const patch of autumnBillingPlan.patchCustomerProducts ?? []) { - changes.push({ - action: "updated", - ...toCustomerPlanSnapshot({ cusProduct: patch.customerProduct }), - previous_attributes: {}, - item_changes: buildPlanItemChanges({ - insertCustomerEntitlements: patch.insertCustomerEntitlements, - deleteCustomerEntitlements: patch.deleteCustomerEntitlements, - }), + entries.push({ + customerProduct: patch.customerProduct, + change: { + action: "updated", + ...toCustomerPlanSnapshot({ cusProduct: patch.customerProduct }), + previous_attributes: {}, + item_changes: buildPlanItemChanges({ + customerProduct: patch.customerProduct, + insertCustomerEntitlements: patch.insertCustomerEntitlements, + deleteCustomerEntitlements: patch.deleteCustomerEntitlements, + insertCustomerPrices: patch.insertCustomerPrices, + deleteCustomerPrices: patch.deleteCustomerPrices, + }), + }, }); } - return collapseSamePlanIdPairs(changes); + return mergeUpdatedPlanChanges(collapseSamePlanIdPairs(entries)).map( + (entry) => entry.change, + ); }; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts index 38776119a..f9f96e32e 100644 --- a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts @@ -1,23 +1,79 @@ import type { CustomerPlanItemChange, + FullCusProduct, FullCustomerEntitlement, + FullCustomerPrice, +} from "@autumn/shared"; +import type { ApiPlanItemV1 } from "@autumn/shared/api/products/items/apiPlanItemV1.js"; +import { + customerEntitlementToFeatureId, + customerEntitlementToPlanItemV1, } from "@autumn/shared"; -export const buildPlanItemChanges = ({ - insertCustomerEntitlements, - deleteCustomerEntitlements, +export type InternalPlanItemChange = { + action: "created" | "deleted"; + feature_id: string; + item: ApiPlanItemV1; + previous_attributes: Record; +}; + +export const buildInternalPlanItemChanges = ({ + customerProduct, + insertCustomerEntitlements = [], + deleteCustomerEntitlements = [], + insertCustomerPrices = [], + deleteCustomerPrices = [], }: { + customerProduct: FullCusProduct; insertCustomerEntitlements?: FullCustomerEntitlement[]; deleteCustomerEntitlements?: FullCustomerEntitlement[]; + insertCustomerPrices?: FullCustomerPrice[]; + deleteCustomerPrices?: FullCustomerPrice[]; +}): InternalPlanItemChange[] => [ + ...insertCustomerEntitlements.map((customerEntitlement) => ({ + action: "created" as const, + feature_id: customerEntitlementToFeatureId(customerEntitlement), + item: customerEntitlementToPlanItemV1({ + customerEntitlement, + customerProduct, + customerPrices: insertCustomerPrices, + }), + previous_attributes: {}, + })), + ...deleteCustomerEntitlements.map((customerEntitlement) => ({ + action: "deleted" as const, + feature_id: customerEntitlementToFeatureId(customerEntitlement), + item: customerEntitlementToPlanItemV1({ + customerEntitlement, + customerProduct, + customerPrices: deleteCustomerPrices, + }), + previous_attributes: {}, + })), +]; + +export const buildPlanItemChanges = ({ + customerProduct, + insertCustomerEntitlements, + deleteCustomerEntitlements, + insertCustomerPrices, + deleteCustomerPrices, +}: { + customerProduct: FullCusProduct; + insertCustomerEntitlements?: FullCustomerEntitlement[]; + deleteCustomerEntitlements?: FullCustomerEntitlement[]; + insertCustomerPrices?: FullCustomerPrice[]; + deleteCustomerPrices?: FullCustomerPrice[]; }): CustomerPlanItemChange[] => { - const changes: CustomerPlanItemChange[] = []; - - for (const ent of insertCustomerEntitlements ?? []) { - changes.push({ action: "created", feature_id: ent.feature_id }); - } - for (const ent of deleteCustomerEntitlements ?? []) { - changes.push({ action: "deleted", feature_id: ent.feature_id }); - } - - return changes; + return buildInternalPlanItemChanges({ + customerProduct, + insertCustomerEntitlements, + deleteCustomerEntitlements, + insertCustomerPrices, + deleteCustomerPrices, + }).map(({ action, feature_id, item }) => ({ + action, + feature_id, + item, + })); }; diff --git a/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxRateIdPreview.ts b/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxRateIdPreview.ts index 11b379a91..502464fe1 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxRateIdPreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxRateIdPreview.ts @@ -1,6 +1,7 @@ import type { AutumnBillingPlan, BillingContext, + LineItem, PreviewTax, } from "@autumn/shared"; import { @@ -8,31 +9,63 @@ import { orgToCurrency, stripeToAtmnAmount, } from "@autumn/shared"; +import { Decimal } from "decimal.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -/** - * Build-stage helper that computes a tax preview for an attach when the - * caller passed an explicit Stripe `tax_rate_id`. Sibling to - * `computeAttachTaxPreview` (which handles automatic_tax). - * - * Pure math: the Stripe TaxRate was fetched once at setup and lives on - * `billingContext.stripeTaxRate`. Tax is applied to the same net - * `chargeImmediately` subtotal the automatic-tax helper uses, so both - * branches feed the formatter and total-assembly identically. - * - * Skip-conditions (return undefined): - * - no `taxRateId` on context - * - flow is `stripe_checkout` (Stripe Checkout computes tax itself, same - * reasoning as the automatic-tax helper) - * - no `chargeImmediately` line items - * - * On `netSubtotal <= 0` we short-circuit with `{ status: "complete", ...zeros }` — - * tax does not apply to a credit invoice. - * - * On a missing/expanded `stripeTaxRate` (fetch failed at setup) we return - * `{ status: "incomplete", ...zeros }` so the merchant sees an explicit - * "tax not computed" signal rather than a silently missing field. - */ +const lineItemToTaxableMinorUnits = ({ + lineItem, + currency, +}: { + lineItem: LineItem; + currency: string; +}) => { + const amount = lineItem.context.discountable + ? lineItem.amount + : (lineItem.amountAfterDiscounts ?? lineItem.amount); + + let taxableMinorUnits = atmnToStripeAmount({ amount, currency }); + + if (!lineItem.context.discountable || taxableMinorUnits <= 0) { + return taxableMinorUnits; + } + + for (const discount of lineItem.discounts ?? []) { + const discountMinorUnits = discount.percentOff + ? new Decimal(taxableMinorUnits) + .times(discount.percentOff) + .div(100) + .round() + .toNumber() + : atmnToStripeAmount({ amount: discount.amountOff, currency }); + + taxableMinorUnits = Math.max(taxableMinorUnits - discountMinorUnits, 0); + } + + return taxableMinorUnits; +}; + +const taxableMinorUnitsToTaxMinorUnits = ({ + taxableMinorUnits, + percentage, + inclusive, +}: { + taxableMinorUnits: number; + percentage: number; + inclusive: boolean; +}) => { + return inclusive + ? new Decimal(taxableMinorUnits) + .times(percentage) + .div(100 + percentage) + .round() + .toNumber() + : new Decimal(taxableMinorUnits) + .times(percentage) + .div(100) + .round() + .toNumber(); +}; + export const computeAttachTaxRateIdPreview = async ({ ctx, billingContext, @@ -43,7 +76,6 @@ export const computeAttachTaxRateIdPreview = async ({ autumnBillingPlan: AutumnBillingPlan; }): Promise => { if (!billingContext.taxRateId) return undefined; - if (billingContext.checkoutMode === "stripe_checkout") return undefined; const allLineItems = autumnBillingPlan.lineItems ?? []; if (allLineItems.length === 0) return undefined; @@ -51,14 +83,16 @@ export const computeAttachTaxRateIdPreview = async ({ const immediateLines = allLineItems.filter((line) => line.chargeImmediately); if (immediateLines.length === 0) return undefined; - const netSubtotal = immediateLines.reduce( - (sum, line) => sum + (line.amountAfterDiscounts ?? line.amount), + const currency = orgToCurrency({ org: ctx.org }); + const taxableMinorUnits = immediateLines.map((lineItem) => + lineItemToTaxableMinorUnits({ lineItem, currency }), + ); + const totalTaxableMinorUnits = taxableMinorUnits.reduce( + (sum, amount) => sum + amount, 0, ); - const currency = orgToCurrency({ org: ctx.org }); - - if (netSubtotal <= 0) { + if (totalTaxableMinorUnits <= 0) { return { total: 0, amount_inclusive: 0, @@ -82,25 +116,22 @@ export const computeAttachTaxRateIdPreview = async ({ }; } - // Round through Stripe minor-units to match how Stripe rounds tax on - // the real invoice (per-line rounding to the nearest cent). - const subtotalMinorUnits = atmnToStripeAmount({ - amount: netSubtotal, + const taxMinorUnits = taxableMinorUnits.reduce( + (sum, amount) => + sum + + taxableMinorUnitsToTaxMinorUnits({ + taxableMinorUnits: amount, + percentage: taxRate.percentage, + inclusive: taxRate.inclusive, + }), + 0, + ); + + const taxAmount = stripeToAtmnAmount({ + amount: Math.max(taxMinorUnits, 0), currency, }); - const taxMinorUnits = taxRate.inclusive - ? Math.round( - (subtotalMinorUnits * taxRate.percentage) / (100 + taxRate.percentage), - ) - : Math.round((subtotalMinorUnits * taxRate.percentage) / 100); - - const taxAmount = stripeToAtmnAmount({ amount: taxMinorUnits, currency }); - - // For an inclusive rate the line amount already contains the tax, so - // Stripe charges only the line amount. `total` drives - // applyPreviewAdjustmentsToTotal and must stay 0 here to avoid inflating - // preview.total. `amount_inclusive` still reports the notional split. return { total: taxRate.inclusive ? 0 : taxAmount, amount_inclusive: taxRate.inclusive ? taxAmount : 0, diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts index b703172b8..1ab8c3883 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts @@ -2,6 +2,7 @@ import { type AutumnBillingPlan, type BillingContext, type FullCusProduct, + type LineItem, ms, sumValues, timestampsMatch, @@ -10,24 +11,91 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems"; import { filterStripeDiscountsForNextCycle } from "@/internal/billing/v2/providers/stripe/utils/discounts/filterStripeDiscountsForNextCycle"; import { customerProductToArrearLineItems } from "../../lineItems/customerProductToArrearLineItems"; -import { customerProductToLineItems } from "../../lineItems/customerProductToLineItems"; +import { getLineItemsForDirection } from "../../lineItems/getLineItemsForDirection"; import { lineItemToPreviewLineItem } from "../../lineItems/lineItemToPreviewLineItem"; import { lineItemToPreviewUsageLineItem } from "../../lineItems/lineItemToPreviewUsageLineItem"; +type NextCycleLineItemSpec = { + customerProducts: FullCusProduct[]; + direction: "charge" | "refund"; + billingContext?: BillingContext; + billingCycleAnchorMs?: BillingContext["billingCycleAnchorMs"]; + filterBillingPeriodStart?: boolean; + priceFilters?: { + excludeOneOffPrices?: boolean; + }; +}; + +const buildLineItemsForSpec = ({ + ctx, + spec, + billingContext, + nextCycleStart, +}: { + ctx: AutumnContext; + spec: NextCycleLineItemSpec; + billingContext: BillingContext; + nextCycleStart: number; +}) => { + const lineItems = spec.customerProducts.flatMap((customerProduct) => + getLineItemsForDirection({ + ctx, + customerProduct, + billingContext: { + ...billingContext, + ...spec.billingContext, + currentEpochMs: nextCycleStart, + subscriptionBackdateStartMs: undefined, + }, + direction: spec.direction, + priceFilters: spec.priceFilters, + billingCycleAnchorMsOverride: spec.billingCycleAnchorMs, + }), + ); + + if (spec.filterBillingPeriodStart === false) return lineItems; + + return lineItems.filter( + (lineItem) => + lineItem.context.billingPeriod?.start !== undefined && + timestampsMatch(lineItem.context.billingPeriod.start, nextCycleStart), + ); +}; + +const prefixRefundDescriptions = ({ lineItems }: { lineItems: LineItem[] }) => + lineItems.map((lineItem) => { + if (lineItem.context.direction !== "refund") return lineItem; + if (lineItem.description.startsWith("Unused ")) return lineItem; + + return { + ...lineItem, + description: `Unused ${lineItem.description}`, + }; + }); + export const billingPlanToNextCycleLineItems = ({ ctx, customerProducts, + productsForUsageLineItems = customerProducts, + lineItemSpecs = [ + { + customerProducts, + direction: "charge", + }, + ], autumnBillingPlan, billingContext, nextCycleStart, }: { ctx: AutumnContext; customerProducts: FullCusProduct[]; + productsForUsageLineItems?: FullCusProduct[]; + lineItemSpecs?: NextCycleLineItemSpec[]; autumnBillingPlan: AutumnBillingPlan; billingContext: BillingContext; nextCycleStart: number; }) => { - const arrearLineItems = customerProducts.flatMap( + const arrearLineItems = productsForUsageLineItems.flatMap( (customerProduct) => customerProductToArrearLineItems({ ctx, @@ -44,24 +112,17 @@ export const billingPlanToNextCycleLineItems = ({ lineItemToPreviewUsageLineItem, ); - const autumnLineItems = customerProducts.flatMap((customerProduct) => - customerProductToLineItems({ + let nextCycleAutumnLineItems = lineItemSpecs.flatMap((spec) => + buildLineItemsForSpec({ ctx, - customerProduct, - billingContext: { - ...billingContext, - currentEpochMs: nextCycleStart, - }, - direction: "charge", + spec, + billingContext, + nextCycleStart, }), ); - - // Only keep line items whose billing period starts at the next cycle. - let nextCycleAutumnLineItems = autumnLineItems.filter( - (lineItem) => - lineItem.context.billingPeriod?.start !== undefined && - timestampsMatch(lineItem.context.billingPeriod.start, nextCycleStart), - ); + nextCycleAutumnLineItems = prefixRefundDescriptions({ + lineItems: nextCycleAutumnLineItems, + }); const deferredLineItems = (autumnBillingPlan.lineItems ?? []).filter( (lineItem) => lineItem.chargeImmediately === false, @@ -72,6 +133,7 @@ export const billingPlanToNextCycleLineItems = ({ stripeDiscounts: billingContext.stripeDiscounts, currentEpochMs: billingContext.currentEpochMs, nextCycleStart, + discountStartMs: billingContext.subscriptionBackdateStartMs, }); nextCycleAutumnLineItems = applyStripeDiscountsToLineItems({ diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview.ts index 15c10d12f..1a86fd229 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview.ts @@ -1,30 +1,26 @@ import { type BillingContext, - type BillingInterval, type BillingPlan, type BillingPreviewResponse, cp, - cusProductsToPrices, type FullCusProduct, - getCycleEnd, - getSmallestInterval, hasCustomerProductEnded, } from "@autumn/shared"; import type { Decimal } from "decimal.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { - applyCustomerProductPatch, - applyCustomerProductUpdate, - getPatchCustomerProducts, - getUpdateCustomerProducts, -} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; +import { autumnBillingPlanToFinalFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer"; import { billingPlanToNextCycleLineItems } from "./billingPlanToNextCycleLineItems"; import { computeScheduledAnchorResetPreview } from "./computeScheduledAnchorResetPreview"; +import { + getActiveCustomerProductsAt, + getNextCycleEvent, + type SmallestInterval, +} from "./getNextCycleEvent"; export type NextCyclePreviewDebug = { allCustomerProducts: FullCusProduct[]; currentCustomerProducts: FullCusProduct[]; - smallestInterval: { interval: string; intervalCount: number } | null; + smallestInterval: SmallestInterval | null; anchorMs: number; nextCycleStart: number | null; filteredCustomerProducts: FullCusProduct[]; @@ -35,81 +31,52 @@ export type NextCyclePreviewResult = { debug: NextCyclePreviewDebug; }; -const applyPatchCustomerProducts = ({ - allCustomerProducts, - billingPlan, -}: { - allCustomerProducts: FullCusProduct[]; - billingPlan: BillingPlan; -}): FullCusProduct[] => { - const patchCustomerProducts = getPatchCustomerProducts({ - autumnBillingPlan: billingPlan.autumn, - }); - if (patchCustomerProducts.length === 0) return allCustomerProducts; +const MS_PER_SECOND = 1000; - const patchedCustomerProducts = patchCustomerProducts.map((patch) => - applyCustomerProductPatch({ - customerProduct: - allCustomerProducts.find( - (customerProduct) => customerProduct.id === patch.customerProduct.id, - ) ?? patch.customerProduct, - patch, - }), - ); - const patchedCustomerProductIds = new Set( - patchedCustomerProducts.map((customerProduct) => customerProduct.id), - ); - - return [ - ...allCustomerProducts.filter( - (customerProduct) => !patchedCustomerProductIds.has(customerProduct.id), - ), - ...patchedCustomerProducts, - ]; -}; -const getScheduledStartPreviewContext = ({ +const filterCustomerProductsForEventStart = ({ customerProducts, - currentCustomerProducts, - currentEpochMs, + nextCycleStart, }: { customerProducts: FullCusProduct[]; - currentCustomerProducts: FullCusProduct[]; - currentEpochMs: number; + nextCycleStart: number; +}) => + customerProducts.filter( + (customerProduct) => + customerProduct.starts_at <= nextCycleStart && + !hasCustomerProductEnded(customerProduct, { nowMs: nextCycleStart }), + ); + +const scaleNextCycleAmounts = ({ + lineItemsResult, + prorationRatio, +}: { + lineItemsResult: ReturnType; + prorationRatio: Decimal; }) => { - let scheduledStartMs: number | null = null; - - for (const customerProduct of customerProducts) { - if (!cp(customerProduct).scheduled().valid) continue; - if (customerProduct.starts_at <= currentEpochMs) continue; - - scheduledStartMs = - scheduledStartMs === null - ? customerProduct.starts_at - : Math.min(scheduledStartMs, customerProduct.starts_at); - } - - const scheduledStartCustomerProducts = - scheduledStartMs === null - ? [] - : customerProducts.filter( - (customerProduct) => customerProduct.starts_at === scheduledStartMs, - ); - - const currentPrices = cusProductsToPrices({ - cusProducts: currentCustomerProducts, - filters: { excludeOneOffPrices: true }, - }); - const scheduledStartPrices = cusProductsToPrices({ - cusProducts: scheduledStartCustomerProducts, - filters: { excludeOneOffPrices: true }, - }); + const previewLineItems = lineItemsResult.previewLineItems.map((item) => ({ + ...item, + subtotal: prorationRatio.mul(item.subtotal).toDecimalPlaces(2).toNumber(), + total: prorationRatio.mul(item.total).toDecimalPlaces(2).toNumber(), + discounts: item.discounts?.map((discount) => ({ + ...discount, + amount_off: prorationRatio + .mul(discount.amount_off) + .toDecimalPlaces(2) + .toNumber(), + })), + })); return { - scheduledStartMs, - scheduledStartCustomerProducts, - smallestInterval: getSmallestInterval({ - prices: currentPrices.length > 0 ? currentPrices : scheduledStartPrices, - }), + ...lineItemsResult, + previewLineItems, + subtotal: prorationRatio + .mul(lineItemsResult.subtotal) + .toDecimalPlaces(2) + .toNumber(), + total: prorationRatio + .mul(lineItemsResult.total) + .toDecimalPlaces(2) + .toNumber(), }; }; @@ -124,18 +91,11 @@ export const billingPlanToNextCyclePreview = ({ }): NextCyclePreviewResult => { const { billingCycleAnchorMs } = billingContext; - const { insertCustomerProducts } = billingPlan.autumn; - const updateCustomerProducts = getUpdateCustomerProducts({ + const finalFullCustomer = autumnBillingPlanToFinalFullCustomer({ + billingContext, autumnBillingPlan: billingPlan.autumn, - }).map(({ customerProduct, updates }) => - applyCustomerProductUpdate({ customerProduct, updates }), - ); - - // Get all customer products - const allCustomerProducts = applyPatchCustomerProducts({ - allCustomerProducts: [...insertCustomerProducts, ...updateCustomerProducts], - billingPlan, }); + const allCustomerProducts = finalFullCustomer.customer_products; const customerProducts = allCustomerProducts.filter( (customerProduct) => @@ -147,27 +107,25 @@ export const billingPlanToNextCyclePreview = ({ cp(customerProduct).paid().recurring().hasActiveStatus().valid, ); - const { scheduledStartMs, scheduledStartCustomerProducts, smallestInterval } = - getScheduledStartPreviewContext({ - customerProducts, - currentCustomerProducts, - currentEpochMs: billingContext.currentEpochMs, - }); - - // Calculate anchor const anchorMs = billingCycleAnchorMs === "now" ? billingContext.currentEpochMs : billingCycleAnchorMs; + const event = getNextCycleEvent({ + billingContext, + customerProducts, + anchorMs, + }); + const baseDebug = { allCustomerProducts, currentCustomerProducts, - smallestInterval, + smallestInterval: event.kind === "none" ? null : event.smallestInterval, anchorMs, }; - if (billingCycleAnchorMs === "now" && scheduledStartMs === null) { + if (event.kind === "none") { return { nextCycle: undefined, debug: { @@ -178,56 +136,120 @@ export const billingPlanToNextCyclePreview = ({ }; } - if (!smallestInterval) { + if (event.kind === "scheduled_change") { + const productsForUsageLineItems = getActiveCustomerProductsAt({ + customerProducts, + startsAtMs: event.startsAtMs - MS_PER_SECOND, + }); + const lineItemsResult = billingPlanToNextCycleLineItems({ + ctx, + customerProducts: [ + ...event.incomingCustomerProducts, + ...event.outgoingCustomerProducts, + ], + productsForUsageLineItems, + lineItemSpecs: [ + { + customerProducts: event.incomingCustomerProducts, + direction: "charge", + billingCycleAnchorMs: anchorMs, + filterBillingPeriodStart: false, + priceFilters: { excludeOneOffPrices: true }, + }, + { + customerProducts: event.outgoingCustomerProducts, + direction: "refund", + billingCycleAnchorMs: anchorMs, + filterBillingPeriodStart: false, + priceFilters: { excludeOneOffPrices: true }, + }, + ], + autumnBillingPlan: billingPlan.autumn, + billingContext, + nextCycleStart: event.startsAtMs, + }); + return { - nextCycle: undefined, + nextCycle: { + starts_at: event.startsAtMs, + subtotal: lineItemsResult.subtotal, + total: lineItemsResult.total, + line_items: lineItemsResult.previewLineItems, + usage_line_items: lineItemsResult.previewUsageLineItems, + }, debug: { ...baseDebug, - nextCycleStart: null, - filteredCustomerProducts: [], + nextCycleStart: event.startsAtMs, + filteredCustomerProducts: event.incomingCustomerProducts, }, }; } - const isScheduledAnchorReset = - typeof billingContext.requestedBillingCycleAnchor === "number"; + if (event.kind === "scheduled_start") { + const productsForUsageLineItems = getActiveCustomerProductsAt({ + customerProducts, + startsAtMs: event.startsAtMs - MS_PER_SECOND, + }); + const billingCycleAnchorMs = + productsForUsageLineItems.length === 0 ? event.startsAtMs : anchorMs; + const lineItemsResult = billingPlanToNextCycleLineItems({ + ctx, + customerProducts: event.customerProducts, + productsForUsageLineItems, + lineItemSpecs: [ + { + customerProducts: event.customerProducts, + direction: "charge", + billingCycleAnchorMs, + filterBillingPeriodStart: false, + priceFilters: { excludeOneOffPrices: true }, + }, + ], + autumnBillingPlan: billingPlan.autumn, + billingContext, + nextCycleStart: event.startsAtMs, + }); + + return { + nextCycle: { + starts_at: event.startsAtMs, + subtotal: lineItemsResult.subtotal, + total: lineItemsResult.total, + line_items: lineItemsResult.previewLineItems, + usage_line_items: lineItemsResult.previewUsageLineItems, + }, + debug: { + ...baseDebug, + nextCycleStart: event.startsAtMs, + filteredCustomerProducts: event.customerProducts, + }, + }; + } let nextCycleStart: number; let lineItemsBillingContext: BillingContext = billingContext; let prorationRatio: Decimal | undefined; + let nextCycleCustomerProducts: FullCusProduct[]; - if (isScheduledAnchorReset) { + if (event.kind === "anchor_reset") { const result = computeScheduledAnchorResetPreview({ billingContext, - interval: smallestInterval.interval as BillingInterval, - intervalCount: smallestInterval.intervalCount, + interval: event.smallestInterval.interval, + intervalCount: event.smallestInterval.intervalCount, }); nextCycleStart = result.nextCycleStart; prorationRatio = result.prorationRatio; lineItemsBillingContext = result.lineItemsBillingContext; - } else if (billingCycleAnchorMs === "now" && scheduledStartMs !== null) { - nextCycleStart = scheduledStartMs; + nextCycleCustomerProducts = customerProducts; } else { - nextCycleStart = getCycleEnd({ - anchor: anchorMs, - interval: smallestInterval.interval, - intervalCount: smallestInterval.intervalCount, - now: billingContext.currentEpochMs, - floor: anchorMs, - }); + nextCycleStart = event.startsAtMs; + nextCycleCustomerProducts = event.customerProducts; } - const nextCycleCustomerProducts = - billingCycleAnchorMs === "now" && scheduledStartMs !== null - ? scheduledStartCustomerProducts - : customerProducts; - const filteredCustomerProducts = nextCycleCustomerProducts.filter( - (customerProduct) => { - return !hasCustomerProductEnded(customerProduct, { - nowMs: nextCycleStart, - }); - }, - ); + const filteredCustomerProducts = filterCustomerProductsForEventStart({ + customerProducts: nextCycleCustomerProducts, + nextCycleStart, + }); if (filteredCustomerProducts.length === 0) { return { @@ -236,39 +258,39 @@ export const billingPlanToNextCyclePreview = ({ }; } - let { previewLineItems, previewUsageLineItems, subtotal, total } = - billingPlanToNextCycleLineItems({ - ctx, - customerProducts: filteredCustomerProducts, - autumnBillingPlan: billingPlan.autumn, - billingContext: lineItemsBillingContext, - nextCycleStart, - }); + const productsForUsageLineItems = getActiveCustomerProductsAt({ + customerProducts, + startsAtMs: nextCycleStart - MS_PER_SECOND, + }); + let lineItemsResult = billingPlanToNextCycleLineItems({ + ctx, + customerProducts: filteredCustomerProducts, + productsForUsageLineItems, + autumnBillingPlan: billingPlan.autumn, + billingContext: { + ...lineItemsBillingContext, + billingCycleAnchorMs: + lineItemsBillingContext.billingCycleAnchorMs === "now" + ? anchorMs + : lineItemsBillingContext.billingCycleAnchorMs, + }, + nextCycleStart, + }); if (prorationRatio) { - previewLineItems = previewLineItems.map((item) => ({ - ...item, - subtotal: prorationRatio.mul(item.subtotal).toDecimalPlaces(2).toNumber(), - total: prorationRatio.mul(item.total).toDecimalPlaces(2).toNumber(), - discounts: item.discounts?.map((discount) => ({ - ...discount, - amount_off: prorationRatio - .mul(discount.amount_off) - .toDecimalPlaces(2) - .toNumber(), - })), - })); - subtotal = prorationRatio.mul(subtotal).toDecimalPlaces(2).toNumber(); - total = prorationRatio.mul(total).toDecimalPlaces(2).toNumber(); + lineItemsResult = scaleNextCycleAmounts({ + lineItemsResult, + prorationRatio, + }); } return { nextCycle: { starts_at: nextCycleStart, - subtotal, - total, - line_items: previewLineItems, - usage_line_items: previewUsageLineItems, + subtotal: lineItemsResult.subtotal, + total: lineItemsResult.total, + line_items: lineItemsResult.previewLineItems, + usage_line_items: lineItemsResult.previewUsageLineItems, }, debug: { ...baseDebug, nextCycleStart, filteredCustomerProducts }, }; diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/activeCustomerProducts.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/activeCustomerProducts.ts new file mode 100644 index 000000000..b89bfe3fc --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/activeCustomerProducts.ts @@ -0,0 +1,19 @@ +import type { FullCusProduct } from "@autumn/shared"; +import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs"; +import { SECOND_MS } from "./timeUtils"; + +/** Mirrors Stripe phase overlap logic for a single timestamp window. */ +export const getActiveCustomerProductsAt = ({ + customerProducts, + startsAtMs, +}: { + customerProducts: FullCusProduct[]; + startsAtMs: number; +}) => + customerProducts.filter((customerProduct) => + isCustomerProductActiveDuringPeriod({ + customerProduct, + startMs: startsAtMs, + endMs: startsAtMs + SECOND_MS, + }), + ); diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/classifyNextCycleEvent.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/classifyNextCycleEvent.ts new file mode 100644 index 000000000..b4df5b15d --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/classifyNextCycleEvent.ts @@ -0,0 +1,137 @@ +import { + type BillingContext, + type FullCusProduct, + timestampsMatch, +} from "@autumn/shared"; +import { getActiveCustomerProductsAt } from "./activeCustomerProducts"; +import { + differenceByCustomerProductId, + getImplicitOutgoingCustomerProducts, + uniqueCustomerProductsById, +} from "./customerProductDiffs"; +import { SECOND_MS, timestampsEqual } from "./timeUtils"; +import type { NextCycleEvent, SmallestInterval } from "./types"; +import { + getExactTransitionTimestamp, + hasProductTransitionAt, + hasTrialEndAt, +} from "./transitionCandidates"; + +/** Classifies one candidate timestamp into the invoice event it represents. */ +export const classifyNextCycleEvent = ({ + billingContext, + customerProducts, + normalizedCustomerProducts, + startsAtMs, + renewalBoundaryMs, + smallestInterval, +}: { + billingContext: BillingContext; + customerProducts: FullCusProduct[]; + normalizedCustomerProducts: FullCusProduct[]; + startsAtMs: number; + renewalBoundaryMs: number; + smallestInterval: SmallestInterval; +}): NextCycleEvent | undefined => { + const exactStartsAtMs = getExactTransitionTimestamp({ + billingContext, + customerProducts, + startsAtMs, + }); + const activeCustomerProducts = getActiveCustomerProductsAt({ + customerProducts, + startsAtMs: exactStartsAtMs, + }); + + if (timestampsMatch(startsAtMs, renewalBoundaryMs)) { + const activeCustomerProducts = getActiveCustomerProductsAt({ + customerProducts, + startsAtMs: renewalBoundaryMs, + }); + + return { + kind: "renewal", + smallestInterval, + startsAtMs: renewalBoundaryMs, + customerProducts: activeCustomerProducts, + }; + } + + const isAnchorReset = timestampsEqual( + billingContext.requestedBillingCycleAnchor, + startsAtMs, + ); + const isProductTransition = hasProductTransitionAt({ + customerProducts: normalizedCustomerProducts, + startsAtMs, + }); + const isTrialEnd = hasTrialEndAt({ + billingContext, + customerProducts: normalizedCustomerProducts, + startsAtMs, + }); + + if (isAnchorReset && !isProductTransition && !isTrialEnd) { + return { kind: "anchor_reset", smallestInterval }; + } + + const previousCustomerProducts = getActiveCustomerProductsAt({ + customerProducts, + startsAtMs: exactStartsAtMs - SECOND_MS, + }); + const incomingCustomerProducts = differenceByCustomerProductId({ + left: activeCustomerProducts, + right: previousCustomerProducts, + }); + const outgoingCustomerProducts = uniqueCustomerProductsById([ + ...differenceByCustomerProductId({ + left: previousCustomerProducts, + right: activeCustomerProducts, + }), + ...getImplicitOutgoingCustomerProducts({ + incomingCustomerProducts, + previousCustomerProducts, + }), + ]); + + if ( + incomingCustomerProducts.length > 0 && + outgoingCustomerProducts.length > 0 + ) { + return { + kind: "scheduled_change", + smallestInterval, + startsAtMs: exactStartsAtMs, + incomingCustomerProducts, + outgoingCustomerProducts, + }; + } + + if (incomingCustomerProducts.length > 0) { + return { + kind: "scheduled_start", + smallestInterval, + startsAtMs: exactStartsAtMs, + customerProducts: incomingCustomerProducts, + }; + } + + if (outgoingCustomerProducts.length > 0) { + return { + kind: "scheduled_change", + smallestInterval, + startsAtMs: exactStartsAtMs, + incomingCustomerProducts, + outgoingCustomerProducts, + }; + } + + if (isTrialEnd) { + return { + kind: "trial_end", + smallestInterval, + startsAtMs: exactStartsAtMs, + customerProducts: activeCustomerProducts, + }; + } +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/customerProductDiffs.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/customerProductDiffs.ts new file mode 100644 index 000000000..bc7dc1ecb --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/customerProductDiffs.ts @@ -0,0 +1,46 @@ +import type { FullCusProduct } from "@autumn/shared"; + +export const differenceByCustomerProductId = ({ + left, + right, +}: { + left: FullCusProduct[]; + right: FullCusProduct[]; +}) => { + const rightIds = new Set(right.map((customerProduct) => customerProduct.id)); + return left.filter((customerProduct) => !rightIds.has(customerProduct.id)); +}; + +export const uniqueCustomerProductsById = ( + customerProducts: FullCusProduct[], +) => { + const seen = new Set(); + return customerProducts.filter((customerProduct) => { + if (seen.has(customerProduct.id)) return false; + seen.add(customerProduct.id); + return true; + }); +}; + +/** Treats same-group future products as replacing the active product. */ +export const getImplicitOutgoingCustomerProducts = ({ + incomingCustomerProducts, + previousCustomerProducts, +}: { + incomingCustomerProducts: FullCusProduct[]; + previousCustomerProducts: FullCusProduct[]; +}) => + previousCustomerProducts.filter((previousCustomerProduct) => + incomingCustomerProducts.some((incomingCustomerProduct) => { + const productGroup = incomingCustomerProduct.product.group; + if (!productGroup) return false; + + return ( + previousCustomerProduct.product.group === productGroup && + previousCustomerProduct.product.is_add_on === + incomingCustomerProduct.product.is_add_on && + previousCustomerProduct.internal_entity_id === + incomingCustomerProduct.internal_entity_id + ); + }), + ); diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/index.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/index.ts new file mode 100644 index 000000000..59f65a024 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/index.ts @@ -0,0 +1,77 @@ +import { + type BillingContext, + type FullCusProduct, + getCycleEnd, +} from "@autumn/shared"; +import { normalizeCustomerProductTimestamps } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/normalizeCustomerProductTimestamps"; +import { classifyNextCycleEvent } from "./classifyNextCycleEvent"; +import { getSmallestIntervalForNextCycle } from "./smallestInterval"; +import { normalizeMs } from "./timeUtils"; +import type { NextCycleEvent } from "./types"; +import { buildNextCycleTransitionPoints } from "./transitionCandidates"; + +export { getActiveCustomerProductsAt } from "./activeCustomerProducts"; +export type { NextCycleEvent, SmallestInterval } from "./types"; + +/** Finds the next chronological event that should generate an invoice preview. */ +export const getNextCycleEvent = ({ + billingContext, + customerProducts, + anchorMs, +}: { + billingContext: BillingContext; + customerProducts: FullCusProduct[]; + anchorMs: number; +}): NextCycleEvent => { + const { billingCycleAnchorMs, currentEpochMs } = billingContext; + const nowMs = normalizeMs(currentEpochMs); + const normalizedCustomerProducts = customerProducts.map( + normalizeCustomerProductTimestamps, + ); + const smallestInterval = getSmallestIntervalForNextCycle({ + customerProducts: normalizedCustomerProducts, + currentEpochMs, + }); + + if (!smallestInterval) { + return { kind: "none" }; + } + + const renewalBoundaryMs = getCycleEnd({ + anchor: anchorMs, + interval: smallestInterval.interval, + intervalCount: smallestInterval.intervalCount, + now: currentEpochMs, + floor: anchorMs, + }); + const transitionTimestamps = buildNextCycleTransitionPoints({ + billingContext, + customerProducts: normalizedCustomerProducts, + nowMs, + }); + const shouldShowRenewal = + billingCycleAnchorMs !== "now" || transitionTimestamps.length > 0; + const candidateTimestamps = Array.from( + new Set([ + ...transitionTimestamps, + ...(shouldShowRenewal && renewalBoundaryMs > nowMs + ? [renewalBoundaryMs] + : []), + ]), + ).sort((a, b) => a - b); + + for (const startsAtMs of candidateTimestamps) { + const event = classifyNextCycleEvent({ + billingContext, + customerProducts, + normalizedCustomerProducts, + startsAtMs, + renewalBoundaryMs, + smallestInterval, + }); + + if (event) return event; + } + + return { kind: "none" }; +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/smallestInterval.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/smallestInterval.ts new file mode 100644 index 000000000..eedca0bb1 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/smallestInterval.ts @@ -0,0 +1,50 @@ +import { + cp, + cusProductsToPrices, + type FullCusProduct, + getSmallestInterval, +} from "@autumn/shared"; + +/** Chooses the interval that defines the next invoice boundary. */ +export const getSmallestIntervalForNextCycle = ({ + customerProducts, + currentEpochMs, +}: { + customerProducts: FullCusProduct[]; + currentEpochMs: number; +}) => { + const currentCustomerProducts = customerProducts.filter( + (customerProduct) => cp(customerProduct).hasActiveStatus().valid, + ); + let scheduledStartMs: number | null = null; + + for (const customerProduct of customerProducts) { + if (!cp(customerProduct).scheduled().valid) continue; + if (customerProduct.starts_at <= currentEpochMs) continue; + + scheduledStartMs = + scheduledStartMs === null + ? customerProduct.starts_at + : Math.min(scheduledStartMs, customerProduct.starts_at); + } + + const scheduledStartCustomerProducts = + scheduledStartMs === null + ? [] + : customerProducts.filter( + (customerProduct) => customerProduct.starts_at === scheduledStartMs, + ); + + const currentPrices = cusProductsToPrices({ + cusProducts: currentCustomerProducts, + filters: { excludeOneOffPrices: true }, + }); + const scheduledStartPrices = cusProductsToPrices({ + cusProducts: scheduledStartCustomerProducts, + filters: { excludeOneOffPrices: true }, + }); + + return getSmallestInterval({ + prices: currentPrices.length > 0 ? currentPrices : scheduledStartPrices, + }); +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/timeUtils.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/timeUtils.ts new file mode 100644 index 000000000..c71221992 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/timeUtils.ts @@ -0,0 +1,22 @@ +import { truncateMsToSecondPrecision } from "@autumn/shared"; + +export const SECOND_MS = 1000; + +export const normalizeMs = (timestamp: number) => + truncateMsToSecondPrecision(timestamp); + +export const timestampsEqual = ( + left: number | "now" | undefined | null, + right: number, +) => typeof left === "number" && normalizeMs(left) === normalizeMs(right); + +export const isFutureTimestamp = ({ + timestamp, + nowMs, +}: { + timestamp: number | undefined | null; + nowMs: number; +}) => + timestamp !== undefined && + timestamp !== null && + normalizeMs(timestamp) > nowMs; diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/transitionCandidates.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/transitionCandidates.ts new file mode 100644 index 000000000..6bf17bb6e --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/transitionCandidates.ts @@ -0,0 +1,123 @@ +import type { BillingContext, FullCusProduct } from "@autumn/shared"; +import { buildTransitionPoints } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildTransitionPoints"; +import { + isFutureTimestamp, + normalizeMs, + timestampsEqual, +} from "./timeUtils"; + +const getFutureTrialEndsAt = ({ + billingContext, + customerProducts, + nowMs, +}: { + billingContext: BillingContext; + customerProducts: FullCusProduct[]; + nowMs: number; +}) => { + const trialEndsAt = [ + billingContext.trialContext?.trialEndsAt, + ...customerProducts.map((customerProduct) => customerProduct.trial_ends_at), + ] + .filter((timestamp): timestamp is number => + isFutureTimestamp({ timestamp, nowMs }), + ) + .map(normalizeMs) + .sort((a, b) => a - b); + + return trialEndsAt[0]; +}; + +/** Builds preview candidates from Stripe schedule transitions plus trial ends. */ +export const buildNextCycleTransitionPoints = ({ + billingContext, + customerProducts, + nowMs, +}: { + billingContext: BillingContext; + customerProducts: FullCusProduct[]; + nowMs: number; +}) => { + const trialEndsAt = getFutureTrialEndsAt({ + billingContext, + customerProducts, + nowMs, + }); + + const transitionPoints = buildTransitionPoints({ + customerProducts, + nowMs, + trialEndsAt, + newBillingCycleAnchorMs: + typeof billingContext.requestedBillingCycleAnchor === "number" + ? billingContext.requestedBillingCycleAnchor + : undefined, + }).filter( + (timestamp): timestamp is number => + typeof timestamp === "number" && timestamp > nowMs, + ); + + return Array.from( + new Set([ + ...transitionPoints.map(normalizeMs), + ...(trialEndsAt ? [trialEndsAt] : []), + ]), + ).sort((a, b) => a - b); +}; + +export const hasProductTransitionAt = ({ + customerProducts, + startsAtMs, +}: { + customerProducts: FullCusProduct[]; + startsAtMs: number; +}) => + customerProducts.some( + (customerProduct) => + timestampsEqual(customerProduct.starts_at, startsAtMs) || + timestampsEqual(customerProduct.ended_at, startsAtMs), + ); + +export const hasTrialEndAt = ({ + billingContext, + customerProducts, + startsAtMs, +}: { + billingContext: BillingContext; + customerProducts: FullCusProduct[]; + startsAtMs: number; +}) => + timestampsEqual( + billingContext.trialContext?.trialEndsAt ?? undefined, + startsAtMs, + ) || + customerProducts.some((customerProduct) => + timestampsEqual(customerProduct.trial_ends_at ?? undefined, startsAtMs), + ); + +export const getExactTransitionTimestamp = ({ + billingContext, + customerProducts, + startsAtMs, +}: { + billingContext: BillingContext; + customerProducts: FullCusProduct[]; + startsAtMs: number; +}) => { + const exactTimestamps = [ + typeof billingContext.requestedBillingCycleAnchor === "number" + ? billingContext.requestedBillingCycleAnchor + : undefined, + billingContext.trialContext?.trialEndsAt, + ...customerProducts.flatMap((customerProduct) => [ + customerProduct.starts_at, + customerProduct.ended_at ?? undefined, + customerProduct.trial_ends_at ?? undefined, + ]), + ].filter( + (timestamp): timestamp is number => + typeof timestamp === "number" && timestampsEqual(timestamp, startsAtMs), + ); + + return exactTimestamps.sort((a, b) => a - b)[0] ?? startsAtMs; +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/types.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/types.ts new file mode 100644 index 000000000..a03afaf12 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent/types.ts @@ -0,0 +1,38 @@ +import type { + BillingInterval, + FullCusProduct, +} from "@autumn/shared"; + +export type SmallestInterval = { + interval: BillingInterval; + intervalCount: number; +}; + +type NextCycleEventContext = { + smallestInterval: SmallestInterval; +}; + +export type NextCycleEvent = + | { kind: "none" } + | ({ kind: "anchor_reset" } & NextCycleEventContext) + | ({ + kind: "renewal"; + startsAtMs: number; + customerProducts: FullCusProduct[]; + } & NextCycleEventContext) + | ({ + kind: "scheduled_start"; + startsAtMs: number; + customerProducts: FullCusProduct[]; + } & NextCycleEventContext) + | ({ + kind: "trial_end"; + startsAtMs: number; + customerProducts: FullCusProduct[]; + } & NextCycleEventContext) + | ({ + kind: "scheduled_change"; + startsAtMs: number; + incomingCustomerProducts: FullCusProduct[]; + outgoingCustomerProducts: FullCusProduct[]; + } & NextCycleEventContext); diff --git a/server/src/internal/billing/v2/utils/handleCarryOvers/carryOverUtils.ts b/server/src/internal/billing/v2/utils/handleCarryOvers/carryOverUtils.ts index 51eddba31..9cff7e5fd 100644 --- a/server/src/internal/billing/v2/utils/handleCarryOvers/carryOverUtils.ts +++ b/server/src/internal/billing/v2/utils/handleCarryOvers/carryOverUtils.ts @@ -1,5 +1,4 @@ import { - type AttachParamsV1, deduplicateArray, type ExistingUsagesConfig, type Feature, @@ -16,7 +15,12 @@ export const carryOverUsagesToExistingUsagesConfig = ({ currentCustomerProduct, }: { ctx: AutumnContext; - params: AttachParamsV1; + params: { + carry_over_usages?: { + enabled: boolean; + feature_ids?: string[]; + }; + }; currentCustomerProduct: FullCusProduct; }): ExistingUsagesConfig | undefined => { const carryOverUsages = params.carry_over_usages; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts new file mode 100644 index 000000000..2e887f31b --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts @@ -0,0 +1,21 @@ +import type { FullCustomerEntitlement } from "@autumn/shared"; + +export type CustomerEntitlementCarryIdentity = { + internalFeatureId: string; +}; + +export const carryIdentityToKey = ( + identity: CustomerEntitlementCarryIdentity, +) => identity.internalFeatureId; + +export const customerEntitlementToCarryIdentity = ({ + customerEntitlement, +}: { + customerEntitlement: FullCustomerEntitlement; +}): CustomerEntitlementCarryIdentity => { + const entitlement = customerEntitlement.entitlement; + + return { + internalFeatureId: entitlement.internal_feature_id, + }; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts new file mode 100644 index 000000000..f682ebd01 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts @@ -0,0 +1,164 @@ +import { + type FullCusProduct, + type FullCustomerEntitlement, +} from "@autumn/shared"; +import { + carryIdentityToKey, + customerEntitlementToCarryIdentity, +} from "./carryIdentity"; +import { customerProductWithOnlyEntitlements } from "./projectCustomerProductForCarry"; + +export type CustomerProductCarryGroup = { + fromCustomerProduct: FullCusProduct; + toCustomerProduct: FullCusProduct; +}; + +/** Resolved replacement pair used when an updated item no longer identity-matches its source. */ +export type CustomerProductCarryLink = { + fromCustomerEntitlement: FullCustomerEntitlement; + toCustomerEntitlement: FullCustomerEntitlement; +}; + +const addToGroup = (groups: Map, key: string, value: T) => { + const group = groups.get(key); + if (group) { + group.push(value); + return; + } + + groups.set(key, [value]); +}; + +const groupCustomerEntitlementsByCarryIdentity = ({ + customerEntitlements, +}: { + customerEntitlements: FullCustomerEntitlement[]; +}) => { + const customerEntitlementsByKey = new Map< + string, + FullCustomerEntitlement[] + >(); + + for (const customerEntitlement of customerEntitlements) { + const key = carryIdentityToKey( + customerEntitlementToCarryIdentity({ + customerEntitlement, + }), + ); + addToGroup(customerEntitlementsByKey, key, customerEntitlement); + } + + return customerEntitlementsByKey; +}; + +const getLinkedCustomerProductCarryGroups = ({ + fromCustomerProduct, + toCustomerProduct, + links, +}: { + fromCustomerProduct: FullCusProduct; + toCustomerProduct: FullCusProduct; + links: CustomerProductCarryLink[]; +}): CustomerProductCarryGroup[] => + links.map((link) => ({ + fromCustomerProduct: customerProductWithOnlyEntitlements({ + customerProduct: fromCustomerProduct, + customerEntitlements: [link.fromCustomerEntitlement], + }), + toCustomerProduct: customerProductWithOnlyEntitlements({ + customerProduct: toCustomerProduct, + customerEntitlements: [link.toCustomerEntitlement], + }), + })); + +const getIdentityCustomerProductCarryGroups = ({ + fromCustomerProduct, + toCustomerProduct, + fromCustomerEntitlements, +}: { + fromCustomerProduct: FullCusProduct; + toCustomerProduct: FullCusProduct; + fromCustomerEntitlements: FullCustomerEntitlement[]; +}): CustomerProductCarryGroup[] => { + const toEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({ + customerEntitlements: toCustomerProduct.customer_entitlements, + }); + const fromEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({ + customerEntitlements: fromCustomerEntitlements, + }); + + return Array.from(fromEntitlementsByKey.entries()).flatMap( + ([key, fromEntitlements]) => { + const toEntitlements = toEntitlementsByKey.get(key); + if (!toEntitlements) return []; + + return { + fromCustomerProduct: customerProductWithOnlyEntitlements({ + customerProduct: fromCustomerProduct, + customerEntitlements: fromEntitlements, + }), + toCustomerProduct: customerProductWithOnlyEntitlements({ + customerProduct: toCustomerProduct, + customerEntitlements: toEntitlements, + }), + }; + }, + ); +}; + +const getUnlinkedCustomerEntitlements = ({ + customerEntitlements, + linkedCustomerEntitlementIds, +}: { + customerEntitlements: FullCustomerEntitlement[]; + linkedCustomerEntitlementIds: Set; +}) => { + const unlinkedCustomerEntitlements: FullCustomerEntitlement[] = []; + + for (const customerEntitlement of customerEntitlements) { + if (linkedCustomerEntitlementIds.has(customerEntitlement.id)) continue; + unlinkedCustomerEntitlements.push(customerEntitlement); + } + + return unlinkedCustomerEntitlements; +}; + +export const getCustomerProductCarryGroups = ({ + fromCustomerProduct, + toCustomerProduct, + fromCustomerEntitlements, + links, +}: { + fromCustomerProduct: FullCusProduct; + toCustomerProduct: FullCusProduct; + fromCustomerEntitlements: FullCustomerEntitlement[]; + links?: CustomerProductCarryLink[]; +}): CustomerProductCarryGroup[] => { + const linkedFromCustomerEntitlementIds = new Set( + links?.map((link) => link.fromCustomerEntitlement.id), + ); + const linkedToCustomerEntitlementIds = new Set( + links?.map((link) => link.toCustomerEntitlement.id), + ); + const linkedCarryGroups = getLinkedCustomerProductCarryGroups({ + fromCustomerProduct, + toCustomerProduct, + links: links ?? [], + }); + const identityCarryGroups = getIdentityCustomerProductCarryGroups({ + fromCustomerProduct, + toCustomerProduct: { + ...toCustomerProduct, + customer_entitlements: getUnlinkedCustomerEntitlements({ + customerEntitlements: toCustomerProduct.customer_entitlements, + linkedCustomerEntitlementIds: linkedToCustomerEntitlementIds, + }), + }, + fromCustomerEntitlements: getUnlinkedCustomerEntitlements({ + customerEntitlements: fromCustomerEntitlements, + linkedCustomerEntitlementIds: linkedFromCustomerEntitlementIds, + }), + }); + + return [...linkedCarryGroups, ...identityCarryGroups]; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/index.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/index.ts new file mode 100644 index 000000000..64379fbb7 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/index.ts @@ -0,0 +1,3 @@ +export * from "./carryIdentity"; +export * from "./customerProductCarryGroups"; +export * from "./projectCustomerProductForCarry"; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/projectCustomerProductForCarry.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/projectCustomerProductForCarry.ts new file mode 100644 index 000000000..f86f42dfa --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/projectCustomerProductForCarry.ts @@ -0,0 +1,46 @@ +import { + type FullCusEntWithFullCusProduct, + type FullCusProduct, + type FullCustomerEntitlement, + type FullCustomerPrice, +} from "@autumn/shared"; +import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; + +const customerPricesForCustomerEntitlements = ({ + customerProduct, + customerEntitlements, +}: { + customerProduct: FullCusProduct; + customerEntitlements: FullCustomerEntitlement[]; +}): FullCustomerPrice[] => { + const customerPricesById = new Map(); + + for (const customerEntitlement of customerEntitlements) { + const customerPrice = cusEntToCusPrice({ + cusEnt: { + ...customerEntitlement, + customer_product: customerProduct, + } satisfies FullCusEntWithFullCusProduct, + }); + if (!customerPrice) continue; + + customerPricesById.set(customerPrice.id, customerPrice); + } + + return Array.from(customerPricesById.values()); +}; + +export const customerProductWithOnlyEntitlements = ({ + customerProduct, + customerEntitlements, +}: { + customerProduct: FullCusProduct; + customerEntitlements: FullCustomerEntitlement[]; +}): FullCusProduct => ({ + ...customerProduct, + customer_prices: customerPricesForCustomerEntitlements({ + customerProduct, + customerEntitlements, + }), + customer_entitlements: customerEntitlements, +}); diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts index aede0662a..5578f519f 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -39,6 +39,7 @@ export const initCustomerProduct = ({ accessStartsAt, previousCustomerProductId, onTrialEnd, + processorType, } = initOptions ?? {}; const internalEntityId = @@ -96,8 +97,10 @@ export const initCustomerProduct = ({ status, - // Legacy - // processor: null, + // Only stamp `processor` when an explicit type was supplied (e.g. RevenueCat + // from external-PSP origin flows). Stripe-origin and legacy callers omit + // it; `cusProductToProcessorType` resolves the missing field to Stripe. + ...(processorType ? { processor: { type: processorType } } : {}), starts_at: startsAt, access_starts_at: accessStartsAt ?? null, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts deleted file mode 100644 index 3fb2a3c2e..000000000 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { FullCusProduct, PatchContext } from "@autumn/shared"; - -export const getPatchCarryCustomerProduct = ({ - patchContext, -}: { - patchContext: PatchContext; -}): FullCusProduct => { - const deletedEntitlementIds = new Set( - patchContext.deleteCustomerEntitlements.map( - (customerEntitlement) => customerEntitlement.entitlement.id, - ), - ); - const deletedCustomerPriceIds = new Set( - patchContext.deleteCustomerPrices.map((customerPrice) => customerPrice.id), - ); - - return { - ...patchContext.originalCustomerProduct, - customer_prices: - patchContext.originalCustomerProduct.customer_prices.filter( - (customerPrice) => - deletedCustomerPriceIds.has(customerPrice.id) || - (customerPrice.price.entitlement_id - ? deletedEntitlementIds.has(customerPrice.price.entitlement_id) - : false), - ), - customer_entitlements: patchContext.deleteCustomerEntitlements, - }; -}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts index f7f907c78..335d458e5 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts @@ -1,4 +1,3 @@ export * from "./applyCustomerProductItemsPatch"; -export * from "./getPatchCarryCustomerProduct"; export * from "./initPatchCustomerProduct"; export * from "./initPatchedCustomerEntitlementsAndPrices"; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts index ba166529b..35a03219b 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchCustomerProduct.ts @@ -1,6 +1,7 @@ import { type AutumnBillingPlan, cusProductToProduct, + type InsertCustomerEntitlement, type PatchContext, type TrialContext, type UpdateSubscriptionBillingContext, @@ -68,8 +69,14 @@ export const initPatchCustomerProduct = ({ }): { finalCustomerProduct: PatchContext["finalCustomerProduct"]; customerProductUpdates: CustomerProductUpdates; + oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[]; } => { - const { customerPrices, customerEntitlements } = + const { + customerPrices, + customerEntitlements, + oneOffPrepaidCarryOverEntitlements, + oneOffPrepaidCarryOverCustomerEntitlements, + } = initPatchedCustomerEntitlementsAndPrices({ ctx, billingContext, @@ -95,6 +102,7 @@ export const initPatchCustomerProduct = ({ }); patchContext.insertCustomerPrices = customerPrices; patchContext.insertCustomerEntitlements = customerEntitlements; + patchContext.customEntitlements.push(...oneOffPrepaidCarryOverEntitlements); patchContext.fullProduct = cusProductToProduct({ cusProduct: patchContext.finalCustomerProduct, }); @@ -116,5 +124,6 @@ export const initPatchCustomerProduct = ({ ...trialUpdates, ...customUpdates, }, + oneOffPrepaidCarryOverCustomerEntitlements, }; }; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts index 1bc08ce19..0d42c4167 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts @@ -1,15 +1,18 @@ import type { + Entitlement, FullCustomerEntitlement, FullCustomerPrice, + InsertCustomerEntitlement, PatchContext, UpdateSubscriptionBillingContext, } from "@autumn/shared"; import { enrichEntitlementsWithFeatures } from "@shared/utils/productUtils/entUtils/enrichEntitlement"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { getCustomerProductCarryGroups } from "@/internal/billing/v2/utils/initFullCustomerProduct/carryExisting"; import { applyExistingStatesToCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/applyExisting/applyExistingStatesToCustomerProduct"; import { initCustomerEntitlement } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement"; import { initCustomerPrice } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice"; -import { getPatchCarryCustomerProduct } from "./getPatchCarryCustomerProduct"; +import { applyOneOffPrepaidCarryOvers } from "../../handleOneOffPrepaidCarryOvers/applyOneOffPrepaidCarryOvers"; type PatchInitBillingContext = Pick< UpdateSubscriptionBillingContext, @@ -32,6 +35,8 @@ export const initPatchedCustomerEntitlementsAndPrices = ({ }): { customerPrices: FullCustomerPrice[]; customerEntitlements: FullCustomerEntitlement[]; + oneOffPrepaidCarryOverEntitlements: Entitlement[]; + oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[]; } => { const { fullCustomer, @@ -86,25 +91,71 @@ export const initPatchedCustomerEntitlementsAndPrices = ({ customer_prices: customerPrices, customer_entitlements: customerEntitlements, }; - const carryCustomerProduct = getPatchCarryCustomerProduct({ patchContext }); + const deletedEntitlementsById = new Map( + patchContext.deleteCustomerEntitlements.map((customerEntitlement) => [ + customerEntitlement.id, + customerEntitlement, + ]), + ); + const customerEntitlementsByEntitlementId = new Map( + customerEntitlements.map((customerEntitlement) => [ + customerEntitlement.entitlement.id, + customerEntitlement, + ]), + ); + const carryGroups = getCustomerProductCarryGroups({ + fromCustomerProduct: patchContext.originalCustomerProduct, + toCustomerProduct: customerProductWithNewItemsOnly, + fromCustomerEntitlements: patchContext.deleteCustomerEntitlements, + links: patchContext.updateItemCarryLinks.flatMap((link) => { + const fromCustomerEntitlement = deletedEntitlementsById.get( + link.fromCustomerEntitlementId, + ); + const toCustomerEntitlement = customerEntitlementsByEntitlementId.get( + link.toEntitlementId, + ); - applyExistingStatesToCustomerProduct({ - ctx, - fullCustomer, - customerProduct: customerProductWithNewItemsOnly, - existingUsagesConfig: skipExistingUsageCarry - ? undefined - : { - fromCustomerProduct: carryCustomerProduct, - carryAllConsumableFeatures: true, - }, - existingRolloversConfig: { - fromCustomerProduct: carryCustomerProduct, - }, + if (!fromCustomerEntitlement || !toCustomerEntitlement) return []; + return { fromCustomerEntitlement, toCustomerEntitlement }; + }), }); + const oneOffPrepaidCarryOverEntitlements: Entitlement[] = []; + const oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[] = + []; + + for (const carryGroup of carryGroups) { + applyExistingStatesToCustomerProduct({ + ctx, + fullCustomer, + customerProduct: carryGroup.toCustomerProduct, + existingUsagesConfig: skipExistingUsageCarry + ? undefined + : { + fromCustomerProduct: carryGroup.fromCustomerProduct, + carryAllConsumableFeatures: true, + }, + existingRolloversConfig: { + fromCustomerProduct: carryGroup.fromCustomerProduct, + }, + }); + + const oneOffPrepaidCarryOvers = applyOneOffPrepaidCarryOvers({ + oldCustomerProduct: carryGroup.fromCustomerProduct, + newCustomerProduct: carryGroup.toCustomerProduct, + fullCustomer, + }); + oneOffPrepaidCarryOverEntitlements.push( + ...oneOffPrepaidCarryOvers.entitlements, + ); + oneOffPrepaidCarryOverCustomerEntitlements.push( + ...oneOffPrepaidCarryOvers.customerEntitlements, + ); + } return { customerPrices: customerProductWithNewItemsOnly.customer_prices, customerEntitlements: customerProductWithNewItemsOnly.customer_entitlements, + oneOffPrepaidCarryOverEntitlements, + oneOffPrepaidCarryOverCustomerEntitlements, }; }; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts index a46ea9c8a..ac972f792 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts @@ -29,6 +29,7 @@ export const initScheduledCustomerProduct = ({ currentEpochMs, accessStartsAt, externalId, + isCustom, subscriptionId, subscriptionScheduleId, internalEntityId, @@ -44,6 +45,7 @@ export const initScheduledCustomerProduct = ({ accessStartsAt?: number; /** Customer-facing Autumn subscription API id, stored on customer_products.external_id. */ externalId?: string; + isCustom?: boolean; /** When syncing from an existing Stripe sub/schedule, link the resulting * scheduled cusProduct back to it so the customer-products view shows the * Stripe linkage and downstream actions (cancel, restore) can find it. */ @@ -75,6 +77,7 @@ export const initScheduledCustomerProduct = ({ status: accessStartsAt === undefined ? CusProductStatus.Scheduled : undefined, accessStartsAt, externalId, + isCustom, subscriptionId, subscriptionScheduleId, internalEntityId, diff --git a/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts new file mode 100644 index 000000000..bfcbcd21d --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts @@ -0,0 +1,107 @@ +import { generateKsuid } from "@autumn/ksuid"; +import type { BillingContext } from "@autumn/shared"; +import { + customerProductToEntity, + type DbInvoiceLineItem, + type FullCusProduct, + type InvoiceLineItemDiscount, + type LineItem, + type LineItemContext, + LineItemSchema, + orgToCurrency, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +export const chargeRowToRefundLineItem = ({ + chargeRow, + creditAmount, + customerProduct, + billingContext, + ctx, +}: { + chargeRow: DbInvoiceLineItem; + creditAmount: number; + customerProduct: FullCusProduct; + billingContext: BillingContext; + ctx: AutumnContext; +}): LineItem => { + const periodStart = + chargeRow.effective_period_start ?? billingContext.currentEpochMs; + const periodEnd = + chargeRow.effective_period_end ?? billingContext.currentEpochMs; + + const entity = customerProductToEntity({ + customerProduct, + entities: billingContext.fullCustomer.entities, + }); + + const matchingCusPrice = customerProduct.customer_prices.find( + (cp) => + cp.price.id === chargeRow.price_id || + (chargeRow.stripe_price_id != null && + cp.price.config?.stripe_price_id === chargeRow.stripe_price_id), + ); + + const couponNameById = new Map( + (billingContext.stripeDiscounts ?? []).map((discount) => [ + discount.source.coupon.id, + discount.source.coupon.name ?? discount.source.coupon.id, + ]), + ); + const price = + matchingCusPrice?.price ?? customerProduct.customer_prices[0]?.price; + + if (!price) { + throw new Error( + `[chargeRowToRefundLineItem] No price found on cusProduct ${customerProduct.id} for charge row ${chargeRow.id}`, + ); + } + + const context: LineItemContext = { + price, + product: customerProduct.product, + feature: undefined, + currency: orgToCurrency({ org: ctx.org }), + billingPeriod: { start: periodStart, end: periodEnd }, + effectivePeriod: { start: billingContext.currentEpochMs, end: periodEnd }, + direction: "refund", + now: billingContext.currentEpochMs, + billingTiming: "in_advance", + discountable: false, + entity, + customerProduct, + customerPrice: matchingCusPrice, + }; + + const description = chargeRow.description + ? `Unused ${chargeRow.description}` + : `Unused ${customerProduct.product.name}`; + + const lineItemData = { + id: generateKsuid({ prefix: "invoice_li_" }), + amount: creditAmount, + amountAfterDiscounts: creditAmount, + description, + context, + stripePriceId: chargeRow.stripe_price_id ?? undefined, + stripeProductId: chargeRow.stripe_product_id ?? undefined, + chargeImmediately: true, + prorated: true, + discounts: + (chargeRow.discounts as InvoiceLineItemDiscount[] | null)?.map((d) => ({ + amountOff: d.amount_off, + percentOff: d.percent_off, + stripeCouponId: d.stripe_coupon_id, + couponName: d.stripe_coupon_id + ? (couponNameById.get(d.stripe_coupon_id) ?? d.stripe_coupon_id) + : undefined, + })) ?? [], + }; + + const result = LineItemSchema.safeParse(lineItemData); + if (!result.success) { + throw result.error; + } + + return result.data; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts index 4378f4a93..b28c40c8c 100644 --- a/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts @@ -20,10 +20,9 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { getBillingCycleAnchorForDirection } from "@/internal/billing/v2/utils/billingContext/getBillingCycleAnchorForDirection"; import { augmentBillingContextForAnchorResetRefund } from "./augmentBillingContextForAnchorResetRefund"; +import { getBackdatedLineItemContext } from "./getBackdatedLineItemContext"; import { getLineItemBillingPeriod } from "./getLineItemBillingPeriod"; -type LineItemDirection = "charge" | "refund"; - /** * Generates line items for a customer product. * - "charge" direction: positive amounts (for NEW product) @@ -38,6 +37,7 @@ export const customerProductToLineItems = ({ billingContext, direction, priceFilters, + billingCycleAnchorMsOverride, }: { ctx: AutumnContext; customerProduct: FullCusProduct; @@ -46,13 +46,16 @@ export const customerProductToLineItems = ({ priceFilters?: { excludeOneOffPrices?: boolean; }; + billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"]; }): LineItem[] => { const { currentEpochMs } = billingContext; - const anchorMs = getBillingCycleAnchorForDirection({ - billingContext, - direction, - }); + const anchorMs = + billingCycleAnchorMsOverride ?? + getBillingCycleAnchorForDirection({ + billingContext, + direction, + }); const lineItems: LineItem[] = []; const entity = customerProductToEntity({ @@ -95,6 +98,15 @@ export const customerProductToLineItems = ({ if (action.type === "use_snapped_now") effectiveNow = action.snappedNow; } + const backdatedLineItemContext = getBackdatedLineItemContext({ + price, + billingContext: billingContextForPeriod, + billingPeriod, + direction, + billingTiming: "in_advance", + }); + if (backdatedLineItemContext) effectiveNow = backdatedLineItemContext.now; + // Build line item context const context: LineItemContext = { price, @@ -109,6 +121,8 @@ export const customerProductToLineItems = ({ entity, customerProduct, customerPrice: cusPrice, + effectivePeriod: backdatedLineItemContext?.effectivePeriod, + backdate: backdatedLineItemContext?.backdate, }; if (isFixedPrice(price)) { diff --git a/server/src/internal/billing/v2/utils/lineItems/getBackdatedLineItemContext.ts b/server/src/internal/billing/v2/utils/lineItems/getBackdatedLineItemContext.ts new file mode 100644 index 000000000..c2cad5817 --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/getBackdatedLineItemContext.ts @@ -0,0 +1,47 @@ +import type { + BillingContext, + BillingPeriod, + LineItemContext, + Price, +} from "@autumn/shared"; +import { getBackdatedImmediatePeriod } from "@/internal/billing/v2/utils/backdate/getBackdatedImmediatePeriod"; + +type BackdatedLineItemContext = Pick< + LineItemContext, + "now" | "effectivePeriod" | "backdate" +>; + +export const getBackdatedLineItemContext = ({ + price, + billingContext, + billingPeriod, + direction, + billingTiming, +}: { + price: Price; + billingContext: BillingContext; + billingPeriod?: BillingPeriod; + direction: LineItemContext["direction"]; + billingTiming: LineItemContext["billingTiming"]; +}): BackdatedLineItemContext | undefined => { + if (!billingPeriod) return undefined; + if (billingContext.subscriptionBackdateStartMs === undefined) return undefined; + if (billingContext.stripeSubscription) return undefined; + if (direction !== "charge") return undefined; + if (billingTiming !== "in_advance") return undefined; + + const period = getBackdatedImmediatePeriod({ + price, + billingContext, + }); + if (!period) return undefined; + + return { + now: billingPeriod.start, + effectivePeriod: { start: period.start, end: period.end }, + backdate: { + startsAt: period.start, + cycleCount: period.cycleCount, + }, + }; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts b/server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts new file mode 100644 index 000000000..4938db98d --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts @@ -0,0 +1,39 @@ +import type { BillingContext, FullCusProduct, LineItem } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { customerProductToLineItems } from "./customerProductToLineItems"; +import { getRefundLineItems } from "./getRefundLineItems"; + +export const getLineItemsForDirection = ({ + ctx, + customerProduct, + billingContext, + direction, + priceFilters, + billingCycleAnchorMsOverride, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; + direction: "charge" | "refund"; + priceFilters?: { excludeOneOffPrices?: boolean }; + billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"]; +}): LineItem[] => { + if (direction === "refund") { + return getRefundLineItems({ + ctx, + customerProduct, + billingContext, + priceFilters, + billingCycleAnchorMsOverride, + }); + } + + return customerProductToLineItems({ + ctx, + customerProduct, + billingContext, + direction, + priceFilters, + billingCycleAnchorMsOverride, + }); +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts new file mode 100644 index 000000000..b5941b2b6 --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts @@ -0,0 +1,45 @@ +import type { BillingContext, FullCusProduct, LineItem } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { customerProductToLineItems } from "./customerProductToLineItems"; +import { invoiceCreditFromStoredLineItems } from "./invoiceCreditFromStoredLineItems"; + +export const getRefundLineItems = ({ + ctx, + customerProduct, + billingContext, + priceFilters, + billingCycleAnchorMsOverride, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; + priceFilters?: { excludeOneOffPrices?: boolean }; + billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"]; +}): LineItem[] => { + const { + lineItems: matchedCredits, + allPricesResolved, + resolvedPriceIds, + } = invoiceCreditFromStoredLineItems({ + ctx, + customerProduct, + billingContext, + }); + + if (allPricesResolved) return matchedCredits; + + const catalogCredits = customerProductToLineItems({ + ctx, + customerProduct, + billingContext, + direction: "refund", + priceFilters, + billingCycleAnchorMsOverride, + }); + + const fallbackCredits = catalogCredits.filter( + (li) => !resolvedPriceIds.includes(li.context.price.id), + ); + + return [...matchedCredits, ...fallbackCredits]; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts new file mode 100644 index 000000000..2afa57bf8 --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts @@ -0,0 +1,31 @@ +import type { BillingContext, FullCusProduct, LineItem } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { getRefundLineItems } from "./getRefundLineItems"; + +export const getRefundLineItemsForPrice = ({ + ctx, + customerProduct, + billingContext, + priceId, + catalogFallback, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; + priceId: string; + catalogFallback: LineItem | undefined; +}): LineItem[] => { + const matchedRefundLineItems = getRefundLineItems({ + ctx, + customerProduct, + billingContext, + }); + + const matchedRefundsForPrice = matchedRefundLineItems.filter( + (li) => li.context.price.id === priceId, + ); + + if (matchedRefundsForPrice.length > 0) return matchedRefundsForPrice; + + return catalogFallback ? [catalogFallback] : []; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts new file mode 100644 index 000000000..423284811 --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts @@ -0,0 +1,125 @@ +import type { BillingContext } from "@autumn/shared"; +import { + type FullCusProduct, + isOneOffPrice, + type LineItem, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { chargeRowToRefundLineItem } from "./chargeRowToRefundLineItem"; +import { + computeAlreadyRefundedForCharge, + computeProratedCredit, + splitMultiEntityAmount, +} from "./storedLineItemUtils"; + +type InvoiceMatchedCreditResult = { + lineItems: LineItem[]; + allPricesResolved: boolean; + resolvedPriceIds: string[]; +}; + +export const invoiceCreditFromStoredLineItems = ({ + ctx, + customerProduct, + billingContext, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; +}): InvoiceMatchedCreditResult => { + const { logger } = ctx; + const now = billingContext.currentEpochMs; + const chargeRows = billingContext.storedChargeLineItems ?? []; + const refundRows = billingContext.storedRefundLineItems ?? []; + + const pricesToCredit = customerProduct.customer_prices.filter( + (cp) => !isOneOffPrice(cp.price), + ); + + if (pricesToCredit.length === 0) { + return { lineItems: [], allPricesResolved: true, resolvedPriceIds: [] }; + } + + const allLineItems: LineItem[] = []; + const resolvedPriceIds: string[] = []; + let anyMissed = false; + + for (const cusPrice of pricesToCredit) { + const priceChargeRows = chargeRows.filter( + (row) => + row.customer_product_ids.includes(customerProduct.id) && + (row.price_id === cusPrice.price.id || + row.stripe_price_id === cusPrice.price.config?.stripe_price_id), + ); + + const usableRows = priceChargeRows.filter( + (row) => + row.customer_product_ids.length > 0 && + row.effective_period_start != null && + row.effective_period_end != null && + row.effective_period_start < now && + row.effective_period_end > now, + ); + + if (usableRows.length === 0) { + anyMissed = true; + logger.warn( + `[invoiceCreditFromStoredLineItems] No usable stored charge row for cusProduct=${customerProduct.id} price=${cusPrice.price.id}; falling back to catalog synthesis`, + ); + continue; + } + + resolvedPriceIds.push(cusPrice.price.id); + + const currentPeriodRefunds = refundRows.filter( + (r) => + r.customer_product_ids.includes(customerProduct.id) && + r.effective_period_end != null && + r.effective_period_start != null && + r.effective_period_start < now && + r.effective_period_end > now, + ); + + for (const chargeRow of usableRows) { + const attributedAmount = splitMultiEntityAmount(chargeRow); + + const alreadyRefunded = computeAlreadyRefundedForCharge({ + chargeRow, + refundRows: currentPeriodRefunds, + }); + + const adjustedChargeRow = { + ...chargeRow, + amount_after_discounts: attributedAmount, + }; + + const creditAmount = computeProratedCredit({ + chargeRow: adjustedChargeRow, + now, + alreadyRefunded, + }); + + if (creditAmount === 0) continue; + + allLineItems.push( + chargeRowToRefundLineItem({ + chargeRow, + creditAmount, + customerProduct, + billingContext, + ctx, + }), + ); + } + } + + if (anyMissed && allLineItems.length === 0) { + return { lineItems: [], allPricesResolved: false, resolvedPriceIds }; + } + + return { + lineItems: allLineItems, + allPricesResolved: !anyMissed, + resolvedPriceIds, + }; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts b/server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts new file mode 100644 index 000000000..be302ee4f --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts @@ -0,0 +1,81 @@ +import type { DbInvoiceLineItem } from "@autumn/shared"; +import { Decimal } from "decimal.js"; + +export const isWithinPeriod = ( + inner: DbInvoiceLineItem, + outer: DbInvoiceLineItem, +): boolean => + inner.effective_period_start != null && + outer.effective_period_start != null && + inner.effective_period_end != null && + outer.effective_period_end != null && + inner.effective_period_start >= outer.effective_period_start && + inner.effective_period_end <= outer.effective_period_end; + +export const hasSamePrice = ( + a: DbInvoiceLineItem, + b: DbInvoiceLineItem, +): boolean => + (a.price_id != null && a.price_id === b.price_id) || + (a.stripe_price_id != null && a.stripe_price_id === b.stripe_price_id); + +export const computeProratedCredit = ({ + chargeRow, + now, + alreadyRefunded, +}: { + chargeRow: DbInvoiceLineItem; + now: number; + alreadyRefunded: number; +}): number => { + const periodStart = chargeRow.effective_period_start; + const periodEnd = chargeRow.effective_period_end; + + if (periodStart == null || periodEnd == null || periodEnd <= periodStart) { + return 0; + } + + const totalCharged = chargeRow.amount_after_discounts; + const refundable = new Decimal(totalCharged).minus(alreadyRefunded); + + if (refundable.lte(0)) return 0; + + const remaining = new Decimal(periodEnd).minus(now); + const total = new Decimal(periodEnd).minus(periodStart); + + if (remaining.lte(0)) return 0; + + const prorationFraction = remaining.div(total); + return prorationFraction.mul(refundable).neg().toNumber(); +}; + +export const computeAlreadyRefundedForCharge = ({ + chargeRow, + refundRows, +}: { + chargeRow: DbInvoiceLineItem; + refundRows: DbInvoiceLineItem[]; +}): number => { + const matchingRefunds = refundRows.filter( + (refund) => + isWithinPeriod(refund, chargeRow) && hasSamePrice(refund, chargeRow), + ); + + return matchingRefunds.reduce( + (sum, r) => + new Decimal(sum) + .plus(Math.abs(splitMultiEntityAmount(r))) + .toNumber(), + 0, + ); +}; + +export const splitMultiEntityAmount = ( + chargeRow: DbInvoiceLineItem, +): number => { + const ids = chargeRow.customer_product_ids; + if (ids.length <= 1) return chargeRow.amount_after_discounts; + return new Decimal(chargeRow.amount_after_discounts) + .div(ids.length) + .toNumber(); +}; diff --git a/server/src/internal/chat/ChatService.ts b/server/src/internal/chat/ChatService.ts new file mode 100644 index 000000000..e4c466253 --- /dev/null +++ b/server/src/internal/chat/ChatService.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto"; +import { + AppEnv, + apiKeys, + chatInstallations, + createChatInstallState, +} from "@autumn/shared"; +import { addMinutes } from "date-fns"; +import { and, eq } from "drizzle-orm"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { + createSlackInstallUrl, + getChatStateSecret, + getMissingSlackScopes, + slackProvider, +} from "./chatUtils.js"; + +export class ChatService { + static async listInstallations(ctx: AutumnContext) { + const installations = await ctx.db.query.chatInstallations.findMany({ + where: and( + eq(chatInstallations.org_id, ctx.org.id), + eq(chatInstallations.provider, slackProvider), + ), + }); + + return installations.map((installation) => { + const missingScopes = getMissingSlackScopes(installation.scopes); + return { + connected: true, + provider: installation.provider, + workspace_id: installation.workspace_id, + workspace_name: installation.workspace_name, + bot_user_id: installation.bot_user_id, + default_env: installation.default_env, + scopes: installation.scopes, + missing_scopes: missingScopes, + needs_reconnect: missingScopes.length > 0, + created_at: installation.created_at, + updated_at: installation.updated_at, + }; + }); + } + + static createInstallUrl(ctx: AutumnContext, env = AppEnv.Live) { + const state = createChatInstallState({ + secret: getChatStateSecret(), + provider: slackProvider, + orgId: ctx.org.id, + userId: ctx.userId ?? "", + env, + expiresAt: addMinutes(Date.now(), 10).getTime(), + nonce: randomUUID(), + }); + const url = createSlackInstallUrl(state); + + console.info("[chat] Created install URL", { + provider: slackProvider, + orgId: ctx.org.id, + env, + redirectUri: + new URL(url).searchParams.get("redirect_uri") ?? "Slack app default", + }); + + return url; + } + + static async disconnect(ctx: AutumnContext) { + await ctx.db.transaction(async (tx) => { + const installations = await tx.query.chatInstallations.findMany({ + where: and( + eq(chatInstallations.org_id, ctx.org.id), + eq(chatInstallations.provider, slackProvider), + ), + }); + + const keyIds = installations + .flatMap((installation) => [ + installation.sandbox_api_key_id, + installation.live_api_key_id, + ]) + .filter((id): id is string => !!id); + for (const id of keyIds) { + await tx + .delete(apiKeys) + .where(and(eq(apiKeys.id, id), eq(apiKeys.org_id, ctx.org.id))); + } + + await tx + .delete(chatInstallations) + .where( + and( + eq(chatInstallations.org_id, ctx.org.id), + eq(chatInstallations.provider, slackProvider), + ), + ); + }); + } +} diff --git a/server/src/internal/chat/chatRouter.ts b/server/src/internal/chat/chatRouter.ts new file mode 100644 index 000000000..a9e44f9b7 --- /dev/null +++ b/server/src/internal/chat/chatRouter.ts @@ -0,0 +1,11 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleCreateChatInstall } from "./handlers/handleCreateChatInstall.js"; +import { handleDisconnectChat } from "./handlers/handleDisconnectChat.js"; +import { handleGetChat } from "./handlers/handleGetChat.js"; + +export const chatRouter = new Hono(); + +chatRouter.get("/", ...handleGetChat); +chatRouter.post("/install", ...handleCreateChatInstall); +chatRouter.delete("/:provider", ...handleDisconnectChat); diff --git a/server/src/internal/chat/chatUtils.ts b/server/src/internal/chat/chatUtils.ts new file mode 100644 index 000000000..1d6b0037d --- /dev/null +++ b/server/src/internal/chat/chatUtils.ts @@ -0,0 +1,61 @@ +import { ErrCode, RecaseError } from "@autumn/shared"; + +export const slackProvider = "slack" as const; +export const slackAdminProviderPrefix = "slack_admin" as const; + +export const getSlackAdminProvider = ({ + clientId = getRequiredChatEnv("SLACK_CLIENT_ID"), +}: { + clientId?: string; +} = {}) => `${slackAdminProviderPrefix}:${clientId}` as const; + +export const defaultSlackScopes = [ + "app_mentions:read", + "assistant:write", + "channels:history", + "channels:read", + "chat:write", + "groups:history", + "groups:read", + "im:history", + "im:read", + "im:write", + "mpim:history", + "mpim:read", + "users:read", +]; + +export const getMissingSlackScopes = (scopes: string[]) => { + const granted = new Set(scopes); + return defaultSlackScopes.filter((scope) => !granted.has(scope)); +}; + +export const getRequiredChatEnv = (key: string) => { + const value = process.env[key]; + if (value) return value; + + throw new RecaseError({ + message: `${key} is not configured`, + code: ErrCode.InvalidRequest, + statusCode: 500, + }); +}; + +export const getChatStateSecret = () => + process.env.CHAT_STATE_SECRET ?? + process.env.SLACK_STATE_SECRET ?? + process.env.BETTER_AUTH_SECRET ?? + getRequiredChatEnv("ENCRYPTION_PASSWORD"); + +export const createSlackInstallUrl = (state: string) => { + const scope = process.env.SLACK_BOT_SCOPES ?? defaultSlackScopes.join(","); + const params = new URLSearchParams({ + client_id: getRequiredChatEnv("SLACK_CLIENT_ID"), + scope, + state, + }); + if (process.env.SLACK_REDIRECT_URI) { + params.set("redirect_uri", process.env.SLACK_REDIRECT_URI); + } + return `https://slack.com/oauth/v2/authorize?${params}`; +}; diff --git a/server/src/internal/chat/handlers/handleCreateChatInstall.ts b/server/src/internal/chat/handlers/handleCreateChatInstall.ts new file mode 100644 index 000000000..91ad3f862 --- /dev/null +++ b/server/src/internal/chat/handlers/handleCreateChatInstall.ts @@ -0,0 +1,21 @@ +import { AppEnv, Scopes } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { ChatService } from "../ChatService.js"; +import { slackProvider } from "../chatUtils.js"; + +const installBody = z.strictObject({ + provider: z.literal(slackProvider), + env: z.enum(AppEnv).optional(), +}); + +export const handleCreateChatInstall = createRoute({ + scopes: [Scopes.Organisation.Write, Scopes.ApiKeys.Write], + body: installBody, + handler: async (c) => { + const { env } = c.req.valid("json"); + const url = ChatService.createInstallUrl(c.get("ctx"), env); + + return c.json({ url }); + }, +}); diff --git a/server/src/internal/chat/handlers/handleDisconnectChat.ts b/server/src/internal/chat/handlers/handleDisconnectChat.ts new file mode 100644 index 000000000..a53cf2b95 --- /dev/null +++ b/server/src/internal/chat/handlers/handleDisconnectChat.ts @@ -0,0 +1,17 @@ +import { Scopes } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { ChatService } from "../ChatService.js"; +import { slackProvider } from "../chatUtils.js"; + +const providerParam = z.literal(slackProvider); + +export const handleDisconnectChat = createRoute({ + scopes: [Scopes.Organisation.Write, Scopes.ApiKeys.Write], + handler: async (c) => { + providerParam.parse(c.req.param("provider")); + await ChatService.disconnect(c.get("ctx")); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/chat/handlers/handleGetChat.ts b/server/src/internal/chat/handlers/handleGetChat.ts new file mode 100644 index 000000000..a52a73f80 --- /dev/null +++ b/server/src/internal/chat/handlers/handleGetChat.ts @@ -0,0 +1,12 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { ChatService } from "../ChatService.js"; + +export const handleGetChat = createRoute({ + scopes: [Scopes.Organisation.Read], + handler: async (c) => { + return c.json({ + installations: await ChatService.listInstallations(c.get("ctx")), + }); + }, +}); diff --git a/server/src/internal/customers/CusBatchService.ts b/server/src/internal/customers/CusBatchService.ts index 490ae97ab..5f2c6c0da 100644 --- a/server/src/internal/customers/CusBatchService.ts +++ b/server/src/internal/customers/CusBatchService.ts @@ -23,30 +23,16 @@ import { CusSearchService } from "./CusSearchService.js"; import { getCursorPaginatedFullCusQuery } from "./cursorPaginatedFullCusQuery.js"; import { getApiCustomerBase } from "./cusUtils/apiCusUtils/getApiCustomerBase.js"; import { - type DashboardProductVersionFilter, - type DashboardStatusFilter, getPaginatedFullCusQuery, + parseDashboardProcessorFilter, + parseDashboardStatusFilter, + parseDashboardVersionFilter, } from "./getFullCusQuery.js"; - -const parseDashboardVersionFilter = ( - raw: string[] | undefined, -): DashboardProductVersionFilter[] => { - if (!raw?.length) return []; - return raw - .filter(Boolean) - .map((s) => { - const [productId, version] = s.split(":"); - return { productId, version: parseInt(version, 10) }; - }) - .filter( - (v): v is DashboardProductVersionFilter => - !!v.productId && !Number.isNaN(v.version), - ); -}; import { type FlattenedCustomerRow, reassembleFlattenedCustomer, } from "./reassembleFlattenedCustomer/index.js"; +import type { CustomerListFilters } from "./customerListFilters.js"; export class CusBatchService { static async getByInternalIds({ @@ -311,12 +297,7 @@ export class CusBatchService { }: { ctx: RequestContext; search: string; - filters?: { - status?: string[]; - version?: string[]; - none?: boolean; - processor?: string[]; - }; + filters?: CustomerListFilters; cursor: { t: number; id: string } | null; limit: number; }): Promise<{ @@ -332,14 +313,7 @@ export class CusBatchService { orgSlug: ctx.org.slug, }); - const statusFilters = (filters?.status ?? []).filter( - (s): s is DashboardStatusFilter => - s === "active" || - s === "past_due" || - s === "canceled" || - s === "free_trial" || - s === "expired", - ); + const statusFilters = parseDashboardStatusFilter(filters?.status); const productVersionFilters = parseDashboardVersionFilter(filters?.version); @@ -392,7 +366,7 @@ export class CusBatchService { search: requiresResolveStep ? undefined : search, processors: requiresResolveStep ? undefined - : (filters?.processor as ListCustomersV2Params["processors"]), + : parseDashboardProcessorFilter(filters?.processor), cusProductLimit, }); diff --git a/server/src/internal/customers/CusSearchService.ts b/server/src/internal/customers/CusSearchService.ts index 104bc873f..11e9c47c9 100644 --- a/server/src/internal/customers/CusSearchService.ts +++ b/server/src/internal/customers/CusSearchService.ts @@ -24,6 +24,13 @@ import { import { alias } from "drizzle-orm/pg-core"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { getOrgCusProductLimit } from "../misc/edgeConfig/orgLimitsStore.js"; +import type { CustomerListFilters } from "./customerListFilters.js"; +import { + type DashboardProductVersionFilter, + isCustomDashboardProductFilter, + isVersionDashboardProductFilter, + parseDashboardVersionFilter, +} from "./getFullCusQuery.js"; // Create alias for subquery const customerProductsAlias = alias(customerProducts, "cp_alias"); @@ -54,12 +61,26 @@ const productFields = { is_add_on: products.is_add_on, }; -interface SearchFilters { - status?: string[]; - version?: string[]; - none?: boolean; - processor?: string[]; -} +const dashboardProductFilterToDrizzleSql = ( + filter: DashboardProductVersionFilter, +) => + and( + isCustomDashboardProductFilter(filter) + ? and( + eq(customerProducts.product_id, filter.productId), + eq(customerProducts.is_custom, true), + ) + : and(eq(products.id, filter.productId), eq(products.version, filter.version)), + ); + +const dashboardProductFilterToRawSql = ( + filter: DashboardProductVersionFilter, +) => + isCustomDashboardProductFilter(filter) + ? sql`(${customerProducts.product_id} = ${filter.productId} AND ${customerProducts.is_custom} = true)` + : sql`(${products.id} = ${filter.productId} AND ${products.version} = ${filter.version})`; + +type SearchFilters = CustomerListFilters; export class CusSearchService { static getProcessorFilterSql({ @@ -153,29 +174,13 @@ export class CusSearchService { statuses = []; } - // Handle product:version combinations - let productVersionFilters: Array<{ productId: string; version: number }> = - []; - - // Parse version field which now contains "productId:version,productId2:version2" - if (filters.version && filters.version.length > 0) { - const versionSelections = filters.version.filter(Boolean); - productVersionFilters = versionSelections.map((selection) => { - const [productId, version] = selection.split(":"); - return { productId, version: parseInt(version) }; - }); - } + const productVersionFilters = parseDashboardVersionFilter(filters.version); const filtersDrizzle = and( // New product:version filtering productVersionFilters.length > 0 ? or( - ...productVersionFilters.map((pv) => - and( - eq(customerProducts.product_id, pv.productId), - eq(products.version, pv.version), - ), - ), + ...productVersionFilters.map(dashboardProductFilterToDrizzleSql), ) : undefined, // Legacy product filtering (fallback) @@ -958,11 +963,10 @@ const buildSearchPredicates = ({ filters?.status && filters.status.length > 0 && !filters.status.includes("") ? filters.status : []; - const versions = filters?.version?.filter(Boolean) ?? []; - const productVersionFilters = versions.map((selection) => { - const [productId, version] = selection.split(":"); - return { productId, version: parseInt(version, 10) }; - }); + const productVersionFilters = parseDashboardVersionFilter(filters?.version); + const hasNumberedVersion = productVersionFilters.some( + isVersionDashboardProductFilter, + ); if (statuses.length === 0 && productVersionFilters.length === 0) { return { @@ -1005,10 +1009,7 @@ const buildSearchPredicates = ({ const versionRaw = productVersionFilters.length > 0 ? sql`(${sql.join( - productVersionFilters.map( - (pv) => - sql`(${customerProducts.product_id} = ${pv.productId} AND ${products.version} = ${pv.version})`, - ), + productVersionFilters.map(dashboardProductFilterToRawSql), sql` OR `, )})` : null; @@ -1038,12 +1039,7 @@ const buildSearchPredicates = ({ const filtersDrizzle = and( productVersionFilters.length > 0 ? or( - ...productVersionFilters.map((pv) => - and( - eq(customerProducts.product_id, pv.productId), - eq(products.version, pv.version), - ), - ), + ...productVersionFilters.map(dashboardProductFilterToDrizzleSql), ) : undefined, statuses.length > 0 @@ -1093,7 +1089,7 @@ const buildSearchPredicates = ({ return { kind: "productMode", - useInnerJoin: productVersionFilters.length > 0, + useInnerJoin: hasNumberedVersion, where: and( shouldApplyActiveFilter ? activeDrizzle : undefined, filtersDrizzle, diff --git a/server/src/internal/customers/actions/ensureStripeCustomerFromCustomerData.ts b/server/src/internal/customers/actions/ensureStripeCustomerFromCustomerData.ts new file mode 100644 index 000000000..20033a53f --- /dev/null +++ b/server/src/internal/customers/actions/ensureStripeCustomerFromCustomerData.ts @@ -0,0 +1,40 @@ +import type { Customer, CustomerData } from "@autumn/shared"; +import { getOrCreateStripeCustomer } from "@/external/stripe/customers/index.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { updateCachedCustomerData as updateCachedFullSubjectCustomerData } from "@/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.js"; +import { updateCachedCustomerData as updateCachedFullCustomerData } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/updateCachedCustomerData.js"; + +export const ensureStripeCustomerFromCustomerData = async ({ + ctx, + customer, + customerData, +}: { + ctx: AutumnContext; + customer: Customer; + customerData?: CustomerData; +}) => { + if (!customerData?.create_in_stripe || customer.processor?.id) return false; + + await getOrCreateStripeCustomer({ + ctx, + customer, + }); + + if (!customer.processor?.id) return false; + + const customerId = customer.id || customer.internal_id; + await Promise.all([ + updateCachedFullCustomerData({ + ctx, + customerId, + updates: { processor: customer.processor }, + }), + updateCachedFullSubjectCustomerData({ + ctx, + customerId, + updates: { processor: customer.processor }, + }), + ]); + + return true; +}; diff --git a/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts b/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts index 27572a7d8..91036172e 100644 --- a/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts +++ b/server/src/internal/customers/actions/getOrCreateApiCustomerByRollout.ts @@ -5,6 +5,7 @@ import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjec import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js"; import { getOrCreateCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; import { getApiCustomerV2 } from "../cusUtils/getApiCustomerV2/index.js"; +import { ensureStripeCustomerFromCustomerData } from "./ensureStripeCustomerFromCustomerData.js"; export const getOrCreateApiCustomerByRollout = async ({ ctx, @@ -19,29 +20,34 @@ export const getOrCreateApiCustomerByRollout = async ({ source?: string; withAutumnId?: boolean; }) => { + let fullSubject: + | Awaited> + | undefined; + let fullCustomer: + | Awaited> + | undefined; + if (isFullSubjectRolloutEnabled({ ctx })) { - const fullSubject = await getOrCreateCachedFullSubject({ + fullSubject = await getOrCreateCachedFullSubject({ ctx, params, source, }); - - return getApiCustomerV2({ + } else { + fullCustomer = await getOrCreateCachedFullCustomer({ ctx, - fullSubject, - withAutumnId, + params, + source, }); } - const fullCustomer = await getOrCreateCachedFullCustomer({ + await ensureStripeCustomerFromCustomerData({ ctx, - params, - source, + customer: fullSubject?.customer ?? fullCustomer!, + customerData: params.customer_data, }); - return getApiCustomer({ - ctx, - fullCustomer, - withAutumnId, - }); + if (fullSubject) return getApiCustomerV2({ ctx, fullSubject, withAutumnId }); + + return getApiCustomer({ ctx, fullCustomer: fullCustomer!, withAutumnId }); }; diff --git a/server/src/internal/customers/cursorPaginatedFullCusQuery.ts b/server/src/internal/customers/cursorPaginatedFullCusQuery.ts index 90c22ab49..7621fbda5 100644 --- a/server/src/internal/customers/cursorPaginatedFullCusQuery.ts +++ b/server/src/internal/customers/cursorPaginatedFullCusQuery.ts @@ -68,6 +68,8 @@ export const getCursorPaginatedFullCusQuery = ({ const customerListFilterSql = getCustomerListFilterSql({ internalCustomerIds, + orgId, + env, inStatuses, plans, processors, diff --git a/server/src/internal/customers/cusProducts/CusProdReadService.ts b/server/src/internal/customers/cusProducts/CusProdReadService.ts index a2807a82a..8f456bc74 100644 --- a/server/src/internal/customers/cusProducts/CusProdReadService.ts +++ b/server/src/internal/customers/cusProducts/CusProdReadService.ts @@ -140,24 +140,7 @@ export class CusProdReadService { orgId: string; env: AppEnv; }) { - const internalProductIds = await db - .select({ - internal_id: products.internal_id, - }) - .from(products) - .where( - and( - eq(products.id, productId), - eq(products.org_id, orgId), - eq(products.env, env), - ), - ); - - const internalProductIdsArray = internalProductIds.map( - (item) => item.internal_id, - ); - - const result = await db + const rows = await db .select({ active: countDistinct(customerProducts.internal_customer_id).as( "active", @@ -173,17 +156,82 @@ export class CusProdReadService { ).as("trialing"), all: countDistinct(customerProducts.internal_customer_id).as("all"), }) - .from(customerProducts) + .from(products) + .leftJoin( + customerProducts, + and( + eq(customerProducts.internal_product_id, products.internal_id), + inArray(customerProducts.status, activeStatuses), + ), + ) .where( and( - inArray( - customerProducts.internal_product_id, - internalProductIdsArray, - ), - inArray(customerProducts.status, activeStatuses), + eq(products.id, productId), + eq(products.org_id, orgId), + eq(products.env, env), ), ); - return result[0]; + return rows[0]; + } + + static async getCountsPerVersion({ + db, + productId, + orgId, + env, + }: { + db: DrizzleCli; + productId: string; + orgId: string; + env: AppEnv; + }) { + const rows = await db + .select({ + version: products.version, + active: countDistinct(customerProducts.internal_customer_id).as( + "active", + ), + canceled: countDistinct( + sql`CASE WHEN ${isNotNull(customerProducts.canceled_at)} THEN ${customerProducts.internal_customer_id} END`, + ).as("canceled"), + custom: countDistinct( + sql`CASE WHEN ${eq(customerProducts.is_custom, true)} THEN ${customerProducts.internal_customer_id} END`, + ).as("custom"), + trialing: countDistinct( + sql`CASE WHEN ${isNotNull(customerProducts.trial_ends_at)} AND ${sql`${customerProducts.trial_ends_at} > (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`} THEN ${customerProducts.internal_customer_id} END`, + ).as("trialing"), + all: countDistinct(customerProducts.internal_customer_id).as("all"), + }) + .from(products) + .leftJoin( + customerProducts, + and( + eq(customerProducts.internal_product_id, products.internal_id), + inArray(customerProducts.status, activeStatuses), + ), + ) + .where( + and( + eq(products.id, productId), + eq(products.org_id, orgId), + eq(products.env, env), + ), + ) + .groupBy(products.version); + + const result: Record< + number, + { active: number; canceled: number; custom: number; trialing: number } + > = {}; + for (const row of rows) { + result[row.version] = { + active: row.active, + canceled: row.canceled, + custom: row.custom, + trialing: row.trialing, + }; + } + return result; } } diff --git a/server/src/internal/customers/cusProducts/actions/index.ts b/server/src/internal/customers/cusProducts/actions/index.ts index 8dbdb630f..3a623eaa3 100644 --- a/server/src/internal/customers/cusProducts/actions/index.ts +++ b/server/src/internal/customers/cusProducts/actions/index.ts @@ -8,6 +8,7 @@ import { getExpiredCustomerProductsCache, setExpiredCustomerProductsCache, } from "./expiredCache"; +import { markCustomerProductActive } from "./markCustomerProductActive"; import { markCustomerProductPastDue } from "./markCustomerProductPastDue"; import { preserveOneOffPrepaidCarryOvers } from "./preserveOneOffPrepaidCarryOvers"; import { uncancelCustomerProduct } from "./uncancelCustomerProduct"; @@ -32,6 +33,9 @@ export const customerProductActions = { /** Marks a customer product as past due and sends a PastDue webhook */ markPastDue: markCustomerProductPastDue, + /** Marks a customer product as active (e.g. recovery from past-due); webhook gated by sendWebhook flag */ + markActive: markCustomerProductActive, + /** * Persists remaining one-off prepaid balances as lifetime cusEnts before * the customer product is expired (webhook-driven flows). diff --git a/server/src/internal/customers/cusProducts/actions/markCustomerProductActive.ts b/server/src/internal/customers/cusProducts/actions/markCustomerProductActive.ts new file mode 100644 index 000000000..ee358edf4 --- /dev/null +++ b/server/src/internal/customers/cusProducts/actions/markCustomerProductActive.ts @@ -0,0 +1,69 @@ +import { + AttachScenario, + CusProductStatus, + type FullCusProduct, + type FullCustomer, + type InsertCustomerProduct, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; + +/** + * Marks a customer product as active (e.g. recovering from past-due). + * + * This action: + * 1. Sets status to Active on the customer product + * 2. Optionally sends a products_updated webhook with Renew scenario (off by default) + * 3. Updates the FullCustomer in memory + * + * Used by RevenueCat renewal webhooks (past-due → active recovery) and any + * external active-recovery flow. + */ +export const markCustomerProductActive = async ({ + ctx, + customerProduct, + fullCustomer, + sendWebhook = false, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + fullCustomer: FullCustomer; + sendWebhook?: boolean; +}): Promise<{ updates: Partial }> => { + const { org, env } = ctx; + + const updates: Partial = { + status: CusProductStatus.Active, + }; + + await CusProductService.update({ + ctx, + cusProductId: customerProduct.id, + updates, + }); + + ctx.logger.debug( + `[markCustomerProductActive]: marking ${customerProduct.product.name} as active`, + ); + + if (sendWebhook) { + await addProductsUpdatedWebhookTask({ + ctx, + internalCustomerId: customerProduct.internal_customer_id, + org, + env, + customerId: fullCustomer.id || "", + scenario: AttachScenario.Renew, + cusProduct: customerProduct, + }); + } + + fullCustomer.customer_products = fullCustomer.customer_products.map((cp) => + cp.id === customerProduct.id + ? ({ ...cp, ...updates } as FullCusProduct) + : cp, + ); + + return { updates }; +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts index 9d5459422..95205bd2e 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts @@ -31,6 +31,26 @@ import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import RecaseError from "@/utils/errorUtils.js"; export class CusEntService { + /** + * Which of these catalog entitlements are referenced by any + * customer_entitlements row — across every status, including loose, + * scheduled and canceled. + */ + static async getReferencedEntitlementIds({ + db, + entitlementIds, + }: { + db: DrizzleCli; + entitlementIds: string[]; + }): Promise> { + if (entitlementIds.length === 0) return new Set(); + const rows = await db + .select({ entitlement_id: customerEntitlements.entitlement_id }) + .from(customerEntitlements) + .where(inArray(customerEntitlements.entitlement_id, entitlementIds)); + return new Set(rows.map((row) => row.entitlement_id)); + } + static async get({ ctx, externalId, diff --git a/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts b/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts index ada373a78..88f5523bc 100644 --- a/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts +++ b/server/src/internal/customers/cusProducts/cusPrices/CusPriceService.ts @@ -4,10 +4,28 @@ import { type FullCustomerEntitlement, type FullCustomerPrice, } from "@autumn/shared"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; export class CusPriceService { + /** Which of these catalog prices are referenced by any customer_prices row. */ + static async getReferencedPriceIds({ + db, + priceIds, + }: { + db: DrizzleCli; + priceIds: string[]; + }): Promise> { + if (priceIds.length === 0) return new Set(); + const rows = await db + .select({ price_id: customerPrices.price_id }) + .from(customerPrices) + .where(inArray(customerPrices.price_id, priceIds)); + return new Set( + rows.map((row) => row.price_id).filter((id): id is string => id !== null), + ); + } + static async getRelatedToCusEnt({ db, cusEnt, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts index 7f29e24c8..a84b24d2e 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts @@ -134,6 +134,7 @@ export const getApiSubscription = async ({ quantity: cusProduct.quantity, current_period_start: stripeSubData?.current_period_start || null, current_period_end: stripeSubData?.current_period_end || null, + scope: cusProduct.internal_entity_id ? "entity" : "customer", } satisfies ApiSubscriptionV1); return { diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts index 2f3fdd5f9..e099d1cbb 100644 --- a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts @@ -37,13 +37,19 @@ export const mergeAggregatedBalanceIntoApiBalanceV2 = ({ const aggregatedRolloverBalance = aggregatedFeatureBalance.rollover_balance ?? 0; const aggregatedRolloverUsage = aggregatedFeatureBalance.rollover_usage ?? 0; + const aggregatedRolloverGrant = new Decimal(aggregatedRolloverBalance) + .add(aggregatedRolloverUsage) + .toNumber(); // Aggregate rows do not retain the full per-entity/per-product breakdown, so // the top-level summary is merged from the coarse aggregate values only. - const granted = new Decimal(aggregatedAllowance) + const baseGranted = new Decimal(aggregatedAllowance) .add(aggregatedPrepaidGrantFromOptions) .add(aggregatedAdjustment) .toNumber(); + const granted = new Decimal(baseGranted) + .add(aggregatedRolloverGrant) + .toNumber(); // Main remaining is floored at 0 (matches legacy behaviour). Rollover // remaining is added on top, since rollover balances are independent of @@ -57,7 +63,7 @@ export const mergeAggregatedBalanceIntoApiBalanceV2 = ({ .toNumber(); // Usage mirrors the entity-view formula: (granted - main balance) + rollover usage. - const usage = new Decimal(granted) + const usage = new Decimal(baseGranted) .sub(aggregatedBalance) .add(aggregatedRolloverUsage); diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts index b50765d0e..fa2faa9e4 100644 --- a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts @@ -129,11 +129,12 @@ export const getApiSubscriptionV2 = async ({ trial_ends_at: isCustomerProductTrialing(customerProduct) ? (customerProduct.trial_ends_at ?? null) : null, - started_at: customerProduct.starts_at, - quantity: customerProduct.quantity, - current_period_start: subscriptionPeriod.current_period_start, - current_period_end: subscriptionPeriod.current_period_end, - } satisfies ApiSubscriptionV1), + started_at: customerProduct.starts_at, + quantity: customerProduct.quantity, + current_period_start: subscriptionPeriod.current_period_start, + current_period_end: subscriptionPeriod.current_period_end, + scope: customerProduct.internal_entity_id ? "entity" : "customer", + } satisfies ApiSubscriptionV1), legacyData: { subscription_id: subId || undefined, options: customerProduct.options, diff --git a/server/src/internal/customers/customerListFilters.ts b/server/src/internal/customers/customerListFilters.ts new file mode 100644 index 000000000..a76dce078 --- /dev/null +++ b/server/src/internal/customers/customerListFilters.ts @@ -0,0 +1,10 @@ +import { z } from "zod/v4"; + +export const CustomerListFiltersSchema = z.object({ + status: z.array(z.string()).optional(), + version: z.array(z.string()).optional(), + none: z.boolean().optional(), + processor: z.array(z.string()).optional(), +}); + +export type CustomerListFilters = z.infer; diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index cae8c4886..cc0767b41 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -13,10 +13,75 @@ export type DashboardStatusFilter = | "free_trial" | "expired"; -export type DashboardProductVersionFilter = { - productId: string; - version: number; -}; +export const parseDashboardStatusFilter = ( + raw: string[] | undefined, +): DashboardStatusFilter[] => + (raw ?? []).filter( + (s): s is DashboardStatusFilter => + s === "active" || + s === "past_due" || + s === "canceled" || + s === "free_trial" || + s === "expired", + ); + +type DashboardProcessorFilter = NonNullable< + ListCustomersV2Params["processors"] +>[number]; + +export const parseDashboardProcessorFilter = ( + raw: string[] | undefined, +): ListCustomersV2Params["processors"] => + (raw ?? []).filter( + (p): p is DashboardProcessorFilter => + p === "stripe" || p === "revenuecat" || p === "vercel", + ); + +export type DashboardProductVersionFilter = + | { productId: string; version: number; custom?: never } + | { productId: string; custom: true; version?: never }; + +export const isCustomDashboardProductFilter = ( + filter: DashboardProductVersionFilter, +): filter is Extract => + "custom" in filter; + +export const isVersionDashboardProductFilter = ( + filter: DashboardProductVersionFilter, +): filter is Extract => + "version" in filter; + +export const parseDashboardVersionFilter = ( + raw: string[] | undefined, +): DashboardProductVersionFilter[] => + (raw ?? []).flatMap((value): DashboardProductVersionFilter[] => { + if (!value) return []; + + const [productId, version] = value.split(":"); + if (!productId || !version) return []; + if (version === "custom") return [{ productId, custom: true }]; + + const parsedVersion = Number.parseInt(version, 10); + if (Number.isNaN(parsedVersion)) return []; + return [{ productId, version: parsedVersion }]; + }); + +const dashboardProductFilterToCustomerListSql = ( + filter: DashboardProductVersionFilter, + { orgId, env }: { orgId?: string; env?: string } = {}, +): SQL => + isCustomDashboardProductFilter(filter) + ? sql`(cp_dash.product_id = ${filter.productId} AND cp_dash.is_custom = true)` + : orgId && env + ? sql`cp_dash.internal_product_id IN ( + SELECT p_lookup.internal_id + FROM products p_lookup + WHERE p_lookup.org_id = ${orgId} + AND p_lookup.env = ${env} + AND p_lookup.id = ${filter.productId} + AND p_lookup.version = ${filter.version} + )` + : sql`(p_dash.id = ${filter.productId} AND p_dash.version = ${filter.version})`; const buildOptimizedCusProductsCTE = ({ inStatuses, @@ -557,6 +622,8 @@ export const getPaginatedFullCusQuery = ({ const customerListFilterSql = getCustomerListFilterSql({ internalCustomerIds, + orgId, + env, inStatuses, plans, processors, @@ -865,6 +932,8 @@ export const hasCustomerListFilters = ({ export const getCustomerListFilterSql = ({ internalCustomerIds, + orgId, + env, inStatuses, plans, processors, @@ -874,6 +943,8 @@ export const getCustomerListFilterSql = ({ productVersionFilters, }: { internalCustomerIds?: string[]; + orgId?: string; + env?: string; inStatuses?: CusProductStatus[]; plans?: ListCustomersV2Params["plans"]; processors?: ListCustomersV2Params["processors"]; @@ -963,10 +1034,13 @@ export const getCustomerListFilterSql = ({ )`); } + const productFilters = productVersionFilters ?? []; const hasStatus = statusFilters && statusFilters.length > 0; - const hasVersion = - productVersionFilters && productVersionFilters.length > 0; - if (hasStatus || hasVersion) { + const hasProductFilter = productFilters.length > 0; + const hasVersion = productFilters.some(isVersionDashboardProductFilter); + const canUseProductCandidateSet = + orgId && env && productFilters.every(isVersionDashboardProductFilter); + if (hasStatus || hasProductFilter) { const innerClauses: SQL[] = []; // Mirrors CusSearchService.buildSearchPredicates productMode: @@ -1006,15 +1080,24 @@ export const getCustomerListFilterSql = ({ innerClauses.push(sql`(${sql.join(statusClauses, sql` OR `)})`); } - if (hasVersion) { - const versionClauses = productVersionFilters!.map( - (pv) => - sql`(cp_dash.product_id = ${pv.productId} AND p_dash.version = ${pv.version})`, + if (hasProductFilter) { + const versionClauses = productFilters.map( + (filter) => dashboardProductFilterToCustomerListSql(filter, { orgId, env }), ); innerClauses.push(sql`(${sql.join(versionClauses, sql` OR `)})`); } + if (canUseProductCandidateSet) { + filters.push(sql`AND c.internal_id IN ( + SELECT cp_dash.internal_customer_id + FROM customer_products cp_dash + WHERE ${sql.join(innerClauses, sql` AND `)} + )`); + return sql.join(filters, sql` `); + } + const joinProducts = hasVersion + && !(orgId && env) ? sql`JOIN products p_dash ON cp_dash.internal_product_id = p_dash.internal_id` : sql``; diff --git a/server/src/internal/customers/handlers/handleTransferProduct/transferRelatedCustomerProducts.ts b/server/src/internal/customers/handlers/handleTransferProduct/transferRelatedCustomerProducts.ts index cb20ea15a..42a9f6f04 100644 --- a/server/src/internal/customers/handlers/handleTransferProduct/transferRelatedCustomerProducts.ts +++ b/server/src/internal/customers/handlers/handleTransferProduct/transferRelatedCustomerProducts.ts @@ -1,8 +1,4 @@ -import type { - Entity, - FullCusProduct, - FullCustomer, -} from "@autumn/shared"; +import type { Entity, FullCusProduct, FullCustomer } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { nullish } from "@/utils/genUtils.js"; import { CusProductService } from "../../cusProducts/CusProductService.js"; @@ -45,13 +41,16 @@ export const findTransferCustomerProduct = ({ fullCustomer, fromEntity, productId, + customerProductId, }: { fullCustomer: FullCustomer; fromEntity: Entity | null; productId: string; + customerProductId?: string | null; }) => fullCustomer.customer_products.find( (cusProduct) => + (!customerProductId || cusProduct.id === customerProductId) && matchesTransferSource({ cusProduct, fromEntity }) && cusProduct.product.id === productId, ); @@ -73,18 +72,40 @@ export const findExistingTransferTargetProduct = ({ : nullish(cusProduct.internal_entity_id)), ); +export const getTransferCustomerProducts = ({ + fullCustomer, + fromEntity, + product, + customerProductId, +}: { + fullCustomer: FullCustomer; + fromEntity: Entity | null; + product: TransferProduct; + customerProductId?: string | null; +}) => + fullCustomer.customer_products.filter( + (cusProduct) => + (customerProductId + ? cusProduct.id === customerProductId && + cusProduct.product.id === product.id + : matchesTransferProduct({ cusProduct, product })) && + matchesTransferSource({ cusProduct, fromEntity }), + ); + export const transferRelatedCustomerProducts = async ({ ctx, fullCustomer, fromEntity, toEntity, product, + customerProductId, }: { ctx: AutumnContext; fullCustomer: FullCustomer; fromEntity: Entity | null; toEntity: Entity | null; product: TransferProduct; + customerProductId?: string | null; }): Promise => { const updates = { entity_id: toEntity?.id ?? null, @@ -92,19 +113,18 @@ export const transferRelatedCustomerProducts = async ({ }; await Promise.all( - fullCustomer.customer_products - .filter( - (cusProduct) => - matchesTransferProduct({ cusProduct, product }) && - matchesTransferSource({ cusProduct, fromEntity }), - ) - .map((cusProduct) => - CusProductService.update({ - ctx, - cusProductId: cusProduct.id, - updates, - }), - ), + getTransferCustomerProducts({ + fullCustomer, + fromEntity, + product, + customerProductId, + }).map((cusProduct) => + CusProductService.update({ + ctx, + cusProductId: cusProduct.id, + updates, + }), + ), ); return updates; diff --git a/server/src/internal/customers/handlers/handleTransferProductV2.ts b/server/src/internal/customers/handlers/handleTransferProductV2.ts index b9838fe50..d4d9f27c4 100644 --- a/server/src/internal/customers/handlers/handleTransferProductV2.ts +++ b/server/src/internal/customers/handlers/handleTransferProductV2.ts @@ -22,6 +22,7 @@ const TransferProductSchema = z.object({ from_entity_id: z.string().nullish(), to_entity_id: z.string().nullish(), product_id: z.string(), + customer_product_id: z.string().nullish(), }); // Supports: @@ -36,7 +37,8 @@ export const handleTransferProductV2 = createRoute({ const ctx = c.get("ctx"); const { db, org, env } = ctx; const { customer_id } = c.req.param(); - const { from_entity_id, to_entity_id, product_id } = c.req.valid("json"); + const { from_entity_id, to_entity_id, product_id, customer_product_id } = + c.req.valid("json"); if (!from_entity_id && !to_entity_id) { throw new RecaseError({ @@ -65,10 +67,16 @@ export const handleTransferProductV2 = createRoute({ } const fromEntity = - customer.entities.find((entity) => entity.id === from_entity_id) ?? null; + customer.entities.find( + (entity) => + entity.id === from_entity_id || entity.internal_id === from_entity_id, + ) ?? null; const toEntity = to_entity_id - ? (customer.entities.find((entity) => entity.id === to_entity_id) ?? null) + ? (customer.entities.find( + (entity) => + entity.id === to_entity_id || entity.internal_id === to_entity_id, + ) ?? null) : null; if (to_entity_id && !toEntity) { @@ -81,6 +89,7 @@ export const handleTransferProductV2 = createRoute({ fullCustomer: customer, fromEntity, productId: product_id, + customerProductId: customer_product_id, }); if (!cusProduct) { @@ -120,6 +129,7 @@ export const handleTransferProductV2 = createRoute({ fromEntity, toEntity, product, + customerProductId: customer_product_id, }); await addProductsUpdatedWebhookTask({ diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index e02f430a9..69529004b 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleGetCustomer } from "@/internal/customers/internalHandlers/handleGetCustomer.js"; +import { handleClearCustomerCache } from "./handlers/handleClearCustomerCache.js"; import { handleCountCustomers } from "./internalHandlers/handleCountCustomers.js"; import { handleGetCusReferrals } from "./internalHandlers/handleGetCusReferrals.js"; import { handleGetCustomerProduct } from "./internalHandlers/handleGetCustomerProduct.js"; @@ -15,6 +16,7 @@ export const internalCusRouter = new Hono(); internalCusRouter.post("/all/search", ...handleSearchCustomers); internalCusRouter.post("/all/full_customers", ...handleGetFullCustomers); internalCusRouter.post("/all/count", ...handleCountCustomers); +internalCusRouter.post("/clear_cache", ...handleClearCustomerCache); internalCusRouter.get("/:customer_id", ...handleGetCustomer); internalCusRouter.get( "/:customer_id/product/:product_id", diff --git a/server/src/internal/customers/internalHandlers/handleCountCustomers.ts b/server/src/internal/customers/internalHandlers/handleCountCustomers.ts index f4ace3416..16369e1a0 100644 --- a/server/src/internal/customers/internalHandlers/handleCountCustomers.ts +++ b/server/src/internal/customers/internalHandlers/handleCountCustomers.ts @@ -1,20 +1,14 @@ import { Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CustomerListFiltersSchema } from "../customerListFilters"; import { CusSearchService } from "../CusSearchService"; export const handleCountCustomers = createRoute({ scopes: [Scopes.Customers.Read], body: z.object({ search: z.string().optional(), - filters: z - .object({ - status: z.array(z.string()).optional(), - version: z.array(z.string()).optional(), - none: z.boolean().optional(), - processor: z.array(z.string()).optional(), - }) - .optional(), + filters: CustomerListFiltersSchema.optional(), }), handler: async (c) => { const { db, org, env } = c.get("ctx"); diff --git a/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts b/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts index 9a6f45d55..657ab6b8c 100644 --- a/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts +++ b/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts @@ -1,6 +1,7 @@ import { Scopes, StandardCursor } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CustomerListFiltersSchema } from "../customerListFilters"; import { CusBatchService } from "../CusBatchService"; export const handleGetFullCustomers = createRoute({ @@ -9,14 +10,7 @@ export const handleGetFullCustomers = createRoute({ search: z.string().optional(), limit: z.number().int().min(1).max(1000).optional().default(50), cursor: z.string().optional().default(""), - filters: z - .object({ - status: z.array(z.string()).optional(), - version: z.array(z.string()).optional(), - none: z.boolean().optional(), - processor: z.array(z.string()).optional(), - }) - .optional(), + filters: CustomerListFiltersSchema.optional(), }), handler: async (c) => { const ctx = c.get("ctx"); diff --git a/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts b/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts index 5635f3d88..f75f39262 100644 --- a/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts +++ b/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts @@ -1,6 +1,7 @@ import { type FullCusProduct, Scopes, StandardCursor } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { CustomerListFiltersSchema } from "../customerListFilters"; import { CusBatchService } from "../CusBatchService"; export const handleSearchCustomers = createRoute({ @@ -9,14 +10,7 @@ export const handleSearchCustomers = createRoute({ search: z.string().optional(), limit: z.number().int().min(1).max(1000).optional().default(50), cursor: z.string().optional().default(""), - filters: z - .object({ - status: z.array(z.string()).optional(), - version: z.array(z.string()).optional(), - none: z.boolean().optional(), - processor: z.array(z.string()).optional(), - }) - .optional(), + filters: CustomerListFiltersSchema.optional(), }), handler: async (c) => { const ctx = c.get("ctx"); diff --git a/server/src/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds.ts b/server/src/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds.ts new file mode 100644 index 000000000..9f1d4ca2e --- /dev/null +++ b/server/src/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds.ts @@ -0,0 +1,54 @@ +import { + type AutumnBillingPlan, + schedulePhases, + schedules, +} from "@autumn/shared"; +import { and, eq, isNull } from "drizzle-orm"; +import type { RepoContext } from "@/db/repoContext.js"; + +export const replaceScheduledPhaseCustomerProductIds = async ({ + ctx, + replacements, +}: { + ctx: RepoContext; + replacements?: AutumnBillingPlan["schedulePhaseCustomerProductReplacements"]; +}) => { + await Promise.all((replacements ?? []).map(async (replacement) => { + const phases = await ctx.db + .select({ + id: schedulePhases.id, + customerProductIds: schedulePhases.customer_product_ids, + }) + .from(schedulePhases) + .innerJoin(schedules, eq(schedulePhases.schedule_id, schedules.id)) + .where( + and( + eq(schedules.org_id, ctx.org.id), + eq(schedules.env, ctx.env), + eq(schedules.internal_customer_id, replacement.internalCustomerId), + replacement.internalEntityId + ? eq(schedules.internal_entity_id, replacement.internalEntityId) + : isNull(schedules.internal_entity_id), + ), + ); + + await Promise.all( + phases + .filter((phase) => + phase.customerProductIds.includes(replacement.oldCustomerProductId), + ) + .map((phase) => + ctx.db + .update(schedulePhases) + .set({ + customer_product_ids: phase.customerProductIds.map((id) => + id === replacement.oldCustomerProductId + ? replacement.newCustomerProductId + : id, + ), + }) + .where(eq(schedulePhases.id, phase.id)), + ), + ); + })); +}; diff --git a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts index 292a48173..08ab45a13 100644 --- a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts +++ b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts @@ -1,28 +1,18 @@ -import { - AppEnv, - checkScopes, - ErrCode, - oauthAccessToken, - oauthConsent, - RecaseError, - type ScopeString, - Scopes, -} from "@autumn/shared"; -import { verifyAccessToken } from "better-auth/oauth2"; -import { and, eq, gt } from "drizzle-orm"; +import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import { hashOAuthToken } from "@/utils/oauthUtils.js"; +import { isMcpOAuthClientId } from "@/internal/auth/oauth/mcpOAuthScopes.js"; +import { + getExternalOAuthApiKeyForToken, + getOAuthAccessTokenRecord, +} from "@/internal/auth/oauth/oauthAccessTokenApiKey.js"; +import { oauthConsentRepo } from "@/internal/auth/repos/index.js"; import { ApiKeyPrefix, createKey } from "../../api-keys/apiKeyUtils.js"; import { type OAuthApiKeyRequestBody, OAuthApiKeyRequestBodySchema, parseRequestedScopes, - tokenRecordFromResourceToken, } from "../oauthApiKeyUtils.js"; -const getOAuthIssuer = () => - `${process.env.BETTER_AUTH_URL?.replace(/\/$/, "") ?? ""}/api/auth`; - const parseBody = (rawBody: string): OAuthApiKeyRequestBody => { let body: unknown = {}; if (rawBody) { @@ -47,34 +37,6 @@ const parseBody = (rawBody: string): OAuthApiKeyRequestBody => { }); }; -const verifyResourceAccessToken = async ({ - accessToken, - resource, - requestedScopes, -}: { - accessToken: string; - resource: string | null; - requestedScopes: ScopeString[] | null; -}) => { - if (!resource) return null; - - const issuer = getOAuthIssuer(); - try { - const payload = await verifyAccessToken(accessToken, { - jwksUrl: `${issuer}/jwks`, - verifyOptions: { - audience: resource, - issuer, - }, - scopes: requestedScopes ?? undefined, - }); - - return tokenRecordFromResourceToken(payload as Record); - } catch { - return null; - } -}; - /** * Create API keys from an OAuth access token. * Called by the CLI after completing the OAuth flow. @@ -87,7 +49,8 @@ const verifyResourceAccessToken = async ({ export const handleCreateOAuthApiKeys = createRoute({ scopes: [Scopes.Public], handler: async (c) => { - const db = c.get("ctx").db; + const ctx = c.get("ctx"); + const db = ctx.db; const rawBody = await c.req.text(); const body = parseBody(rawBody); const requestedScopes = parseRequestedScopes(body.scopes); @@ -105,90 +68,64 @@ export const handleCreateOAuthApiKeys = createRoute({ const accessToken = authHeader.substring(7); - // Better-auth stores opaque tokens as SHA-256 hashes in base64url format - const hashedToken = await hashOAuthToken(accessToken); - - // Look up the token in the oauth_access_token table - const tokenRecords = await db - .select() - .from(oauthAccessToken) - .where( - and( - eq(oauthAccessToken.token, hashedToken), - gt(oauthAccessToken.expiresAt, new Date()), - ), - ) - .limit(1); - - const tokenRecord = - tokenRecords[0] ?? - (await verifyResourceAccessToken({ - accessToken, - resource, - requestedScopes, - })); - - if (!tokenRecord) { - throw new RecaseError({ - message: "Invalid or expired access token", - code: ErrCode.InvalidRequest, - statusCode: 401, - }); - } - - if (requestedScopes) { - const { allowed, missing } = checkScopes( - requestedScopes, - tokenRecord.scopes, - ); - if (!allowed) { - throw new RecaseError({ - message: `Insufficient scopes. Missing: ${missing.join(", ")}`, - code: ErrCode.InsufficientScopes, - statusCode: 403, - }); - } - } - + const tokenRecord = await getOAuthAccessTokenRecord({ + db, + accessToken, + resource, + requestedScopes, + }); const userId = tokenRecord.userId; - if (!userId) { + const orgId = tokenRecord.referenceId; + const clientId = tokenRecord.clientId; + if (tokenRecord.scopes.length === 0) { throw new RecaseError({ - message: "Token missing user information", + message: "OAuth token has no scopes", code: ErrCode.InvalidRequest, statusCode: 401, }); } - - // Get the org ID from the referenceId field (set by consentReferenceId) - const orgId = tokenRecord.referenceId; - if (!orgId) { + const apiKeyScopes = requestedScopes ?? tokenRecord.scopes; + if (await isMcpOAuthClientId({ clientId, ctx })) { throw new RecaseError({ - message: "No organization found. Please select an organization.", + message: "MCP OAuth clients must use OAuth access tokens directly", code: ErrCode.InvalidRequest, statusCode: 400, }); } - const clientId = tokenRecord.clientId; + const externalApiKey = await getExternalOAuthApiKeyForToken({ + db, + tokenRecord, + requestedScopes: apiKeyScopes, + }); + if (externalApiKey) { + return c.json({ + sandbox_key: + externalApiKey.env === AppEnv.Sandbox + ? externalApiKey.apiKey + : undefined, + prod_key: + externalApiKey.env === AppEnv.Live + ? externalApiKey.apiKey + : undefined, + org_id: orgId, + user_id: userId, + client_id: clientId, + scopes: externalApiKey.scopes, + }); + } - // Look up the OAuth consent to get its ID for linking API keys - const consentRecords = await db - .select({ id: oauthConsent.id }) - .from(oauthConsent) - .where( - and( - eq(oauthConsent.clientId, clientId), - eq(oauthConsent.userId, userId), - eq(oauthConsent.referenceId, orgId), - ), - ) - .limit(1); + const consent = await oauthConsentRepo.getForClientUserOrg({ + db, + clientId, + userId, + referenceId: orgId, + }); - const consentId = consentRecords[0]?.id || null; - - // Build meta with consent linkage const meta = { - oauth_consent_id: consentId, + oauth_consent_id: consent?.id ?? null, + oauth_client_id: clientId, + oauth_redirect_uri: consent?.redirectUri ?? null, created_via: "oauth", generatedAt: new Date().toISOString(), }; @@ -203,7 +140,7 @@ export const handleCreateOAuthApiKeys = createRoute({ userId, prefix: ApiKeyPrefix.Sandbox, meta, - scopes: requestedScopes, + scopes: apiKeyScopes, }), createKey({ db, @@ -213,7 +150,7 @@ export const handleCreateOAuthApiKeys = createRoute({ userId, prefix: ApiKeyPrefix.Live, meta, - scopes: requestedScopes, + scopes: apiKeyScopes, }), ]); @@ -223,7 +160,7 @@ export const handleCreateOAuthApiKeys = createRoute({ org_id: orgId, user_id: userId, client_id: clientId, - scopes: requestedScopes, + scopes: apiKeyScopes, }); }, }); diff --git a/server/src/internal/dev/cli/oauthApiKeyUtils.ts b/server/src/internal/dev/cli/oauthApiKeyUtils.ts index a0b1eb49b..2257bd910 100644 --- a/server/src/internal/dev/cli/oauthApiKeyUtils.ts +++ b/server/src/internal/dev/cli/oauthApiKeyUtils.ts @@ -1,4 +1,9 @@ -import { ErrCode, RecaseError, type ScopeString } from "@autumn/shared"; +import { + ErrCode, + isValidScope, + RecaseError, + type ScopeString, +} from "@autumn/shared"; import { z } from "zod/v4"; export type OAuthApiKeyRequestBody = { @@ -7,13 +12,19 @@ export type OAuthApiKeyRequestBody = { }; export type ResourceAccessTokenRecord = { + id?: string; + refreshId?: string | null; userId: string | null; referenceId: string | null; clientId: string; scopes: string[]; }; -const RequestedScopesSchema = z.array(z.string()).optional(); +const ScopeStringSchema = z.custom( + (scope) => typeof scope === "string" && isValidScope(scope), + { message: "Invalid scope" }, +); +const RequestedScopesSchema = z.array(ScopeStringSchema).optional(); export const OAuthApiKeyRequestBodySchema = z .object({ resource: z.unknown().optional(), @@ -23,7 +34,7 @@ export const OAuthApiKeyRequestBodySchema = z export const parseRequestedScopes = (scopes: unknown) => { const parsed = RequestedScopesSchema.safeParse(scopes); - if (parsed.success) return (parsed.data ?? null) as ScopeString[] | null; + if (parsed.success) return parsed.data ?? null; throw new RecaseError({ message: "Invalid scopes", diff --git a/server/src/internal/features/aiCreditSystemUtils.ts b/server/src/internal/features/aiCreditSystemUtils.ts index 77392e1f3..b0eb44de2 100644 --- a/server/src/internal/features/aiCreditSystemUtils.ts +++ b/server/src/internal/features/aiCreditSystemUtils.ts @@ -7,7 +7,6 @@ import { type ModelsDevModel, type ModelsDevProvider, RecaseError, - resolveInheritedMarkup, splitModelId, } from "@autumn/shared"; import { Decimal } from "decimal.js"; @@ -78,7 +77,7 @@ const resolveModel = ({ const getEffectiveCost = ( cost: ModelsDevCost, totalInputTokens: number, -): ModelsDevCost => { +): { effective: ModelsDevCost; tierApplied: boolean } => { if (cost.tiers?.length) { let chosen: ModelsDevCostTier | undefined; for (const tier of cost.tiers) { @@ -91,25 +90,51 @@ const getEffectiveCost = ( } if (chosen) { return { - ...cost, - input: chosen.input, - output: chosen.output, - cache_read: chosen.cache_read ?? cost.cache_read, - cache_write: chosen.cache_write ?? cost.cache_write, + effective: { + ...cost, + input: chosen.input, + output: chosen.output, + cache_read: chosen.cache_read ?? cost.cache_read, + cache_write: chosen.cache_write ?? cost.cache_write, + }, + tierApplied: true, }; } - return cost; + return { effective: cost, tierApplied: false }; } if (cost.context_over_200k && totalInputTokens > LARGE_CONTEXT_THRESHOLD) { return { - ...cost, - input: cost.context_over_200k.input, - output: cost.context_over_200k.output, - cache_read: cost.context_over_200k.cache_read ?? cost.cache_read, - cache_write: cost.context_over_200k.cache_write ?? cost.cache_write, + effective: { + ...cost, + input: cost.context_over_200k.input, + output: cost.context_over_200k.output, + cache_read: cost.context_over_200k.cache_read ?? cost.cache_read, + cache_write: cost.context_over_200k.cache_write ?? cost.cache_write, + }, + tierApplied: true, }; } - return cost; + return { effective: cost, tierApplied: false }; +}; + +/** Effective per-token rates ($/M) used for a charge, after tier overlays and fallbacks. */ +export type ModelCostRates = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + audioInput: number; + audioOutput: number; + reasoning: number; +}; + +export type ModelCostBreakdown = { + cost: number; + baseCost: number; + markup: number; + markupSource: "model" | "provider" | "default" | "none"; + tierApplied: boolean; + rates: ModelCostRates; }; const computeCost = ({ @@ -120,7 +145,7 @@ const computeCost = ({ cost: ModelsDevCost; tokens: TokenInput; markup: number; -}): number => { +}): { cost: number; baseCost: number; tierApplied: boolean; rates: ModelCostRates } => { const cacheRead = tokens.cacheRead ?? 0; const cacheWrite = tokens.cacheWrite ?? 0; const audioInput = tokens.audioInput ?? 0; @@ -128,28 +153,37 @@ const computeCost = ({ const reasoning = tokens.reasoning ?? 0; const totalInput = tokens.input + cacheRead + cacheWrite; - const effective = getEffectiveCost(cost, totalInput); - const inputRate = effective.input; - const outputRate = effective.output; + const { effective, tierApplied } = getEffectiveCost(cost, totalInput); // Pools without a published rate fall back to the base text rate. - const cacheReadRate = effective.cache_read ?? inputRate; - const cacheWriteRate = effective.cache_write ?? inputRate; - const audioInputRate = effective.input_audio ?? inputRate; - const audioOutputRate = effective.output_audio ?? outputRate; - const reasoningRate = effective.reasoning ?? outputRate; + const rates: ModelCostRates = { + input: effective.input, + output: effective.output, + cacheRead: effective.cache_read ?? effective.input, + cacheWrite: effective.cache_write ?? effective.input, + audioInput: effective.input_audio ?? effective.input, + audioOutput: effective.output_audio ?? effective.output, + reasoning: effective.reasoning ?? effective.output, + }; - return new Decimal(inputRate) + const baseCost = new Decimal(rates.input) .mul(tokens.input) - .add(new Decimal(outputRate).mul(tokens.output)) - .add(new Decimal(cacheReadRate).mul(cacheRead)) - .add(new Decimal(cacheWriteRate).mul(cacheWrite)) - .add(new Decimal(audioInputRate).mul(audioInput)) - .add(new Decimal(audioOutputRate).mul(audioOutput)) - .add(new Decimal(reasoningRate).mul(reasoning)) - .div(1_000_000) - .mul(new Decimal(1).add(new Decimal(markup).div(100))) - .toNumber(); + .add(new Decimal(rates.output).mul(tokens.output)) + .add(new Decimal(rates.cacheRead).mul(cacheRead)) + .add(new Decimal(rates.cacheWrite).mul(cacheWrite)) + .add(new Decimal(rates.audioInput).mul(audioInput)) + .add(new Decimal(rates.audioOutput).mul(audioOutput)) + .add(new Decimal(rates.reasoning).mul(reasoning)) + .div(1_000_000); + + return { + cost: baseCost + .mul(new Decimal(1).add(new Decimal(markup).div(100))) + .toNumber(), + baseCost: baseCost.toNumber(), + tierApplied, + rates, + }; }; const resolveAiMarkup = ({ @@ -160,38 +194,41 @@ const resolveAiMarkup = ({ modelName: string; creditSystem: Feature; modelMarkup?: { markup?: number | null } | null; -}) => { +}): { markup: number; source: ModelCostBreakdown["markupSource"] } => { if (modelMarkup?.markup != null) { - return modelMarkup.markup; + return { markup: modelMarkup.markup, source: "model" }; } const { provider } = splitModelId(modelName); const providerMarkup = provider ? creditSystem.config?.provider_markups?.[provider]?.markup : undefined; + if (providerMarkup != null) { + return { markup: providerMarkup, source: "provider" }; + } - return ( - resolveInheritedMarkup({ - providerMarkup, - defaultMarkup: creditSystem.config?.default_markup, - }) ?? 0 - ); + const defaultMarkup = creditSystem.config?.default_markup; + if (defaultMarkup != null) { + return { markup: defaultMarkup, source: "default" }; + } + + return { markup: 0, source: "none" }; }; -export const getModelCreditCost = async ({ +export const getModelCreditCostBreakdown = async ({ modelName, creditSystem, ...tokens }: { modelName: string; creditSystem: Feature; -} & TokenInput): Promise => { +} & TokenInput): Promise => { const markups = creditSystem.model_markups || {}; const pricingData = await getModelsDevPricing(); const resolved = resolveModel({ modelName, pricingData }); const markupEntry = markups[modelName]; - const markup = resolveAiMarkup({ + const { markup, source } = resolveAiMarkup({ modelName, creditSystem, modelMarkup: markupEntry, @@ -207,16 +244,22 @@ export const getModelCreditCost = async ({ data: { modelName }, }); } - return computeCost({ + const computed = computeCost({ cost: { input: markupEntry.input_cost, output: markupEntry.output_cost }, tokens: { input: tokens.input, output: tokens.output }, markup, }); + return { ...computed, markup, markupSource: source }; } - return computeCost({ + const computed = computeCost({ cost: resolved.model.cost, tokens, markup, }); + return { ...computed, markup, markupSource: source }; }; + +export const getModelCreditCost = async ( + args: { modelName: string; creditSystem: Feature } & TokenInput, +): Promise => (await getModelCreditCostBreakdown(args)).cost; diff --git a/server/src/internal/features/creditSystemUtils.ts b/server/src/internal/features/creditSystemUtils.ts index c195ade41..e9ddee717 100644 --- a/server/src/internal/features/creditSystemUtils.ts +++ b/server/src/internal/features/creditSystemUtils.ts @@ -8,10 +8,6 @@ import { RecaseError, } from "@autumn/shared"; import { Decimal } from "decimal.js"; -import { - getModelCreditCost, - type TokenInput, -} from "@/internal/features/aiCreditSystemUtils.js"; const creditSystemContainsFeature = ({ creditSystem, @@ -79,40 +75,31 @@ export const featureToCreditSystem = ({ return amount; }; -export const getCreditCost = async ({ +/** Sync credit-schema math; token pricing (models.dev I/O) lives in getModelCreditCost. */ +export const getCreditCost = ({ featureId, creditSystem, amount = 1, - tokens, - modelName, }: { featureId: string; creditSystem: Feature; amount?: number; - modelName?: string; - tokens?: TokenInput; }) => { if (!isAnyCreditSystem(creditSystem.type)) { return amount; } - if (isAiCreditSystem(creditSystem.type)) { - if (!tokens || !modelName) { - throw new RecaseError({ - message: "modelName and tokens must be provided for AI credit systems", - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } - return await getModelCreditCost({ - modelName, - creditSystem, - ...tokens, - }); - } - // If tracking the credit system feature itself, 1:1 mapping + // Own balance is in the system's native unit (USD for AI), so values map 1:1. if (featureId === creditSystem.id) { return amount; } + if (isAiCreditSystem(creditSystem.type)) { + throw new RecaseError({ + message: `AI credit system ${creditSystem.id} has no schema; only its own feature can be priced here. Use getModelCreditCost for token pricing.`, + code: ErrCode.InvalidRequest, + statusCode: 400, + data: { featureId, creditSystemId: creditSystem.id }, + }); + } const schema: CreditSchemaItem[] = creditSystem.config.schema; for (const schemaItem of schema) { if (schemaItem.metered_feature_id === featureId) { diff --git a/server/src/internal/features/utils/getModelPricing.ts b/server/src/internal/features/utils/getModelPricing.ts index d45d23cd6..0b4cdf0a9 100644 --- a/server/src/internal/features/utils/getModelPricing.ts +++ b/server/src/internal/features/utils/getModelPricing.ts @@ -7,9 +7,13 @@ const CACHE_KEY = "models_dev_pricing"; const STALE_KEY = `${CACHE_KEY}_stale`; const TTL_PRIMARY = 60 * 60 * 3; const TTL_STALE = 60 * 60 * 24 * 3; +// Runs inside the track request path — a hanging models.dev must not hang tracks. +const FETCH_TIMEOUT_MS = 5000; const fetchFromSource = async (): Promise => { - const response = await fetch("https://models.dev/api.json"); + const response = await fetch("https://models.dev/api.json", { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); if (!response.ok) { throw new InternalError({ message: `models.dev returned ${response.status}`, diff --git a/server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts b/server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts new file mode 100644 index 000000000..d28bedbef --- /dev/null +++ b/server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts @@ -0,0 +1,43 @@ +import { type DbInvoiceLineItem, invoiceLineItems } from "@autumn/shared"; +import { and, eq, gte, lte, sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle"; + +export const getByCustomerProductAndPeriod = async ({ + db, + customerProductId, + direction, + priceId, + periodStartMs, + periodEndMs, +}: { + db: DrizzleCli; + customerProductId: string; + direction: "charge" | "refund"; + priceId?: string; + periodStartMs?: number; + periodEndMs?: number; +}): Promise => { + const conditions = [ + eq(invoiceLineItems.direction, direction), + sql`${invoiceLineItems.customer_product_ids}::jsonb @> ${JSON.stringify([customerProductId])}::jsonb`, + ]; + + if (priceId) { + conditions.push(eq(invoiceLineItems.price_id, priceId)); + } + + if (periodStartMs !== undefined) { + conditions.push( + lte(invoiceLineItems.effective_period_start, periodStartMs), + ); + } + + if (periodEndMs !== undefined) { + conditions.push(gte(invoiceLineItems.effective_period_end, periodEndMs)); + } + + return db + .select() + .from(invoiceLineItems) + .where(and(...conditions)); +}; diff --git a/server/src/internal/invoices/lineItems/repos/getByCustomerProductIds.ts b/server/src/internal/invoices/lineItems/repos/getByCustomerProductIds.ts new file mode 100644 index 000000000..c158552bc --- /dev/null +++ b/server/src/internal/invoices/lineItems/repos/getByCustomerProductIds.ts @@ -0,0 +1,34 @@ +import { type DbInvoiceLineItem, invoiceLineItems } from "@autumn/shared"; +import { and, inArray, sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle"; + +const ALL_DIRECTIONS = ["charge", "refund"] as const; + +/** + * Fetch all line items whose customer_product_ids array overlaps any of the + * given ids, in a single query (jsonb `?|` array-overlap, GIN-indexed). + */ +export const getByCustomerProductIds = async ({ + db, + customerProductIds, + directions = ALL_DIRECTIONS, +}: { + db: DrizzleCli; + customerProductIds: string[]; + directions?: readonly ("charge" | "refund")[]; +}): Promise => { + if (customerProductIds.length === 0) return []; + + return db + .select() + .from(invoiceLineItems) + .where( + and( + inArray(invoiceLineItems.direction, [...directions]), + sql`${invoiceLineItems.customer_product_ids} ?| ARRAY[${sql.join( + customerProductIds.map((id) => sql`${id}`), + sql`, `, + )}]::text[]`, + ), + ); +}; diff --git a/server/src/internal/invoices/lineItems/repos/index.ts b/server/src/internal/invoices/lineItems/repos/index.ts index 58bcbe5d0..7ab563ce3 100644 --- a/server/src/internal/invoices/lineItems/repos/index.ts +++ b/server/src/internal/invoices/lineItems/repos/index.ts @@ -1,5 +1,7 @@ import { deleteByInvoiceId } from "./deleteByInvoiceId"; import { deleteStaleByStripeInvoiceId } from "./deleteStaleByStripeInvoiceId"; +import { getByCustomerProductAndPeriod } from "./getByCustomerProductAndPeriod"; +import { getByCustomerProductIds } from "./getByCustomerProductIds"; import { getByInvoiceId } from "./getByInvoiceId"; import { getByInvoiceIds } from "./getByInvoiceIds"; import { getByStripeInvoiceId } from "./getByStripeInvoiceId"; @@ -18,6 +20,8 @@ export const invoiceLineItemRepo = { getByInvoiceId, getByInvoiceIds, getByStripeInvoiceId, + getByCustomerProductAndPeriod, + getByCustomerProductIds, deleteByInvoiceId, deleteStaleByStripeInvoiceId, getDeferredByInvoiceItemIds, diff --git a/server/src/internal/logs/actions/queryLogs/queryLogs.ts b/server/src/internal/logs/actions/queryLogs/queryLogs.ts new file mode 100644 index 000000000..fc3676283 --- /dev/null +++ b/server/src/internal/logs/actions/queryLogs/queryLogs.ts @@ -0,0 +1,43 @@ +import { isAxiomConfigured } from "@/external/axiom/initAxiom.js"; +import { queryAxiom } from "@/external/axiom/queryAxiom.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { buildRequestLogsApl } from "../searchRequestLogs/buildRequestLogsApl.js"; + +export const queryLogs = async ({ + ctx, + query, + range, + limit, +}: { + ctx: AutumnContext; + query: string; + range: { + startDate: string; + endDate: string; + }; + limit: number; +}) => { + if (!isAxiomConfigured()) { + return { list: [], unconfigured: true }; + } + + const apl = buildRequestLogsApl({ + ctx, + query, + limit, + allowedStages: ["where", "summarize", "project", "orderBy", "limit"], + appendDefaultOrder: false, + }); + + const result = await queryAxiom({ + apl, + options: { + startTime: range.startDate, + endTime: range.endDate, + }, + }); + + return { + list: (result.matches ?? []).map((match) => match.data ?? {}), + }; +}; diff --git a/server/src/internal/logs/actions/searchRequestLogs/buildRequestLogsApl.ts b/server/src/internal/logs/actions/searchRequestLogs/buildRequestLogsApl.ts new file mode 100644 index 000000000..dda667805 --- /dev/null +++ b/server/src/internal/logs/actions/searchRequestLogs/buildRequestLogsApl.ts @@ -0,0 +1,81 @@ +import type { AppEnv, Organization } from "@autumn/shared"; +import { + escapeAplString, + parseRestrictedApl, + restrictedAplToApl, +} from "../../parser/restrictedApl.js"; +import type { RestrictedAplStageKind } from "../../parser/restrictedAplConfig.js"; + +export type RequestLogsAplInput = { + ctx: { + org: Pick; + env: AppEnv; + }; + query?: string; + limit: number; + allowedStages?: RestrictedAplStageKind[]; + appendDefaultOrder?: boolean; +}; + +type ProjectionField = { + alias: string; + expression: string; +}; + +const REQUEST_LOG_PROJECTION: ProjectionField[] = [ + { alias: "timestamp", expression: "_time" }, + { alias: "source", expression: "source" }, + { alias: "status_code", expression: "statusCode" }, + { alias: "request_method", expression: "['req.method']" }, + { alias: "request_url", expression: "['req.url']" }, + { + alias: "request_path", + expression: "request_path", + }, + { alias: "request_body", expression: "['req.body']" }, + { alias: "response_body", expression: "res" }, + { alias: "org_id", expression: "['context.org_id']" }, + { alias: "customer_id", expression: "['context.customer_id']" }, + { alias: "entity_id", expression: "['context.entity_id']" }, + { alias: "stripe_event_id", expression: "['stripe_event.id']" }, + { alias: "stripe_event_type", expression: "['stripe_event.type']" }, + { alias: "stripe_object_id", expression: "['stripe_event.object_id']" }, +]; + +const tenantClauses = ({ ctx }: RequestLogsAplInput): string[] => [ + `| where ['context.org_id'] == '${escapeAplString(ctx.org.id)}'`, + `| where ['context.org_slug'] == '${escapeAplString(ctx.org.slug)}'`, + `| where (['context.env'] == '${escapeAplString(ctx.env)}' or env == '${escapeAplString(ctx.env)}')`, +]; + +const projectionStage = (): string => + `| project ${REQUEST_LOG_PROJECTION.map( + ({ alias, expression }) => `${alias} = ${expression}`, + ).join(", ")}`; + +export const buildRequestLogsApl = (input: RequestLogsAplInput): string => { + const ast = parseRestrictedApl({ + query: input.query, + allowedStages: input.allowedStages, + }); + const userStages = restrictedAplToApl(ast); + const shouldAppendDefaultOrder = + (input.appendDefaultOrder ?? true) && + !userStages.some((stage) => stage.startsWith("| order by ")); + + return [ + "['express']", + ...tenantClauses(input), + "| where isnotnull(statusCode)", + "| where isnotnull(['req.url'])", + "| extend request_path = tostring(parse_url(['req.url']).path)", + "| extend source = case(request_path startswith '/v1', 'api_request', request_path startswith '/webhooks/connect/', 'stripe_webhook', request_path startswith '/webhooks/stripe/', 'stripe_webhook', '')", + projectionStage(), + "| where source in ('api_request', 'stripe_webhook')", + ...userStages, + shouldAppendDefaultOrder ? "| order by timestamp desc" : null, + `| limit ${input.limit}`, + ] + .filter((line): line is string => Boolean(line)) + .join("\n"); +}; diff --git a/server/src/internal/logs/actions/searchRequestLogs/projectRequestLog.ts b/server/src/internal/logs/actions/searchRequestLogs/projectRequestLog.ts new file mode 100644 index 000000000..cff8f83a7 --- /dev/null +++ b/server/src/internal/logs/actions/searchRequestLogs/projectRequestLog.ts @@ -0,0 +1,134 @@ +export type RequestLogSource = "api_request" | "stripe_webhook"; + +export type ApiRequestLogEntry = { + timestamp: string; + source: RequestLogSource | null; + status_code: number; + request: { + method: string | null; + url: string | null; + path: string | null; + }; + context: { + org_id: string | null; + customer_id: string | null; + entity_id: string | null; + }; + stripe: { + event_id: string | null; + event_type: string | null; + object_id: string | null; + }; + request_body: unknown | null; + response_body: unknown | null; +}; + +type AxiomMatch = { + _time?: string; + data?: Record; +}; + +const pickString = ( + data: Record, + keys: string[], +): string | null => { + for (const key of keys) { + const value = data[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return null; +}; + +const pickNumber = ( + data: Record, + keys: string[], +): number | null => { + for (const key of keys) { + const value = data[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return null; +}; + +const pickUnknown = ( + data: Record, + keys: string[], +): unknown | null => { + for (const key of keys) { + if (key in data) return data[key] ?? null; + } + return null; +}; +const extractPath = (url: string | null): string | null => { + if (!url) return null; + try { + return new URL(url).pathname; + } catch { + return url.startsWith("/") ? url.split("?")[0] : null; + } +}; + +const sourceFromPath = (path: string | null): RequestLogSource | null => { + if (path?.startsWith("/v1") === true) return "api_request"; + if ( + path?.startsWith("/webhooks/connect/") === true || + path?.startsWith("/webhooks/stripe/") === true + ) { + return "stripe_webhook"; + } + return null; +}; + +const pickSource = ( + data: Record, + path: string | null, +): RequestLogSource | null => { + const source = pickString(data, ["source"]); + if (source === "api_request" || source === "stripe_webhook") return source; + return sourceFromPath(path); +}; + +export const projectRequestLog = (match: AxiomMatch): ApiRequestLogEntry => { + const data = match.data ?? {}; + const url = pickString(data, ["request_url", "req.url", "url"]); + const projectedPath = pickString(data, ["request_path"]); + const path = projectedPath ?? extractPath(url); + const source = pickSource(data, path); + + return { + timestamp: pickString(data, ["timestamp"]) ?? match._time ?? "", + source, + status_code: pickNumber(data, ["status_code", "statusCode"]) ?? 0, + request: { + method: pickString(data, ["request_method", "req.method", "method"]), + url, + path, + }, + context: { + org_id: pickString(data, ["org_id", "context.org_id"]), + customer_id: pickString(data, [ + "customer_id", + "context.customer_id", + "req.customer_id", + ]), + entity_id: pickString(data, [ + "entity_id", + "context.entity_id", + "req.entity_id", + ]), + }, + stripe: { + event_id: pickString(data, ["stripe_event_id", "stripe_event.id"]), + event_type: pickString(data, ["stripe_event_type", "stripe_event.type"]), + object_id: pickString(data, [ + "stripe_object_id", + "stripe_event.object_id", + ]), + }, + request_body: pickUnknown(data, ["request_body", "req.body"]), + response_body: pickUnknown(data, ["response_body", "res"]), + }; +}; + +export const isExternalRequestLog = (log: ApiRequestLogEntry): boolean => + log.source === "api_request" || log.source === "stripe_webhook"; diff --git a/server/src/internal/logs/actions/searchRequestLogs/searchRequestLogs.ts b/server/src/internal/logs/actions/searchRequestLogs/searchRequestLogs.ts new file mode 100644 index 000000000..15249d768 --- /dev/null +++ b/server/src/internal/logs/actions/searchRequestLogs/searchRequestLogs.ts @@ -0,0 +1,50 @@ +import { isAxiomConfigured } from "@/external/axiom/initAxiom.js"; +import { queryAxiom } from "@/external/axiom/queryAxiom.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { buildRequestLogsApl } from "./buildRequestLogsApl.js"; +import { + isExternalRequestLog, + projectRequestLog, +} from "./projectRequestLog.js"; + +export const searchRequestLogs = async ({ + ctx, + query, + range, + limit, +}: { + ctx: AutumnContext; + query?: string; + range: { + startDate: string; + endDate: string; + }; + limit: number; +}) => { + if (!isAxiomConfigured()) { + return { list: [], unconfigured: true }; + } + + const apl = buildRequestLogsApl({ + ctx, + query, + limit, + allowedStages: ["where", "orderBy", "limit"], + appendDefaultOrder: true, + }); + + const result = await queryAxiom({ + apl, + options: { + startTime: range.startDate, + endTime: range.endDate, + }, + }); + + return { + list: (result.matches ?? []) + .map(projectRequestLog) + .filter(isExternalRequestLog) + .slice(0, limit), + }; +}; diff --git a/server/src/internal/logs/handlers/handleQueryLogs.ts b/server/src/internal/logs/handlers/handleQueryLogs.ts new file mode 100644 index 000000000..ba3a8cba1 --- /dev/null +++ b/server/src/internal/logs/handlers/handleQueryLogs.ts @@ -0,0 +1,48 @@ +import { Scopes } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { queryLogs } from "../actions/queryLogs/queryLogs.js"; +import { parseRestrictedApl } from "../parser/restrictedApl.js"; +import { + getQueryLogsRangePolicy, + LogsRangeSchema, + resolveLogsRange, +} from "./logsRequestUtils.js"; + +const QueryLogsSchema = z + .object({ + query: z.string().min(1).max(4000), + range: LogsRangeSchema.optional(), + limit: z.coerce.number().int().min(1).max(200).default(100), + }) + .strict(); + +export const handleQueryLogs = createRoute({ + scopes: [Scopes.Analytics.Read], + body: QueryLogsSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + + const ast = parseRestrictedApl({ + query: body.query, + allowedStages: ["where", "summarize", "project", "orderBy", "limit"], + }); + const rangePolicy = getQueryLogsRangePolicy(ast); + + const range = resolveLogsRange({ + startDate: body.range?.start_date, + endDate: body.range?.end_date, + ...rangePolicy, + }); + + const result = await queryLogs({ + ctx, + query: body.query, + range, + limit: body.limit, + }); + + return c.json(result); + }, +}); diff --git a/server/src/internal/logs/handlers/handleSearchLogs.ts b/server/src/internal/logs/handlers/handleSearchLogs.ts new file mode 100644 index 000000000..d38b0a166 --- /dev/null +++ b/server/src/internal/logs/handlers/handleSearchLogs.ts @@ -0,0 +1,42 @@ +import { Scopes } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { searchRequestLogs } from "../actions/searchRequestLogs/searchRequestLogs.js"; +import { parseRestrictedApl } from "../parser/restrictedApl.js"; +import { LogsRangeSchema, resolveLogsRange } from "./logsRequestUtils.js"; + +const SearchLogsSchema = z + .object({ + query: z.string().max(4000).optional(), + range: LogsRangeSchema.optional(), + limit: z.coerce.number().int().min(1).max(200).default(100), + }) + .strict(); + +export const handleSearchLogs = createRoute({ + scopes: [Scopes.Analytics.Read], + body: SearchLogsSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + + parseRestrictedApl({ + query: body.query, + allowedStages: ["where", "orderBy", "limit"], + }); + + const range = resolveLogsRange({ + startDate: body.range?.start_date, + endDate: body.range?.end_date, + }); + + const result = await searchRequestLogs({ + ctx, + query: body.query, + range, + limit: body.limit, + }); + + return c.json(result); + }, +}); diff --git a/server/src/internal/logs/handlers/logsRequestUtils.ts b/server/src/internal/logs/handlers/logsRequestUtils.ts new file mode 100644 index 000000000..b9033605e --- /dev/null +++ b/server/src/internal/logs/handlers/logsRequestUtils.ts @@ -0,0 +1,105 @@ +import { ErrCode, RecaseError } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import { z } from "zod/v4"; +import type { + RestrictedAplAst, + RestrictedAplExpr, + RestrictedAplField, +} from "../parser/restrictedApl.js"; + +const days = (count: number) => count * 24 * 60 * 60 * 1000; + +const SEARCH_MAX_RANGE_MS = days(7); +const QUERY_ORG_MAX_RANGE_MS = days(15); +const QUERY_CUSTOMER_MAX_RANGE_MS = days(30); +const DEFAULT_RANGE_MS = 30 * 60 * 1000; + +const isoDateTimeString = z.string().refine((value) => { + const date = new Date(value); + return Number.isFinite(date.getTime()); +}, "Expected an ISO datetime string"); + +export const LogsRangeSchema = z + .object({ + start_date: isoDateTimeString.optional(), + end_date: isoDateTimeString.optional(), + }) + .strict(); + +export const resolveLogsRange = ({ + startDate, + endDate, + defaultRangeMs = DEFAULT_RANGE_MS, + maxRangeMs = SEARCH_MAX_RANGE_MS, + maxRangeLabel = "7 days", + now = new Date(), +}: { + startDate?: string; + endDate?: string; + defaultRangeMs?: number; + maxRangeMs?: number; + maxRangeLabel?: string; + now?: Date; +}) => { + const end = endDate ? new Date(endDate) : now; + const start = startDate + ? new Date(startDate) + : new Date(end.getTime() - defaultRangeMs); + + if (start.getTime() >= end.getTime()) { + throw new RecaseError({ + message: "range.start_date must be before range.end_date", + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + if (end.getTime() - start.getTime() > maxRangeMs) { + throw new RecaseError({ + message: `Log range cannot exceed ${maxRangeLabel}`, + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + return { + startDate: start.toISOString(), + endDate: end.toISOString(), + }; +}; + +const isCustomerIdField = (field: RestrictedAplField) => + field.kind === "topLevel" && field.name === "customer_id"; + +const exprHasCustomerIdFilter = (expr: RestrictedAplExpr): boolean => { + switch (expr.kind) { + case "comparison": + case "stringMatch": + case "in": + return isCustomerIdField(expr.field); + case "and": + case "or": + return ( + exprHasCustomerIdFilter(expr.left) || + exprHasCustomerIdFilter(expr.right) + ); + } +}; + +export const getQueryLogsRangePolicy = (ast: RestrictedAplAst) => { + const hasCustomerIdFilter = ast.stages.some( + (stage) => stage.kind === "where" && exprHasCustomerIdFilter(stage.expr), + ); + + return hasCustomerIdFilter + ? { + defaultRangeMs: QUERY_CUSTOMER_MAX_RANGE_MS, + maxRangeMs: QUERY_CUSTOMER_MAX_RANGE_MS, + maxRangeLabel: "30 days", + } + : { + defaultRangeMs: QUERY_ORG_MAX_RANGE_MS, + maxRangeMs: QUERY_ORG_MAX_RANGE_MS, + maxRangeLabel: "15 days", + }; +}; diff --git a/server/src/internal/logs/logsRouter.ts b/server/src/internal/logs/logsRouter.ts new file mode 100644 index 000000000..85930505e --- /dev/null +++ b/server/src/internal/logs/logsRouter.ts @@ -0,0 +1,9 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleQueryLogs } from "./handlers/handleQueryLogs.js"; +import { handleSearchLogs } from "./handlers/handleSearchLogs.js"; + +export const logsRpcRouter = new Hono(); + +logsRpcRouter.post("/logs.search", ...handleSearchLogs); +logsRpcRouter.post("/logs.query", ...handleQueryLogs); diff --git a/server/src/internal/logs/parser/restrictedApl.ts b/server/src/internal/logs/parser/restrictedApl.ts new file mode 100644 index 000000000..7dcbb5960 --- /dev/null +++ b/server/src/internal/logs/parser/restrictedApl.ts @@ -0,0 +1,795 @@ +import { + DEFAULT_RESTRICTED_APL_STAGES, + RESTRICTED_APL_DANGEROUS_TEXT_PATTERNS, + RESTRICTED_APL_FIELD_ALIASES, + RESTRICTED_APL_MAX_LIMIT, + RESTRICTED_APL_MAX_NESTED_PATH_DEPTH, + RESTRICTED_APL_NESTED_ROOTS, + RESTRICTED_APL_NUMERIC_AGGREGATE_FIELDS, + RESTRICTED_APL_TOP_LEVEL_FIELDS, + type RestrictedAplNestedRoot, + type RestrictedAplStageKind, + type RestrictedAplTopLevelField, + SAFE_APL_IDENTIFIER, +} from "./restrictedAplConfig.js"; + +export type RestrictedAplField = + | { + kind: "topLevel"; + name: RestrictedAplTopLevelField; + } + | { + kind: "nested"; + root: RestrictedAplNestedRoot; + path: string[]; + }; + +export type LiteralValue = string | number | boolean | null; + +export type CompareOperator = "==" | "!=" | ">" | ">=" | "<" | "<="; + +export type RestrictedAplExpr = + | { + kind: "comparison"; + field: RestrictedAplField; + op: CompareOperator; + value: LiteralValue; + } + | { + kind: "stringMatch"; + field: RestrictedAplField; + op: "contains" | "startswith"; + value: string; + } + | { + kind: "in"; + field: RestrictedAplField; + values: LiteralValue[]; + } + | { + kind: "and" | "or"; + left: RestrictedAplExpr; + right: RestrictedAplExpr; + }; + +export type SummarizeFunction = + | { kind: "count" } + | { kind: "countif"; expr: RestrictedAplExpr } + | { + kind: "numeric"; + name: "avg" | "sum" | "min" | "max"; + field: RestrictedAplField; + } + | { + kind: "percentile"; + field: RestrictedAplField; + percentile: number; + }; + +export type SummarizeAggregation = { + alias: string; + fn: SummarizeFunction; +}; + +export type AplReference = + | { + kind: "field"; + field: RestrictedAplField; + } + | { + kind: "identifier"; + name: string; + }; + +export type ProjectColumn = { + source: AplReference; + alias?: string; +}; + +export type RestrictedAplStage = + | { kind: "where"; expr: RestrictedAplExpr } + | { + kind: "orderBy"; + target: AplReference; + direction: "asc" | "desc"; + } + | { kind: "limit"; value: number } + | { + kind: "summarize"; + aggregations: SummarizeAggregation[]; + by: RestrictedAplField[]; + } + | { + kind: "project"; + columns: ProjectColumn[]; + }; + +export type RestrictedAplAst = { + stages: RestrictedAplStage[]; +}; + +type Token = + | { kind: "identifier"; value: string } + | { kind: "string"; value: string } + | { kind: "number"; value: number } + | { + kind: "symbol"; + value: + | "|" + | "(" + | ")" + | "," + | "=" + | "==" + | "!=" + | ">" + | ">=" + | "<" + | "<="; + }; + +type SymbolValue = Extract["value"]; + +const textDecoder = (value: string) => + value.replace(/\\'/g, "'").replace(/\\\\/g, "\\"); + +const assertNoDangerousText = (query: string) => { + for (const { pattern, message } of RESTRICTED_APL_DANGEROUS_TEXT_PATTERNS) { + if (pattern.test(query)) throw new Error(message); + } +}; + +const tokenize = (query: string): Token[] => { + assertNoDangerousText(query); + + const tokens: Token[] = []; + let i = 0; + + while (i < query.length) { + const char = query[i]; + + if (/\s/.test(char)) { + i++; + continue; + } + + if (char === "|") { + tokens.push({ kind: "symbol", value: "|" }); + i++; + continue; + } + + if (char === "(" || char === ")" || char === ",") { + tokens.push({ kind: "symbol", value: char }); + i++; + continue; + } + + const two = query.slice(i, i + 2); + if (two === "==" || two === "!=" || two === ">=" || two === "<=") { + tokens.push({ kind: "symbol", value: two }); + i += 2; + continue; + } + + if (char === ">" || char === "<") { + tokens.push({ kind: "symbol", value: char }); + i++; + continue; + } + + if (char === "=") { + tokens.push({ kind: "symbol", value: "=" }); + i++; + continue; + } + + if (char === "'") { + let j = i + 1; + let raw = ""; + while (j < query.length) { + const current = query[j]; + if (current === "\\") { + const next = query[j + 1]; + if (next !== "\\" && next !== "'") { + throw new Error( + "Only escaped quotes and backslashes are supported", + ); + } + raw += current + next; + j += 2; + continue; + } + if (current === "'") break; + raw += current; + j++; + } + if (j >= query.length || query[j] !== "'") { + throw new Error("Unterminated string literal"); + } + tokens.push({ kind: "string", value: textDecoder(raw) }); + i = j + 1; + continue; + } + + if (/[0-9-]/.test(char)) { + const match = query.slice(i).match(/^-?\d+(?:\.\d+)?/); + if (!match) throw new Error("Invalid number literal"); + tokens.push({ kind: "number", value: Number(match[0]) }); + i += match[0].length; + continue; + } + + if (/[A-Za-z_]/.test(char)) { + const match = query.slice(i).match(/^[A-Za-z_][A-Za-z0-9_.]*/); + if (!match) throw new Error("Invalid identifier"); + tokens.push({ kind: "identifier", value: match[0] }); + i += match[0].length; + continue; + } + + throw new Error(`Unsupported query character: ${char}`); + } + + return tokens; +}; + +const NESTED_ROOT_NAMES = Object.keys( + RESTRICTED_APL_NESTED_ROOTS, +) as RestrictedAplNestedRoot[]; + +const isNestedRoot = (value: string): value is RestrictedAplNestedRoot => + NESTED_ROOT_NAMES.includes(value as RestrictedAplNestedRoot); + +const fieldDisplayName = (field: RestrictedAplField): string => + field.kind === "topLevel" + ? field.name + : `${field.root}.${field.path.join(".")}`; + +const resolveFieldIdentifier = (raw: string): RestrictedAplField | null => { + const topLevel = RESTRICTED_APL_FIELD_ALIASES[raw]; + if (topLevel) return { kind: "topLevel", name: topLevel }; + + const [root, ...path] = raw.split("."); + if (!isNestedRoot(root)) return null; + + if (path.length === 0 || path.length > RESTRICTED_APL_MAX_NESTED_PATH_DEPTH) { + throw new Error( + `Nested query field must have 1-${RESTRICTED_APL_MAX_NESTED_PATH_DEPTH} path segments: ${raw}`, + ); + } + + for (const segment of path) { + if (!SAFE_APL_IDENTIFIER.test(segment)) { + throw new Error(`Unsafe nested query field segment: ${segment}`); + } + } + + return { kind: "nested", root, path }; +}; + +class Parser { + private index = 0; + + constructor(private readonly tokens: Token[]) {} + + parse(): RestrictedAplAst { + const stages: RestrictedAplStage[] = []; + + this.consumePipeIfPresent(); + while (!this.isDone()) { + stages.push(this.parseStage()); + if (this.isDone()) break; + this.expectSymbol("|"); + } + + return { stages }; + } + + private parseStage(): RestrictedAplStage { + const keyword = this.expectIdentifier().toLowerCase(); + switch (keyword) { + case "where": + return { kind: "where", expr: this.parseOrExpr() }; + case "order": { + this.expectKeyword("by"); + const target = this.expectSafeIdentifierOrField(); + const direction = this.peekIdentifierLower(); + if (direction === "asc" || direction === "desc") { + this.index++; + return { kind: "orderBy", target, direction }; + } + return { kind: "orderBy", target, direction: "desc" }; + } + case "limit": + case "take": { + const value = this.expectLimit(); + return { kind: "limit", value }; + } + case "summarize": + return this.parseSummarize(); + case "project": + return this.parseProject(); + default: + throw new Error(`Unsupported query stage: ${keyword}`); + } + } + + private parseSummarize(): RestrictedAplStage { + const aggregations: SummarizeAggregation[] = []; + + while (this.peekIdentifierLower() !== "by" && !this.isStageBoundary()) { + const alias = this.expectSafeAlias(); + this.expectSymbol("="); + aggregations.push({ alias, fn: this.parseSummarizeFunction() }); + + if (this.peekSymbol(",")) { + this.index++; + continue; + } + break; + } + + if (aggregations.length === 0) { + throw new Error("summarize requires at least one aggregation"); + } + + const by: RestrictedAplField[] = []; + if (this.peekIdentifierLower() === "by") { + this.index++; + while (!this.isStageBoundary()) { + by.push(this.expectField()); + if (this.peekSymbol(",")) { + this.index++; + continue; + } + break; + } + if (by.length === 0) throw new Error("summarize by requires fields"); + } + + return { kind: "summarize", aggregations, by }; + } + + private parseSummarizeFunction(): SummarizeFunction { + const name = this.expectIdentifier().toLowerCase(); + this.expectSymbol("("); + + if (name === "count") { + this.expectSymbol(")"); + return { kind: "count" }; + } + + if (name === "countif") { + const expr = this.parseOrExpr(); + this.expectSymbol(")"); + return { kind: "countif", expr }; + } + + if (name === "avg" || name === "sum" || name === "min" || name === "max") { + const field = this.expectNumericAggregateField(); + this.expectSymbol(")"); + return { kind: "numeric", name, field }; + } + + if (name === "percentile") { + const field = this.expectNumericAggregateField(); + this.expectSymbol(","); + const percentile = this.expectNumberLiteral(); + this.expectSymbol(")"); + if (percentile <= 0 || percentile >= 100) { + throw new Error("percentile must be between 0 and 100"); + } + return { kind: "percentile", field, percentile }; + } + + throw new Error(`Unsupported summarize function: ${name}`); + } + + private parseProject(): RestrictedAplStage { + const columns: ProjectColumn[] = []; + + while (!this.isStageBoundary()) { + const first = this.expectIdentifier(); + if (this.peekSymbol("=")) { + if (!SAFE_APL_IDENTIFIER.test(first)) { + throw new Error(`Unsafe identifier: ${first}`); + } + this.index++; + columns.push({ + alias: first, + source: this.expectSafeIdentifierOrField(), + }); + } else { + columns.push({ source: this.resolveSafeIdentifierOrField(first) }); + } + + if (this.peekSymbol(",")) { + this.index++; + continue; + } + break; + } + + if (columns.length === 0) throw new Error("project requires fields"); + return { kind: "project", columns }; + } + + private parseOrExpr(): RestrictedAplExpr { + let expr = this.parseAndExpr(); + while (this.peekIdentifierLower() === "or") { + this.index++; + expr = { kind: "or", left: expr, right: this.parseAndExpr() }; + } + return expr; + } + + private parseAndExpr(): RestrictedAplExpr { + let expr = this.parsePrimaryExpr(); + while (this.peekIdentifierLower() === "and") { + this.index++; + expr = { kind: "and", left: expr, right: this.parsePrimaryExpr() }; + } + return expr; + } + + private parsePrimaryExpr(): RestrictedAplExpr { + if (this.peekSymbol("(")) { + this.index++; + const expr = this.parseOrExpr(); + this.expectSymbol(")"); + return expr; + } + return this.parsePredicate(); + } + + private parsePredicate(): RestrictedAplExpr { + const field = this.expectField(); + const opToken = this.next(); + + if (!opToken) throw new Error("Expected operator"); + + if (opToken.kind === "identifier") { + const op = opToken.value.toLowerCase(); + if (op === "contains" || op === "startswith") { + const value = this.expectString(); + return { kind: "stringMatch", field, op, value }; + } + + if (op === "in") { + this.expectSymbol("("); + const values: LiteralValue[] = []; + while (!this.peekSymbol(")")) { + values.push(this.expectLiteral()); + if (this.peekSymbol(",")) { + this.index++; + continue; + } + break; + } + this.expectSymbol(")"); + if (values.length === 0) + throw new Error("in requires at least one value"); + return { kind: "in", field, values }; + } + } + + if (opToken.kind === "symbol" && this.isCompareOperator(opToken.value)) { + return { + kind: "comparison", + field, + op: opToken.value, + value: this.expectLiteral(), + }; + } + + throw new Error("Unsupported predicate operator"); + } + + private expectLimit(): number { + const token = this.next(); + if (!token || token.kind !== "number" || !Number.isInteger(token.value)) { + throw new Error("limit must be an integer"); + } + if (token.value < 1 || token.value > RESTRICTED_APL_MAX_LIMIT) { + throw new Error( + `limit must be between 1 and ${RESTRICTED_APL_MAX_LIMIT}`, + ); + } + return token.value; + } + + private expectNumberLiteral(): number { + const token = this.next(); + if (!token || token.kind !== "number" || !Number.isFinite(token.value)) { + throw new Error("Expected number literal"); + } + return token.value; + } + + private expectField(): RestrictedAplField { + const raw = this.expectIdentifier(); + const field = resolveFieldIdentifier(raw); + if (!field) throw new Error(`Unknown query field: ${raw}`); + return field; + } + + private expectNumericAggregateField(): RestrictedAplField { + const field = this.expectField(); + if ( + field.kind !== "topLevel" || + !RESTRICTED_APL_NUMERIC_AGGREGATE_FIELDS.has(field.name) + ) { + throw new Error( + `Field cannot be used in numeric aggregation: ${fieldDisplayName(field)}`, + ); + } + return field; + } + + private expectSafeAlias(): string { + const identifier = this.expectSafeIdentifier(); + if (resolveFieldIdentifier(identifier)) { + throw new Error(`Aggregation alias cannot shadow field: ${identifier}`); + } + return identifier; + } + + private expectSafeIdentifier(): string { + const identifier = this.expectIdentifier(); + if (!SAFE_APL_IDENTIFIER.test(identifier)) { + throw new Error(`Unsafe identifier: ${identifier}`); + } + return identifier; + } + + private expectSafeIdentifierOrField(): AplReference { + return this.resolveSafeIdentifierOrField(this.expectIdentifier()); + } + + private resolveSafeIdentifierOrField(identifier: string): AplReference { + const field = resolveFieldIdentifier(identifier); + if (field) return { kind: "field", field }; + if (!SAFE_APL_IDENTIFIER.test(identifier)) { + throw new Error(`Unsafe identifier: ${identifier}`); + } + return { kind: "identifier", name: identifier }; + } + + private expectLiteral(): LiteralValue { + const token = this.next(); + if (!token) throw new Error("Expected literal value"); + if (token.kind === "string" || token.kind === "number") return token.value; + if (token.kind === "identifier") { + const value = token.value.toLowerCase(); + if (value === "true") return true; + if (value === "false") return false; + if (value === "null") return null; + } + throw new Error("Expected string, number, boolean, or null literal"); + } + + private expectString(): string { + const token = this.next(); + if (!token || token.kind !== "string") { + throw new Error("Expected string literal"); + } + return token.value; + } + + private expectIdentifier(): string { + const token = this.next(); + if (!token || token.kind !== "identifier") { + throw new Error("Expected identifier"); + } + return token.value; + } + + private expectKeyword(value: string) { + const identifier = this.expectIdentifier(); + if (identifier.toLowerCase() !== value) { + throw new Error(`Expected ${value}`); + } + } + + private expectSymbol(value: SymbolValue) { + const token = this.next(); + if (!token || token.kind !== "symbol" || token.value !== value) { + throw new Error(`Expected ${value}`); + } + } + + private consumePipeIfPresent() { + if (this.peekSymbol("|")) this.index++; + } + + private peekSymbol(value: string): boolean { + const token = this.tokens[this.index]; + return token?.kind === "symbol" && token.value === value; + } + + private peekIdentifierLower(): string | null { + const token = this.tokens[this.index]; + return token?.kind === "identifier" ? token.value.toLowerCase() : null; + } + + private next(): Token | undefined { + return this.tokens[this.index++]; + } + + private isDone(): boolean { + return this.index >= this.tokens.length; + } + + private isStageBoundary(): boolean { + return this.isDone() || this.peekSymbol("|"); + } + + private isCompareOperator(value: string): value is CompareOperator { + return ( + value === "==" || + value === "!=" || + value === ">" || + value === ">=" || + value === "<" || + value === "<=" + ); + } +} + +const topLevelFieldToApl = (field: RestrictedAplTopLevelField): string => + RESTRICTED_APL_TOP_LEVEL_FIELDS[field].apl; + +const nestedFieldToApl = ( + field: Extract, +) => + [ + RESTRICTED_APL_NESTED_ROOTS[field.root].apl, + ...field.path.map((segment) => `['${segment}']`), + ].join(""); + +const fieldToApl = (field: RestrictedAplField): string => + field.kind === "topLevel" + ? topLevelFieldToApl(field.name) + : nestedFieldToApl(field); + +const nestedFieldAlias = ( + field: Extract, +) => [field.root, ...field.path].join("_"); + +const fieldToStringApl = (field: RestrictedAplField): string => { + if (field.kind === "nested") return `tostring(${fieldToApl(field)})`; + if (field.name === "request_body" || field.name === "response_body") { + return `dynamic_to_json(${fieldToApl(field)})`; + } + return fieldToApl(field); +}; + +const fieldToComparisonApl = ( + field: RestrictedAplField, + value: LiteralValue, +): string => { + if (field.kind !== "nested") return fieldToApl(field); + if (typeof value === "string") return `tostring(${fieldToApl(field)})`; + if (typeof value === "number") return `todouble(${fieldToApl(field)})`; + if (typeof value === "boolean") return `tobool(${fieldToApl(field)})`; + return fieldToApl(field); +}; + +const fieldToInApl = ( + field: RestrictedAplField, + values: LiteralValue[], +): string => { + if (field.kind !== "nested") return fieldToApl(field); + const nonNullValues = values.filter((value) => value !== null); + if (nonNullValues.every((value) => typeof value === "string")) { + return `tostring(${fieldToApl(field)})`; + } + if (nonNullValues.every((value) => typeof value === "number")) { + return `todouble(${fieldToApl(field)})`; + } + if (nonNullValues.every((value) => typeof value === "boolean")) { + return `tobool(${fieldToApl(field)})`; + } + return fieldToApl(field); +}; + +const fieldToSummarizeByApl = (field: RestrictedAplField): string => { + if (field.kind === "topLevel") return fieldToApl(field); + return `${nestedFieldAlias(field)} = tostring(${fieldToApl(field)})`; +}; + +const referenceToApl = (reference: AplReference): string => { + if (reference.kind === "identifier") return reference.name; + const { field } = reference; + if (field.kind === "nested") return `tostring(${fieldToApl(field)})`; + return fieldToApl(field); +}; + +const referenceToProjectApl = ({ alias, source }: ProjectColumn): string => { + if (alias) return `${alias} = ${referenceToApl(source)}`; + if (source.kind === "field" && source.field.kind === "nested") { + return `${nestedFieldAlias(source.field)} = ${referenceToApl(source)}`; + } + return referenceToApl(source); +}; + +export const parseRestrictedApl = ({ + query, + allowedStages, +}: { + query: string | undefined; + allowedStages?: RestrictedAplStageKind[]; +}): RestrictedAplAst => { + const trimmed = query?.trim(); + if (!trimmed) return { stages: [] }; + const ast = new Parser(tokenize(trimmed)).parse(); + const allowed = allowedStages ?? DEFAULT_RESTRICTED_APL_STAGES; + for (const stage of ast.stages) { + if (!allowed.includes(stage.kind)) { + throw new Error(`Unsupported query stage: ${stage.kind}`); + } + } + return ast; +}; + +export const escapeAplString = (value: string): string => + value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + +const literalToApl = (value: LiteralValue): string => { + if (typeof value === "string") return `'${escapeAplString(value)}'`; + if (value === null) return "null"; + return String(value); +}; + +const exprToApl = (expr: RestrictedAplExpr): string => { + switch (expr.kind) { + case "comparison": + return `${fieldToComparisonApl(expr.field, expr.value)} ${expr.op} ${literalToApl(expr.value)}`; + case "stringMatch": + return `${fieldToStringApl(expr.field)} ${expr.op} '${escapeAplString(expr.value)}'`; + case "in": + return `${fieldToInApl(expr.field, expr.values)} in (${expr.values.map(literalToApl).join(", ")})`; + case "and": + case "or": + return `(${exprToApl(expr.left)} ${expr.kind} ${exprToApl(expr.right)})`; + } +}; + +const summarizeFunctionToApl = (fn: SummarizeFunction): string => { + switch (fn.kind) { + case "count": + return "count()"; + case "countif": + return `countif(${exprToApl(fn.expr)})`; + case "numeric": + return `${fn.name}(${fieldToApl(fn.field)})`; + case "percentile": + return `percentile(${fieldToApl(fn.field)}, ${fn.percentile})`; + } +}; + +export const restrictedAplToApl = (ast: RestrictedAplAst): string[] => + ast.stages.map((stage) => { + switch (stage.kind) { + case "where": + return `| where ${exprToApl(stage.expr)}`; + case "orderBy": + return `| order by ${referenceToApl(stage.target)} ${stage.direction}`; + case "limit": + return `| limit ${stage.value}`; + case "summarize": { + const aggregations = stage.aggregations + .map(({ alias, fn }) => `${alias} = ${summarizeFunctionToApl(fn)}`) + .join(", "); + const by = + stage.by.length > 0 + ? ` by ${stage.by.map(fieldToSummarizeByApl).join(", ")}` + : ""; + return `| summarize ${aggregations}${by}`; + } + case "project": + return `| project ${stage.columns.map(referenceToProjectApl).join(", ")}`; + } + throw new Error("Unsupported restricted APL stage"); + }); diff --git a/server/src/internal/logs/parser/restrictedAplConfig.ts b/server/src/internal/logs/parser/restrictedAplConfig.ts new file mode 100644 index 000000000..36353cdbe --- /dev/null +++ b/server/src/internal/logs/parser/restrictedAplConfig.ts @@ -0,0 +1,126 @@ +/** + * The restricted log APL surface is intentionally smaller than Axiom APL. + * Keep tenant-safety and query-cost controls visible here before extending it. + */ +export const RESTRICTED_APL_STAGE_KINDS = [ + "where", + "orderBy", + "limit", + "summarize", + "project", +] as const; + +export type RestrictedAplStageKind = + (typeof RESTRICTED_APL_STAGE_KINDS)[number]; + +/** Search/list endpoints default to filtering, ordering, and limiting only. */ +export const DEFAULT_RESTRICTED_APL_STAGES: RestrictedAplStageKind[] = [ + "where", + "orderBy", + "limit", +]; + +/** Hard cap on user-supplied limits, independent of endpoint defaults. */ +export const RESTRICTED_APL_MAX_LIMIT = 200; + +/** Dot-path body access stays shallow to avoid broad arbitrary object walks. */ +export const RESTRICTED_APL_MAX_NESTED_PATH_DEPTH = 4; + +/** Identifier grammar for aliases and dot-path segments. No quoted keys in v1. */ +export const SAFE_APL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export const RESTRICTED_APL_TOP_LEVEL_FIELDS = { + timestamp: { + apl: "timestamp", + aliases: ["timestamp"], + }, + source: { + apl: "source", + aliases: ["source"], + }, + status_code: { + apl: "status_code", + aliases: ["status_code", "statusCode"], + }, + request_method: { + apl: "request_method", + aliases: ["request_method", "method", "request.method"], + }, + request_url: { + apl: "request_url", + aliases: ["request_url", "request.url", "url"], + }, + request_path: { + apl: "request_path", + aliases: ["request_path", "request.path", "path"], + }, + request_body: { + apl: "request_body", + aliases: ["request_body"], + }, + response_body: { + apl: "response_body", + aliases: ["response_body"], + }, + org_id: { + apl: "org_id", + aliases: ["org_id", "context.org_id"], + }, + customer_id: { + apl: "customer_id", + aliases: ["customer_id", "context.customer_id"], + }, + entity_id: { + apl: "entity_id", + aliases: ["entity_id", "context.entity_id"], + }, + stripe_event_id: { + apl: "stripe_event_id", + aliases: ["stripe_event_id"], + }, + stripe_event_type: { + apl: "stripe_event_type", + aliases: ["stripe_event_type"], + }, + stripe_object_id: { + apl: "stripe_object_id", + aliases: ["stripe_object_id"], + }, +} as const; + +export type RestrictedAplTopLevelField = + keyof typeof RESTRICTED_APL_TOP_LEVEL_FIELDS; + +/** Nested map access is only allowed over projected request/response payloads. */ +export const RESTRICTED_APL_NESTED_ROOTS = { + request_body: { + apl: "request_body", + }, + response_body: { + apl: "response_body", + }, +} as const; + +export type RestrictedAplNestedRoot = keyof typeof RESTRICTED_APL_NESTED_ROOTS; + +/** Numeric aggregates are restricted to fields with stable numeric types. */ +export const RESTRICTED_APL_NUMERIC_AGGREGATE_FIELDS = + new Set(["status_code"]); + +/** Raw APL escape hatches stay blocked; the compiler emits brackets itself. */ +export const RESTRICTED_APL_DANGEROUS_TEXT_PATTERNS = [ + { + pattern: /[;[\]{}]/, + message: "Query contains unsupported syntax", + }, + { + pattern: /--|\/\/|\/\*|\*\//, + message: "Query comments are not supported", + }, +] as const; + +export const RESTRICTED_APL_FIELD_ALIASES = Object.fromEntries( + Object.entries(RESTRICTED_APL_TOP_LEVEL_FIELDS).flatMap(([field, config]) => + config.aliases.map((alias) => [alias, field]), + ), +) as Record; diff --git a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts index 7793a01c5..9008cece6 100644 --- a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts +++ b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts @@ -5,7 +5,6 @@ import type { StripeBillingStage, } from "@autumn/shared"; import { InternalError, MetadataType } from "@autumn/shared"; -import { addDays } from "date-fns"; import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -31,7 +30,7 @@ export const insertMetadataFromBillingPlan = async ({ stripeInvoice?: Stripe.Invoice; stripeCheckoutSession?: Stripe.Checkout.Session; resumeAfter?: StripeBillingStage; - expiresAt: number; + expiresAt: number | null; /** Override the auto-detected metadata type. Used by the enable_plan_immediately checkout flow. */ typeOverride?: MetadataType; }) => { @@ -64,7 +63,7 @@ export const insertMetadataFromBillingPlan = async ({ stripe_checkout_session_id: stripeCheckoutSession?.id, data, created_at: Date.now(), - expires_at: expiresAt ?? addDays(Date.now(), 10).getTime(), + expires_at: expiresAt, }, }); diff --git a/server/src/internal/migrations/v2/actions/migrationItem/withMigrationItemTracking.ts b/server/src/internal/migrations/v2/actions/migrationItem/withMigrationItemTracking.ts index 5799d97b7..3fce85e72 100644 --- a/server/src/internal/migrations/v2/actions/migrationItem/withMigrationItemTracking.ts +++ b/server/src/internal/migrations/v2/actions/migrationItem/withMigrationItemTracking.ts @@ -9,6 +9,10 @@ import { migrationItemRunRepo, } from "../../repos/index.js"; import type { RunScopeItem } from "../../run/types/runScope.js"; +import { + normalizeRetryItemStatuses, + type RetryableMigrationItemRunStatus, +} from "../../run/utils/retryItemStatuses.js"; export type MigrationItemTrackingResult = { itemPreview: MigrationItemPreview | null; @@ -169,7 +173,7 @@ export const withMigrationItemTracking = async < item, dryRun, claimItemRun = false, - retryFailed = false, + retryItemStatuses, run, }: { ctx: AutumnContext; @@ -178,10 +182,13 @@ export const withMigrationItemTracking = async < item: RunScopeItem; dryRun: boolean; claimItemRun?: boolean; - retryFailed?: boolean; + retryItemStatuses?: RetryableMigrationItemRunStatus[]; run: () => Promise; }): Promise => { if (claimItemRun) { + const retryStatuses = normalizeRetryItemStatuses({ + retryItemStatuses, + }); const claim = await migrationItemRunRepo.claim({ ctx, migrationInternalId, @@ -189,7 +196,8 @@ export const withMigrationItemTracking = async < dryRun, itemKind: item.kind, itemId: item.internal_id, - claimBehavior: retryFailed ? "retry_failed" : "claim_new", + claimBehavior: retryStatuses.length > 0 ? "retry_statuses" : "claim_new", + retryStatuses, }); if (!claim.claimed) { diff --git a/server/src/internal/migrations/v2/actions/migrationRun/withMigrationRunClaim.ts b/server/src/internal/migrations/v2/actions/migrationRun/withMigrationRunClaim.ts index b598b6ea3..b841c4018 100644 --- a/server/src/internal/migrations/v2/actions/migrationRun/withMigrationRunClaim.ts +++ b/server/src/internal/migrations/v2/actions/migrationRun/withMigrationRunClaim.ts @@ -56,13 +56,6 @@ export const withMigrationRunClaim = async ({ }); } - // Lazy-mode runs need to land on `ctx.org.pendingMigrations` for every - // authed request, so bust the cached api-key payload here. Non-lazy runs - // have no effect on the hot path until the trigger task starts mutating. - if (lazyRun) { - await clearOrgCache({ db: ctx.db, orgId: ctx.org.id, env: ctx.env }); - } - let result: { triggerRunId?: string } | undefined; try { result = await claimed(migrationRun.internal_id); @@ -106,6 +99,12 @@ export const withMigrationRunClaim = async ({ } } + // Publish lazy-mode runs only after claim setup succeeds, so customer + // request-path tasks cannot observe a migration before prepare completes. + if (lazyRun) { + await clearOrgCache({ db: ctx.db, orgId: ctx.org.id, env: ctx.env }); + } + return { migrationRunId: migrationRun.internal_id, triggerRunId: result?.triggerRunId, diff --git a/server/src/internal/migrations/v2/actions/migrationRun/withMigrationRunTracking.ts b/server/src/internal/migrations/v2/actions/migrationRun/withMigrationRunTracking.ts index c7f9ef582..b5eb55ba2 100644 --- a/server/src/internal/migrations/v2/actions/migrationRun/withMigrationRunTracking.ts +++ b/server/src/internal/migrations/v2/actions/migrationRun/withMigrationRunTracking.ts @@ -1,6 +1,10 @@ import { MigrationRunStatus } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { migrationRunRepo } from "../../repos/index.js"; +import { + clearMigrationCancelRequested, + isMigrationCancelRequested, +} from "../../run/utils/migrationCancelToken.js"; export const withMigrationRunTracking = async ({ ctx, @@ -22,14 +26,29 @@ export const withMigrationRunTracking = async ({ try { const result = await run(); + + // In-flight items have drained. If cancellation was requested mid-run, + // settle as `canceled` rather than `succeeded`. + const cancelRequested = await isMigrationCancelRequested({ + migrationRunId, + }); await migrationRunRepo.update({ ctx, internalId: migrationRunId, - updates: { - status: MigrationRunStatus.Succeeded, - finished_at: Date.now(), - }, + updates: cancelRequested + ? { + status: MigrationRunStatus.Canceled, + error_message: "Canceled by user", + finished_at: Date.now(), + } + : { + status: MigrationRunStatus.Succeeded, + finished_at: Date.now(), + }, }); + if (cancelRequested) { + await clearMigrationCancelRequested({ migrationRunId }); + } return result; } catch (error) { await migrationRunRepo.update({ diff --git a/server/src/internal/migrations/v2/cloudAdapter/types.ts b/server/src/internal/migrations/v2/cloudAdapter/types.ts index be9eb5a15..914da5d3f 100644 --- a/server/src/internal/migrations/v2/cloudAdapter/types.ts +++ b/server/src/internal/migrations/v2/cloudAdapter/types.ts @@ -1,5 +1,6 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { RunScopeItem } from "../run/types/runScope.js"; +import type { RetryableMigrationItemRunStatus } from "../run/utils/retryItemStatuses.js"; export type MigrationRunControls = { concurrency?: number; @@ -7,6 +8,7 @@ export type MigrationRunControls = { only?: string[] | null; checkpoint?: boolean; checkpointDryRun?: boolean; + retryItemStatuses?: RetryableMigrationItemRunStatus[]; }; export type MigrationBatchResult> = { diff --git a/server/src/internal/migrations/v2/filters/customers/buildCustomerSelect.ts b/server/src/internal/migrations/v2/filters/customers/buildCustomerSelect.ts index b34c3f7ea..efbbedd4b 100644 --- a/server/src/internal/migrations/v2/filters/customers/buildCustomerSelect.ts +++ b/server/src/internal/migrations/v2/filters/customers/buildCustomerSelect.ts @@ -1,21 +1,64 @@ import type { CustomerFilter, MigrationItemRunStatus } from "@autumn/shared"; -import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js"; import type { ResolutionContext } from "@autumn/shared/api/migrations/compiler/filterToIr/resolutionContext.js"; +import { buildCustomerCandidateQuery } from "@autumn/shared/api/migrations/filters/planner/buildCustomerCandidateQuery.js"; import { type SQL, sql } from "drizzle-orm"; +import type { CustomerListFilters } from "@/internal/customers/customerListFilters.js"; +import { + getCustomerListFilterSql, + parseDashboardProcessorFilter, + parseDashboardStatusFilter, + parseDashboardVersionFilter, +} from "@/internal/customers/getFullCusQuery.js"; import { rawWithParamsToDrizzle } from "../rawWithParamsToDrizzle.js"; +export type IncludeProcessed = { + migrationInternalId: string; + executionFilter?: CustomerExecutionStatusFilter; +}; + +export type CustomerExecutionStatus = + | MigrationItemRunStatus + | "not_run" + | "queued"; + +export type CustomerExecutionStatusFilter = { + statuses: CustomerExecutionStatus[]; + migrationRunId?: string; + dryRun?: boolean; + queuedRun?: { + migrationRunId: string; + dryRun: boolean; + onlyIds?: string[]; + targetLimit?: number; + }; +}; + export type CustomerQueryArgs = { orgId: string; env: string; filter: CustomerFilter; ctx: ResolutionContext; checkpoint?: CustomerCheckpointExclusion; + search?: string; + customerFilters?: CustomerListFilters; }; -const compileWhere = ({ orgId, env, filter, ctx }: CustomerQueryArgs): SQL => - rawWithParamsToDrizzle( - compileFilter({ filter, ctx, ambient: { orgId, env } }), - ); +const compileCustomerCandidate = ({ + orgId, + env, + filter, + ctx, +}: CustomerQueryArgs): { source: SQL; where: SQL } => { + const candidate = buildCustomerCandidateQuery({ + filter, + ctx, + ambient: { orgId, env }, + }); + return { + source: rawWithParamsToDrizzle(candidate.source), + where: rawWithParamsToDrizzle(candidate.where), + }; +}; export type CustomerCheckpointExclusion = { migrationInternalId: string; @@ -56,10 +99,201 @@ const buildCheckpointWhere = ( `; }; +const buildCustomerListWhere = ({ + orgId, + env, + search, + customerFilters, +}: { + orgId: string; + env: string; + search?: string; + customerFilters?: CustomerListFilters; +}): SQL => + getCustomerListFilterSql({ + orgId, + env, + search, + statusFilters: parseDashboardStatusFilter(customerFilters?.status), + noneFilter: customerFilters?.none, + productVersionFilters: parseDashboardVersionFilter(customerFilters?.version), + processors: parseDashboardProcessorFilter(customerFilters?.processor), + }); + +const buildProcessedIn = (includeProcessed: IncludeProcessed): SQL => sql` + c.internal_id IN ( + SELECT mir.item_id FROM migration_item_runs mir + WHERE mir.migration_internal_id = ${includeProcessed.migrationInternalId} + AND mir.item_kind = 'customer' + AND mir.dry_run = false + )`; + +const buildExecutionScope = ( + migrationInternalId: string, + filter: Pick< + CustomerExecutionStatusFilter, + "migrationRunId" | "dryRun" + > | undefined, +): SQL => { + const dryRunScope = + filter?.dryRun !== undefined + ? sql`AND mir.dry_run = ${filter.dryRun}` + : sql`AND mir.dry_run = false`; + const runScope = filter?.migrationRunId + ? sql`AND mir.migration_run_id = ${filter.migrationRunId}` + : sql``; + + return sql` + mir.migration_internal_id = ${migrationInternalId} + AND mir.item_kind = 'customer' + ${dryRunScope} + ${runScope} + `; +}; + +const buildQueuedTargetWhere = ( + queuedRun: CustomerExecutionStatusFilter["queuedRun"], +): SQL => { + if (!queuedRun) return sql`false`; + if (queuedRun.targetLimit !== undefined) return sql`false`; + if (queuedRun.onlyIds && queuedRun.onlyIds.length > 0) { + const ids = sql.join( + queuedRun.onlyIds.map((id) => sql`${id}`), + sql`, `, + ); + return sql`(c.internal_id IN (${ids}) OR c.id IN (${ids}))`; + } + return sql`true`; +}; + +const buildQueuedWhere = ( + includeProcessed: IncludeProcessed, + filter: CustomerExecutionStatusFilter, +): SQL => { + const claimedScope = filter.queuedRun?.dryRun + ? { + migrationRunId: filter.queuedRun.migrationRunId, + dryRun: true, + } + : { dryRun: false }; + + return sql` + ${buildQueuedTargetWhere(filter.queuedRun)} + AND NOT EXISTS ( + SELECT 1 + FROM migration_item_runs mir + WHERE ${buildExecutionScope( + includeProcessed.migrationInternalId, + claimedScope, + )} + AND mir.item_id = c.internal_id + ) + `; +}; + +const buildExecutionStatusWhere = ( + includeProcessed: IncludeProcessed | undefined, + { includeNotRun = true }: { includeNotRun?: boolean } = {}, +): SQL => { + const filter = includeProcessed?.executionFilter; + if (!includeProcessed || !filter || filter.statuses.length === 0) + return sql``; + + const explicitStatuses = filter.statuses.filter( + (status): status is MigrationItemRunStatus => + status !== "not_run" && status !== "queued", + ); + const clauses: SQL[] = []; + + if (explicitStatuses.length > 0) { + const statuses = sql.join( + explicitStatuses.map((status) => sql`${status}`), + sql`, `, + ); + clauses.push(sql` + EXISTS ( + SELECT 1 + FROM migration_item_runs mir + WHERE ${buildExecutionScope(includeProcessed.migrationInternalId, filter)} + AND mir.item_id = c.internal_id + AND mir.status IN (${statuses}) + ) + `); + } + + if (includeNotRun && filter.statuses.includes("not_run")) { + clauses.push(sql` + NOT EXISTS ( + SELECT 1 + FROM migration_item_runs mir + WHERE ${buildExecutionScope(includeProcessed.migrationInternalId, filter)} + AND mir.item_id = c.internal_id + ) + AND NOT (${buildQueuedTargetWhere(filter.queuedRun)}) + `); + } + + if (includeNotRun && filter.statuses.includes("queued")) { + clauses.push(buildQueuedWhere(includeProcessed, filter)); + } + + if (clauses.length === 0) return sql`AND false`; + return clauses.length === 1 + ? sql`AND ${clauses[0]}` + : sql`AND (${sql.join(clauses, sql` OR `)})`; +}; + +const getExecutionFilterMode = ( + includeProcessed: IncludeProcessed, +): "all" | "explicit_only" | "not_run_only" | "mixed" => { + const statuses = includeProcessed.executionFilter?.statuses; + if (!statuses || statuses.length === 0) return "all"; + + const hasNotRun = statuses.includes("not_run"); + const hasQueued = statuses.includes("queued"); + const hasPending = hasNotRun || hasQueued; + const hasExplicit = statuses.some( + (status) => status !== "not_run" && status !== "queued", + ); + if (hasExplicit && hasPending) return "mixed"; + if (hasExplicit) return "explicit_only"; + return "not_run_only"; +}; + +// Predicates shared by both UNION branches (and the single-branch query). +// Rebuilt per call so a branch never reuses another's SQL chunk instance. +const buildCommonWhere = ({ + checkpoint, + orgId, + env, + search, + customerFilters, + afterInternalId, + includeProcessed, + includeNotRun, +}: { + checkpoint?: CustomerCheckpointExclusion; + orgId: string; + env: string; + search?: string; + customerFilters?: CustomerListFilters; + afterInternalId?: string; + includeProcessed?: IncludeProcessed; + includeNotRun?: boolean; +}): SQL => { + const cursor = afterInternalId + ? sql`AND c.internal_id < ${afterInternalId}` + : sql``; + return sql`${buildCheckpointWhere(checkpoint)} ${buildCustomerListWhere({ orgId, env, search, customerFilters })} ${buildExecutionStatusWhere(includeProcessed, { includeNotRun })} ${cursor}`; +}; + /** * Full SELECT. Returns `{ internal_id, id }` rows newest-first via keyset * pagination on `c.internal_id DESC`, so successive iterations over an * unchanged customer set yield rows in the same order. + * + * Pure filter set only — the run path. To also surface already-processed + * customers (preview live view), use `buildProcessedPreviewSelect`. */ export const buildCustomerSelect = ({ orgId, @@ -67,22 +301,20 @@ export const buildCustomerSelect = ({ filter, ctx, checkpoint, + search, + customerFilters, limit, afterInternalId, }: CustomerQueryArgs & { limit?: number; afterInternalId?: string; }): SQL => { - const where = compileWhere({ orgId, env, filter, ctx }); - const checkpointWhere = buildCheckpointWhere(checkpoint); - const cursor = afterInternalId - ? sql`AND c.internal_id < ${afterInternalId}` - : sql``; + const candidate = compileCustomerCandidate({ orgId, env, filter, ctx }); const limitClause = limit !== undefined ? sql`LIMIT ${limit}` : sql``; return sql` SELECT c.internal_id, c.id, c.name, c.email - FROM customers c - WHERE (${where}) ${checkpointWhere} ${cursor} + FROM ${candidate.source} + WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, afterInternalId })} ORDER BY c.internal_id DESC ${limitClause} `; @@ -95,12 +327,147 @@ export const buildCustomerCount = ({ filter, ctx, checkpoint, + search, + customerFilters, }: CustomerQueryArgs): SQL => { - const where = compileWhere({ orgId, env, filter, ctx }); - const checkpointWhere = buildCheckpointWhere(checkpoint); + const candidate = compileCustomerCandidate({ orgId, env, filter, ctx }); return sql` SELECT COUNT(*)::bigint AS count - FROM customers c - WHERE (${where}) ${checkpointWhere} + FROM ${candidate.source} + WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters })} + `; +}; + +export const buildLimitedCustomerCount = ({ + limit, + ...args +}: CustomerQueryArgs & { limit: number }): SQL => { + const candidate = compileCustomerCandidate(args); + return sql` + SELECT COUNT(*)::bigint AS count + FROM ( + SELECT 1 + FROM ${candidate.source} + WHERE (${candidate.where}) ${buildCommonWhere({ + checkpoint: args.checkpoint, + orgId: args.orgId, + env: args.env, + search: args.search, + customerFilters: args.customerFilters, + })} + LIMIT ${limit} + ) limited + `; +}; + +// ─── Preview-only: filter set ∪ already-processed set ──────────────── +// The live view surfaces customers an in-flight migration already ran for, +// which the live filter no longer matches. We UNION the two scoped sets +// rather than OR them: an `OR ... IN (...)` strips org/env scoping from the +// customers scan and forces a full-table seq scan, whereas each UNION branch +// keeps its own index. Equivalent to `(filter OR processed) AND ` +// because `` (checkpoint/search/cursor) is applied per branch. + +type ProcessedPreviewArgs = CustomerQueryArgs & { + includeProcessed: IncludeProcessed; +}; + +export const buildProcessedPreviewSelect = ({ + orgId, + env, + filter, + ctx, + checkpoint, + search, + customerFilters, + includeProcessed, + limit, + afterInternalId, +}: ProcessedPreviewArgs & { + limit?: number; + afterInternalId?: string; +}): SQL => { + const candidate = compileCustomerCandidate({ orgId, env, filter, ctx }); + const processed = buildProcessedIn(includeProcessed); + const limitClause = limit !== undefined ? sql`LIMIT ${limit}` : sql``; + const mode = getExecutionFilterMode(includeProcessed); + + if (mode === "explicit_only") { + return sql` + SELECT c.internal_id, c.id, c.name, c.email + FROM customers c + WHERE (${processed}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, afterInternalId, includeProcessed, includeNotRun: false })} + ORDER BY c.internal_id DESC + ${limitClause} + `; + } + + if (mode === "not_run_only") { + return sql` + SELECT c.internal_id, c.id, c.name, c.email + FROM ${candidate.source} + WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, afterInternalId, includeProcessed })} + ORDER BY c.internal_id DESC + ${limitClause} + `; + } + + return sql` + SELECT u.internal_id, u.id, u.name, u.email + FROM ( + SELECT c.internal_id, c.id, c.name, c.email + FROM ${candidate.source} + WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, afterInternalId, includeProcessed })} + UNION + SELECT c.internal_id, c.id, c.name, c.email + FROM customers c + WHERE (${processed}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, afterInternalId, includeProcessed, includeNotRun: false })} + ) u + ORDER BY u.internal_id DESC + ${limitClause} + `; +}; + +export const buildProcessedPreviewCount = ({ + orgId, + env, + filter, + ctx, + checkpoint, + search, + customerFilters, + includeProcessed, +}: ProcessedPreviewArgs): SQL => { + const candidate = compileCustomerCandidate({ orgId, env, filter, ctx }); + const processed = buildProcessedIn(includeProcessed); + const mode = getExecutionFilterMode(includeProcessed); + + if (mode === "explicit_only") { + return sql` + SELECT COUNT(*)::bigint AS count + FROM customers c + WHERE (${processed}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, includeProcessed, includeNotRun: false })} + `; + } + + if (mode === "not_run_only") { + return sql` + SELECT COUNT(*)::bigint AS count + FROM ${candidate.source} + WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, includeProcessed })} + `; + } + + return sql` + SELECT COUNT(*)::bigint AS count + FROM ( + SELECT c.internal_id + FROM ${candidate.source} + WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, includeProcessed })} + UNION + SELECT c.internal_id + FROM customers c + WHERE (${processed}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, includeProcessed, includeNotRun: false })} + ) u `; }; diff --git a/server/src/internal/migrations/v2/filters/customers/filterCustomers.ts b/server/src/internal/migrations/v2/filters/customers/filterCustomers.ts index 159dc61cc..de80d5469 100644 --- a/server/src/internal/migrations/v2/filters/customers/filterCustomers.ts +++ b/server/src/internal/migrations/v2/filters/customers/filterCustomers.ts @@ -1,10 +1,15 @@ import type { CustomerFilter } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { CustomerListFilters } from "@/internal/customers/customerListFilters.js"; import { iterateOverFilterResults } from "../iterateOverFilterResults.js"; import { buildCustomerCount, buildCustomerSelect, + buildLimitedCustomerCount, + buildProcessedPreviewCount, + buildProcessedPreviewSelect, type CustomerCheckpointExclusion, + type IncludeProcessed, } from "./buildCustomerSelect.js"; export type CustomerRow = { @@ -14,6 +19,50 @@ export type CustomerRow = { email: string | null; }; +const buildArgs = ({ + ctx, + filter, + checkpoint, + search, + customerFilters, +}: { + ctx: AutumnContext; + filter: CustomerFilter; + checkpoint?: CustomerCheckpointExclusion; + search?: string; + customerFilters?: CustomerListFilters; +}) => ({ + orgId: ctx.org.id, + env: ctx.env, + filter, + checkpoint, + search, + customerFilters, + ctx: { features: ctx.features }, +}); + +type CustomerSelectArgs = ReturnType; + +const buildRowsSelect = ({ + args, + includeProcessed, + limit, + afterInternalId, +}: { + args: CustomerSelectArgs; + includeProcessed?: IncludeProcessed; + limit?: number; + afterInternalId?: string; +}) => + includeProcessed + ? buildProcessedPreviewSelect({ + ...args, + includeProcessed, + limit, + afterInternalId, + }) + : buildCustomerSelect({ ...args, limit, afterInternalId }); + /** * Pure inner: takes a CustomerFilter directly. Used by `runFilter` shim * (Migration-fed) and reusable from scripts that don't have a Migration. @@ -22,26 +71,68 @@ export const filterCustomers = ({ ctx, filter, checkpoint, + search, + customerFilters, + includeProcessed, batchSize, + limit, }: { ctx: AutumnContext; filter: CustomerFilter; checkpoint?: CustomerCheckpointExclusion; + search?: string; + customerFilters?: CustomerListFilters; + includeProcessed?: IncludeProcessed; batchSize?: number; + limit?: number; }): AsyncGenerator => { - const args = { - orgId: ctx.org.id, - env: ctx.env, - filter, - checkpoint, - ctx: { features: ctx.features }, - }; - return iterateOverFilterResults({ + const args = buildArgs({ ctx, filter, checkpoint, search, customerFilters }); + const source = iterateOverFilterResults({ db: ctx.db, buildSelect: ({ limit, afterInternalId }) => - buildCustomerSelect({ ...args, limit, afterInternalId }), - batchSize, + buildRowsSelect({ args, includeProcessed, limit, afterInternalId }), + batchSize: + limit === undefined ? batchSize : Math.min(batchSize ?? limit, limit), }); + return limit === undefined ? source : takeRows(source, limit); +}; + +export const getCustomerPage = async ({ + ctx, + filter, + checkpoint, + search, + customerFilters, + includeProcessed, + pageSize, + cursor, +}: { + ctx: AutumnContext; + filter: CustomerFilter; + checkpoint?: CustomerCheckpointExclusion; + search?: string; + customerFilters?: CustomerListFilters; + includeProcessed?: IncludeProcessed; + pageSize: number; + cursor?: string; +}): Promise<{ rows: CustomerRow[]; nextCursor: string | null }> => { + const args = buildArgs({ ctx, filter, checkpoint, search, customerFilters }); + const rows = (await ctx.db.execute( + buildRowsSelect({ + args, + includeProcessed, + limit: pageSize + 1, + afterInternalId: cursor || undefined, + }), + )) as CustomerRow[]; + const pageRows = rows.slice(0, pageSize); + return { + rows: pageRows, + nextCursor: + rows.length > pageSize + ? (pageRows[pageRows.length - 1]?.internal_id ?? null) + : null, + }; }; /** Count of customers matching `filter`. */ @@ -49,19 +140,42 @@ export const countCustomers = async ({ ctx, filter, checkpoint, + search, + customerFilters, + includeProcessed, + limit, }: { ctx: AutumnContext; filter: CustomerFilter; checkpoint?: CustomerCheckpointExclusion; + search?: string; + customerFilters?: CustomerListFilters; + includeProcessed?: IncludeProcessed; + limit?: number; }): Promise => { - const [{ count }] = (await ctx.db.execute( - buildCustomerCount({ - orgId: ctx.org.id, - env: ctx.env, - filter, - checkpoint, - ctx: { features: ctx.features }, - }), - )) as Array<{ count: bigint | number }>; + const args = buildArgs({ ctx, filter, checkpoint, search, customerFilters }); + const query = includeProcessed + ? buildProcessedPreviewCount({ ...args, includeProcessed }) + : limit === undefined + ? buildCustomerCount(args) + : buildLimitedCustomerCount({ ...args, limit }); + const [{ count }] = (await ctx.db.execute(query)) as Array<{ + count: bigint | number; + }>; return Number(count); }; + +async function* takeRows( + source: AsyncGenerator, + limit: number, +): AsyncGenerator { + let remaining = limit; + if (remaining <= 0) return; + + for await (const batch of source) { + const next = batch.slice(0, remaining); + if (next.length > 0) yield next; + remaining -= next.length; + if (remaining <= 0) return; + } +} diff --git a/server/src/internal/migrations/v2/filters/runFilter.ts b/server/src/internal/migrations/v2/filters/runFilter.ts index 65bfd8195..acf702e4f 100644 --- a/server/src/internal/migrations/v2/filters/runFilter.ts +++ b/server/src/internal/migrations/v2/filters/runFilter.ts @@ -1,7 +1,11 @@ -import { MigrationItemRunStatus } from "@autumn/shared"; +import { + MigrationItemRunStatus, + type MigrationItemRunStatus as MigrationItemRunStatusType, +} from "@autumn/shared"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import type { MigrationRunControls } from "../cloudAdapter/types.js"; import type { RunScopeItem, RunScopeKind } from "../run/types/runScope.js"; +import { normalizeRetryItemStatuses } from "../run/utils/retryItemStatuses.js"; import type { MigrationRuntime, MigrationRuntimeWithEventId, @@ -51,14 +55,20 @@ export const runFilter = async ({ dryRun, controls, }); - const count = await countCustomers({ ctx, filter, checkpoint }); + const limit = controls?.limit ?? undefined; + const count = await countCustomers({ + ctx, + filter, + checkpoint, + limit, + }); ctx.logger.info("runFilter: customer scope resolved", { data: { migrationRunId, matchedCount: count, only: controls?.only, - retryFailed: migration.retry_failed === true, + retryItemStatuses: controls?.retryItemStatuses, effectiveFilter: filter, checkpointExcludedStatuses: checkpoint?.excludedStatuses, }, @@ -67,7 +77,7 @@ export const runFilter = async ({ ctx.logger.warn( "runFilter: no customers matched — nothing to migrate. " + "Common causes: customer is excluded by a previous item_run " + - "(set retry_failed=true to re-run failed items), or the customer " + + "(set retry_item_statuses to re-run checkpointed items), or the customer " + "does not match other filter clauses (plan, addon, etc.)", { data: { @@ -79,7 +89,12 @@ export const runFilter = async ({ } const iterate = async function* () { - for await (const batch of filterCustomers({ ctx, filter, checkpoint })) { + for await (const batch of filterCustomers({ + ctx, + filter, + checkpoint, + limit, + })) { yield batch.map( (row): RunScopeItem => ({ kind: "customer", @@ -109,11 +124,19 @@ const getCustomerCheckpointExclusion = ({ (!dryRun || controls?.checkpointDryRun === true); if (!enabled) return undefined; - const excludedStatuses = [ + const retryItemStatuses = normalizeRetryItemStatuses({ + retryItemStatuses: controls?.retryItemStatuses, + }); + const retryItemStatusSet = new Set(retryItemStatuses); + const excludedStatuses: MigrationItemRunStatusType[] = [ MigrationItemRunStatus.Running, MigrationItemRunStatus.Succeeded, - MigrationItemRunStatus.Skipped, - ...(migration.retry_failed ? [] : [MigrationItemRunStatus.Failed]), + ...(retryItemStatusSet.has(MigrationItemRunStatus.Skipped) + ? [] + : [MigrationItemRunStatus.Skipped]), + ...(retryItemStatusSet.has(MigrationItemRunStatus.Failed) + ? [] + : [MigrationItemRunStatus.Failed]), ]; return { diff --git a/server/src/internal/migrations/v2/handlers/handleCancelMigrationRun.ts b/server/src/internal/migrations/v2/handlers/handleCancelMigrationRun.ts index acd6b5e43..abcc6ea74 100644 --- a/server/src/internal/migrations/v2/handlers/handleCancelMigrationRun.ts +++ b/server/src/internal/migrations/v2/handlers/handleCancelMigrationRun.ts @@ -4,21 +4,25 @@ import { RecaseError, Scopes, } from "@autumn/shared"; -import { runs } from "@trigger.dev/sdk/v3"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { migrationRepo, migrationRunRepo, } from "@/internal/migrations/v2/repos/index.js"; +import { setMigrationCancelRequested } from "@/internal/migrations/v2/run/utils/migrationCancelToken.js"; +import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; const CancelMigrationRunBody = z.object({ id: z.string(), }); -/** POST /migrations.cancel_run — cancel the active migration_run for a - * migration, if any. Marks the run as `canceled` and best-effort - * cancels the trigger.dev task. Errors if no active run exists. */ +/** POST /migrations.cancel_run — request cancellation of the active + * migration_run for a migration, if any. Sets a cache token so in-flight + * items finish but no new items start. Lazy runs are marked `canceled` + * immediately (and the org cache cleared) so no further per-customer tasks + * are enqueued; batch runs settle to `canceled` once their runner drains. + * Errors if no active run exists. */ export const handleCancelMigrationRun = createRoute({ scopes: [Scopes.Migrations.Write], body: CancelMigrationRunBody, @@ -43,32 +47,31 @@ export const handleCancelMigrationRun = createRoute({ }); } - if (activeRun.trigger_run_id) { - try { - await runs.cancel(activeRun.trigger_run_id); - } catch (error) { - ctx.logger.warn( - "cancel-migration-run: trigger.dev cancel failed (continuing to mark canceled)", - { - data: { - runId: activeRun.internal_id, - triggerRunId: activeRun.trigger_run_id, - error: error instanceof Error ? error.message : String(error), - }, - }, - ); - } - } + await setMigrationCancelRequested({ migrationRunId: activeRun.internal_id }); - await migrationRunRepo.update({ - ctx, - internalId: activeRun.internal_id, - updates: { - status: MigrationRunStatus.Canceled, - error_message: "Canceled by user", - finished_at: Date.now(), - }, - }); + // Lazy runs have no batch loop to drain. Mark them canceled now and clear + // the org cache so `pendingMigrations` drops this run and the customer + // hot path stops enqueuing per-customer tasks. Batch runs are settled to + // `canceled` by their own runner (withMigrationRunTracking) after the + // in-flight items finish. + if (activeRun.lazy_run) { + await migrationRunRepo.update({ + ctx, + internalId: activeRun.internal_id, + updates: { + status: MigrationRunStatus.Canceled, + error_message: "Canceled by user", + finished_at: Date.now(), + }, + }); + + await clearOrgCache({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + logger: ctx.logger, + }); + } return c.json({ migration_id: id, diff --git a/server/src/internal/migrations/v2/handlers/handleCreateMigration.ts b/server/src/internal/migrations/v2/handlers/handleCreateMigration.ts index ac1439841..de3a82bbb 100644 --- a/server/src/internal/migrations/v2/handlers/handleCreateMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handleCreateMigration.ts @@ -9,6 +9,7 @@ const CreateMigrationBody = z.object({ id: z.string().min(1).max(200), filter: MigrationFilterSchema.nullable().optional(), operations: OperationsSchema.nullable().optional(), + no_billing_changes: z.boolean().optional(), }); /** POST /migrations.create — create a draft migration. */ diff --git a/server/src/internal/migrations/v2/handlers/handleListMigrationItemEvents.ts b/server/src/internal/migrations/v2/handlers/handleListMigrationItemEvents.ts index 8cd8690ee..77ef543cc 100644 --- a/server/src/internal/migrations/v2/handlers/handleListMigrationItemEvents.ts +++ b/server/src/internal/migrations/v2/handlers/handleListMigrationItemEvents.ts @@ -6,6 +6,7 @@ import { migrationItemEventRepo } from "../repos/index.js"; const ListMigrationItemEventsBody = z.object({ migrationId: z.string(), migrationRunId: z.string().optional(), + itemIds: z.array(z.string()).optional(), }); export const handleListMigrationItemEvents = createRoute({ @@ -13,11 +14,12 @@ export const handleListMigrationItemEvents = createRoute({ body: ListMigrationItemEventsBody, handler: async (c) => { const ctx = c.get("ctx"); - const { migrationId, migrationRunId } = c.req.valid("json"); + const { migrationId, migrationRunId, itemIds } = c.req.valid("json"); const events = await migrationItemEventRepo.list({ ctx, migrationId, migrationRunId, + itemIds, }); return c.json({ list: events }); diff --git a/server/src/internal/migrations/v2/handlers/handleListMigrationRuns.ts b/server/src/internal/migrations/v2/handlers/handleListMigrationRuns.ts index 3813d8fde..8bed5737b 100644 --- a/server/src/internal/migrations/v2/handlers/handleListMigrationRuns.ts +++ b/server/src/internal/migrations/v2/handlers/handleListMigrationRuns.ts @@ -1,7 +1,11 @@ import { Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler"; -import { migrationRepo, migrationRunRepo } from "../repos/index.js"; +import { + migrationItemRunRepo, + migrationRepo, + migrationRunRepo, +} from "../repos/index.js"; const ListMigrationRunsBody = z.object({ migrationId: z.string(), @@ -18,7 +22,47 @@ export const handleListMigrationRuns = createRoute({ ctx, migrationInternalId: migration.internal_id, }); + const dryRunIds = runs + .filter((run) => run.dry_run) + .map((run) => run.internal_id); + const hasLiveRuns = runs.some((run) => !run.dry_run); - return c.json({ list: runs }); + const countRows = await migrationItemRunRepo.listCountsByRun({ + ctx, + migrationInternalId: migration.internal_id, + migrationRunIds: dryRunIds, + }); + const liveCounts = hasLiveRuns + ? await migrationItemRunRepo.getCounts({ + ctx, + migrationInternalId: migration.internal_id, + dryRun: false, + }) + : null; + const countsByRunId = new Map( + countRows.map((row) => [row.migration_run_id, row]), + ); + const runsWithCounts = runs.map((run) => { + const counts = run.dry_run + ? countsByRunId.get(run.internal_id) + : liveCounts; + const succeeded = counts?.succeeded ?? 0; + const skipped = counts?.skipped ?? 0; + const failed = counts?.failed ?? 0; + + return { + ...run, + item_run_counts: { + total: counts?.total ?? 0, + running: counts?.running ?? 0, + succeeded, + skipped, + failed, + completed: succeeded + skipped + failed, + }, + }; + }); + + return c.json({ list: runsWithCounts }); }, }); diff --git a/server/src/internal/migrations/v2/handlers/handleListMigrations.ts b/server/src/internal/migrations/v2/handlers/handleListMigrations.ts index 884b28058..017f08b6c 100644 --- a/server/src/internal/migrations/v2/handlers/handleListMigrations.ts +++ b/server/src/internal/migrations/v2/handlers/handleListMigrations.ts @@ -1,4 +1,9 @@ -import { Scopes } from "@autumn/shared"; +import { + MigrationItemKind, + migrationItemRuns, + Scopes, +} from "@autumn/shared"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; @@ -8,6 +13,33 @@ export const handleListMigrations = createRoute({ handler: async (c) => { const ctx = c.get("ctx"); const migrations = await migrationRepo.get({ ctx }); - return c.json({ list: migrations }); + + if (migrations.length === 0) return c.json({ list: [] }); + + const internalIds = migrations.map((m) => m.internal_id); + + const rows = await ctx.db + .select({ + migration_internal_id: migrationItemRuns.migration_internal_id, + count: sql`count(*)::int`, + }) + .from(migrationItemRuns) + .where( + and( + inArray(migrationItemRuns.migration_internal_id, internalIds), + eq(migrationItemRuns.item_kind, MigrationItemKind.Customer), + eq(migrationItemRuns.dry_run, false), + ), + ) + .groupBy(migrationItemRuns.migration_internal_id); + + const liveRunSet = new Set(rows.map((r) => r.migration_internal_id)); + + const enriched = migrations.map((m) => ({ + ...m, + has_live_runs: liveRunSet.has(m.internal_id), + })); + + return c.json({ list: enriched }); }, }); diff --git a/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts b/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts index 66e9817d3..0a9e8efb4 100644 --- a/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts @@ -11,7 +11,8 @@ const PatchMigrationBody = z.object({ id: z.string().min(1).max(200).optional(), filter: MigrationFilterSchema.nullable().optional(), operations: OperationsSchema.nullable().optional(), - retry_failed: z.boolean().optional(), + no_billing_changes: z.boolean().nullable().optional(), + archived: z.boolean().optional(), }), }); diff --git a/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts b/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts index 02be77090..0a8a02382 100644 --- a/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts +++ b/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts @@ -2,25 +2,52 @@ import { CustomerFilterSchema, customerProducts, customers, + MigrationItemKind, products, + RELEVANT_STATUSES, Scopes, } from "@autumn/shared"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod/v4"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { CustomerListFiltersSchema } from "@/internal/customers/customerListFilters.js"; import { countCustomers, - filterCustomers, + getCustomerPage, } from "@/internal/migrations/v2/filters/customers/filterCustomers.js"; +import type { IncludeProcessed } from "../filters/customers/buildCustomerSelect.js"; +import { + migrationItemRunRepo, + migrationRepo, + migrationRunRepo, +} from "../repos/index.js"; -const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_PAGE_SIZE = 50; const PreviewFilterBody = z.object({ filter: CustomerFilterSchema.optional().default({}), search: z.string().optional().default(""), - page: z.number().int().min(0).optional().default(0), - pageSize: z.number().int().min(1).max(500).optional().default(DEFAULT_PAGE_SIZE), + customerFilters: CustomerListFiltersSchema.optional(), + cursor: z.string().optional().default(""), + includeCount: z.boolean().optional().default(true), + countOnly: z.boolean().optional().default(false), + pageSize: z + .number() + .int() + .min(1) + .max(500) + .optional() + .default(DEFAULT_PAGE_SIZE), + migrationId: z.string().optional(), + executionStatuses: z + .array( + z.enum(["queued", "running", "succeeded", "skipped", "failed", "not_run"]), + ) + .optional() + .default([]), + migrationRunId: z.string().optional(), + migrationRunDryRun: z.boolean().optional(), }); /** POST /migrations.filter.preview — count + enriched paginated customers. */ @@ -29,39 +56,130 @@ export const handlePreviewMigrationFilter = createRoute({ body: PreviewFilterBody, handler: async (c) => { const ctx = c.get("ctx"); - const { filter, search, page, pageSize } = c.req.valid("json"); + const { + filter, + search, + customerFilters, + cursor, + includeCount, + countOnly, + pageSize, + migrationId, + executionStatuses, + migrationRunId, + migrationRunDryRun, + } = c.req.valid("json"); - const [count, pageRows] = await Promise.all([ - countCustomers({ ctx, filter }), - collectPage( - filterCustomers({ ctx, filter, batchSize: pageSize }), - page * pageSize, - pageSize, - ), - ]); + const searchTerm = search || undefined; + + // An empty customer scope compiles to nothing (wrapAnd throws). Treat "no + // active filter" as selecting nobody rather than 500ing the preview. + const hasAnyField = Object.values(filter ?? {}).some( + (v) => v !== undefined, + ); + if (!hasAnyField) { + return c.json({ + count: includeCount ? 0 : null, + customers: [], + next_cursor: null, + }); + } + + let includeProcessed: IncludeProcessed | undefined; + let migrationInternalId: string | undefined; + if (migrationId) { + const migration = await migrationRepo.find({ ctx, id: migrationId }); + migrationInternalId = migration.internal_id; + const needsActiveRun = executionStatuses.some((status) => + ["queued", "not_run"].includes(status), + ); + const [activeRun] = needsActiveRun + ? await migrationRunRepo.list({ + ctx, + migrationInternalId: migration.internal_id, + active: true, + }) + : []; + includeProcessed = { + migrationInternalId: migration.internal_id, + executionFilter: + executionStatuses.length > 0 + ? { + statuses: executionStatuses, + migrationRunId, + dryRun: migrationRunDryRun, + queuedRun: activeRun + ? { + migrationRunId: activeRun.internal_id, + dryRun: activeRun.dry_run, + onlyIds: activeRun.only_ids ?? undefined, + targetLimit: activeRun.target_limit ?? undefined, + } + : undefined, + } + : undefined, + }; + } + + const countPromise = includeCount + ? countCustomers({ + ctx, + filter, + search: searchTerm, + customerFilters, + includeProcessed, + }) + : Promise.resolve(null); + const pagePromise = countOnly + ? Promise.resolve({ rows: [], nextCursor: null }) + : getCustomerPage({ + ctx, + filter, + search: searchTerm, + customerFilters, + includeProcessed, + pageSize, + cursor, + }); + const [count, pageResult] = await Promise.all([countPromise, pagePromise]); + const pageRows = pageResult.rows; if (pageRows.length === 0) { - return c.json({ count, customers: [], page, pageSize }); + return c.json({ + count, + customers: [], + next_cursor: null, + }); } const enriched = await enrichCustomers( ctx.db, pageRows.map((r) => r.internal_id), ); + const itemRuns = migrationInternalId + ? await migrationItemRunRepo.listForItems({ + ctx, + migrationInternalId, + itemKind: MigrationItemKind.Customer, + itemIds: pageRows.map((r) => r.internal_id), + dryRun: false, + }) + : []; + const itemRunsByCustomer = new Map( + itemRuns.map((run) => [run.item_id, run]), + ); - let grouped = groupByCustomer(enriched); + const grouped = groupByCustomer(enriched).map((customer) => ({ + ...customer, + migration_item_run: + itemRunsByCustomer.get(customer.internal_id as string) ?? null, + })); - if (search) { - const q = search.toLowerCase(); - grouped = grouped.filter((row) => { - const name = (row.name as string | null)?.toLowerCase() ?? ""; - const email = (row.email as string | null)?.toLowerCase() ?? ""; - const id = (row.id as string | null)?.toLowerCase() ?? ""; - return name.includes(q) || email.includes(q) || id.includes(q); - }); - } - - return c.json({ count, customers: grouped, page, pageSize }); + return c.json({ + count, + customers: grouped, + next_cursor: pageResult.nextCursor, + }); }, }); @@ -103,8 +221,17 @@ async function enrichCustomers(db: DrizzleCli, ids: string[]) { }, }) .from(customers) - .leftJoin(customerProducts, eq(customers.internal_id, customerProducts.internal_customer_id)) - .leftJoin(products, eq(customerProducts.internal_product_id, products.internal_id)) + .leftJoin( + customerProducts, + and( + eq(customers.internal_id, customerProducts.internal_customer_id), + inArray(customerProducts.status, RELEVANT_STATUSES), + ), + ) + .leftJoin( + products, + eq(customerProducts.internal_product_id, products.internal_id), + ) .where(inArray(customers.internal_id, ids)); } @@ -113,10 +240,17 @@ function groupByCustomer(rows: Array>) { for (const row of rows) { const id = row.internal_id as string; if (!map.has(id)) { - const { customer_product, product, ...customer } = row; + const { + customer_product: _customerProduct, + product: _product, + ...customer + } = row; map.set(id, { ...customer, customer_products: [] }); } - if (row.customer_product && (row.customer_product as Record).id) { + if ( + row.customer_product && + (row.customer_product as Record).id + ) { const entry = map.get(id)!; (entry.customer_products as unknown[]).push({ ...(row.customer_product as Record), @@ -126,23 +260,3 @@ function groupByCustomer(rows: Array>) { } return Array.from(map.values()); } - -async function collectPage( - gen: AsyncGenerator, - skip: number, - take: number, -): Promise { - const rows: T[] = []; - let skipped = 0; - for await (const batch of gen) { - for (const row of batch) { - if (skipped < skip) { - skipped++; - continue; - } - rows.push(row); - if (rows.length >= take) return rows; - } - } - return rows; -} diff --git a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts index 35e8e99e3..5f432a0e5 100644 --- a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts @@ -3,15 +3,22 @@ import { auth } from "@trigger.dev/sdk/v3"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { withMigrationRunClaim } from "@/internal/migrations/v2/actions/migrationRun/index.js"; +import { prepare } from "@/internal/migrations/v2/prepare/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; +import { RETRYABLE_MIGRATION_ITEM_RUN_STATUSES } from "@/internal/migrations/v2/run/utils/retryItemStatuses.js"; import { runMigrationTask } from "@/trigger/migrations/runMigrationTask.js"; +const MAX_CONCURRENCY = 5; + const RunMigrationBody = z.object({ id: z.string(), dry_run: z.boolean().default(false), limit: z.number().int().min(1).optional(), only: z.array(z.string()).optional(), - concurrency: z.number().int().min(1).optional(), + concurrency: z.number().int().min(1).max(MAX_CONCURRENCY).optional(), + retry_item_statuses: z + .array(z.enum(RETRYABLE_MIGRATION_ITEM_RUN_STATUSES)) + .optional(), /** When true, claim a lazy run alongside the background sweeper. Customers * hit on the request path get migrated lazily via `runMigrationCustomerTask` * before the sweeper reaches them. Background and lazy run on the same @@ -21,13 +28,15 @@ const RunMigrationBody = z.object({ const getRunMigrationTriggerOptions = ({ orgId, + migrationId, isDev, }: { orgId: string; + migrationId: string; isDev: boolean; }) => ({ ...(isDev ? { region: "eu-central-1" } : {}), - concurrencyKey: orgId, + concurrencyKey: `${orgId}:${migrationId}`, }); export const handleRunMigration = createRoute({ @@ -41,6 +50,7 @@ export const handleRunMigration = createRoute({ limit, only, concurrency, + retry_item_statuses: retryItemStatuses, lazy_run: lazyRun, } = c.req.valid("json"); @@ -53,6 +63,15 @@ export const handleRunMigration = createRoute({ statusCode: 400, }); + if (lazyRun && only && only.length > 0) { + throw new RecaseError({ + message: + "Migration lazy_run cannot be combined with only. Run targeted customers without lazy_run.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + const isDev = process.env.NODE_ENV === "development"; const { migrationRunId, triggerRunId } = await withMigrationRunClaim({ ctx, @@ -62,6 +81,9 @@ export const handleRunMigration = createRoute({ onlyIds: only, targetLimit: limit, claimed: async (migrationRunId) => { + if (lazyRun && !dryRun) { + await prepare({ ctx, migration, dryRun: false }); + } const handle = await runMigrationTask.trigger( { orgId: ctx.org.id, @@ -69,10 +91,17 @@ export const handleRunMigration = createRoute({ migrationId: id, migrationRunId, dryRun, - controls: { limit, only, concurrency }, + lazyRun, + controls: { + limit, + only, + concurrency, + retryItemStatuses, + }, }, getRunMigrationTriggerOptions({ orgId: ctx.org.id, + migrationId: id, isDev, }), ); @@ -96,6 +125,7 @@ export const handleRunMigration = createRoute({ migration_id: id, dry_run: dryRun, lazy_run: lazyRun, + concurrency, run_id: migrationRunId, trigger_run_id: triggerRunId, public_access_token: publicAccessToken, diff --git a/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts b/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts index b33215c7c..138c38ad6 100644 --- a/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts +++ b/server/src/internal/migrations/v2/operations/updatePlan/setup/setupUpdatePlanProductContext.ts @@ -2,7 +2,6 @@ import { BillingVersion, type FullCusProduct, type FullCustomer, - hasCustomItems, orgDisableStripeWrites, type UpdateSubscriptionBillingContext, UpdateSubscriptionIntent, @@ -106,6 +105,7 @@ export const setupUpdatePlanProductContext = async ({ fullCustomer: productFullCustomer, params, reusePricesAndEntitlements, + resetToCatalogVersion: typeof preparedOp.version === "number", }); const operationBillingContext = await setupMigrationOperationBillingContext({ @@ -129,6 +129,7 @@ export const setupUpdatePlanProductContext = async ({ params.no_billing_changes === true || operationBillingContext.stripeSubscription === undefined; + const invoiceMode = await setupInvoiceModeContext({ ctx, params }); const billingContext: UpdateSubscriptionBillingContext = { intent: UpdateSubscriptionIntent.UpdatePlan, fullCustomer: productFullCustomer, @@ -147,13 +148,13 @@ export const setupUpdatePlanProductContext = async ({ resetCycleAnchorMs: operationBillingContext.resetCycleAnchorMs, requestedBillingCycleAnchor: params.billing_cycle_anchor, requestedProrationBehavior: params.proration_behavior, - invoiceMode: setupInvoiceModeContext({ params }), + invoiceMode, featureQuantities, adjustableFeatureQuantities: setupAdjustableQuantities({ params }), customPrices, customEnts, trialContext: operationBillingContext.trialContext, - isCustom: hasCustomItems(params.customize), + isCustom: targetCustomerProduct.is_custom, billingVersion: BillingVersion.V2, actionSource: "migration", skipBillingChanges, diff --git a/server/src/internal/migrations/v2/operations/utils/mergeAutumnBillingPlans.ts b/server/src/internal/migrations/v2/operations/utils/mergeAutumnBillingPlans.ts index bb6786dc6..774623956 100644 --- a/server/src/internal/migrations/v2/operations/utils/mergeAutumnBillingPlans.ts +++ b/server/src/internal/migrations/v2/operations/utils/mergeAutumnBillingPlans.ts @@ -39,6 +39,11 @@ export const mergeAutumnBillingPlans = ({ ...(incoming.deleteCustomerProducts ?? []), ], }), + schedulePhaseCustomerProductReplacements: mergeByKey({ + base: base.schedulePhaseCustomerProductReplacements, + incoming: incoming.schedulePhaseCustomerProductReplacements, + getKey: (replacement) => replacement.oldCustomerProductId, + }), customPrices: mergeById({ base: base.customPrices, incoming: incoming.customPrices, diff --git a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildPlanChanges.ts b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildPlanChanges.ts index ce5c0d341..980aa91d8 100644 --- a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildPlanChanges.ts +++ b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/buildPlanChanges.ts @@ -1,137 +1,12 @@ -import type { - AutumnBillingPlan, - FullCusProduct, - FullCustomerEntitlement, -} from "@autumn/shared"; -import { - getDeleteCustomerProducts, - getPatchCustomerProducts, -} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.js"; -import type { - PreviewPlanChange, - PreviewPlanItemChange, -} from "./types/index.js"; - -const customerProductToPlanChange = ({ - customerProduct, - action, - itemChanges = [], -}: { - customerProduct: FullCusProduct; - action: PreviewPlanChange["action"]; - itemChanges?: PreviewPlanItemChange[]; -}): PreviewPlanChange => ({ - action, - plan_id: customerProduct.product.id, - entity_id: customerProduct.entity_id ?? null, - item_changes: itemChanges, -}); - -const buildUpdatedPreviousAttributes = ({ - oldCustomerEntitlement, - newCustomerEntitlement, -}: { - oldCustomerEntitlement: FullCustomerEntitlement; - newCustomerEntitlement: FullCustomerEntitlement; -}): Record => { - const previous: Record = {}; - - const oldIncluded = oldCustomerEntitlement.entitlement.allowance ?? null; - const newIncluded = newCustomerEntitlement.entitlement.allowance ?? null; - if (oldIncluded !== newIncluded) previous.included = oldIncluded; - - const oldUnlimited = Boolean(oldCustomerEntitlement.unlimited); - const newUnlimited = Boolean(newCustomerEntitlement.unlimited); - if (oldUnlimited !== newUnlimited) previous.unlimited = oldUnlimited; - - return previous; -}; - -/** - * Pair up patch-level insert/delete customer_entitlements that share a - * `feature_id` and emit a single `"updated"` item_change for each pair. - * Unpaired inserts/deletes stay as their own `"created"` / `"deleted"` - * entries. When multiple cusEnts for the same feature are touched (e.g. - * monthly + lifetime), they pair in arrival order; the dashboard sees N - * `"updated"` entries for that feature. - */ -const buildPatchItemChanges = ({ - patch, -}: { - patch: NonNullable[number]; -}): PreviewPlanItemChange[] => { - const changes: PreviewPlanItemChange[] = []; - - const insertsByFeature = new Map(); - for (const insert of patch.insertCustomerEntitlements) { - const featureId = insert.entitlement.feature.id; - const existing = insertsByFeature.get(featureId) ?? []; - existing.push(insert); - insertsByFeature.set(featureId, existing); - } - - const remainingDeletes: FullCustomerEntitlement[] = []; - for (const deleted of patch.deleteCustomerEntitlements) { - const featureId = deleted.entitlement.feature.id; - const matchingInserts = insertsByFeature.get(featureId); - const paired = matchingInserts?.shift(); - if (paired) { - changes.push({ - action: "updated", - feature_id: featureId, - previous_attributes: buildUpdatedPreviousAttributes({ - oldCustomerEntitlement: deleted, - newCustomerEntitlement: paired, - }), - }); - continue; - } - remainingDeletes.push(deleted); - } - - for (const inserts of insertsByFeature.values()) { - for (const insert of inserts) { - changes.push({ - action: "created", - feature_id: insert.entitlement.feature.id, - previous_attributes: {}, - }); - } - } - - for (const deleted of remainingDeletes) { - changes.push({ - action: "deleted", - feature_id: deleted.entitlement.feature.id, - previous_attributes: {}, - }); - } - - return changes; -}; +import type { AutumnBillingPlan } from "@autumn/shared"; +import { buildPlanChanges as buildBillingUpdatedPlanChanges } from "@/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.js"; +import type { PreviewPlanChange } from "./types/index.js"; export const buildPlanChanges = ({ autumnBillingPlan, }: { autumnBillingPlan: AutumnBillingPlan; -}): PreviewPlanChange[] => [ - ...autumnBillingPlan.insertCustomerProducts.map((customerProduct) => - customerProductToPlanChange({ - customerProduct, - action: "created", - }), - ), - ...getDeleteCustomerProducts({ autumnBillingPlan }).map((customerProduct) => - customerProductToPlanChange({ - customerProduct, - action: "deleted", - }), - ), - ...getPatchCustomerProducts({ autumnBillingPlan }).map((patch) => - customerProductToPlanChange({ - customerProduct: patch.customerProduct, - action: "updated", - itemChanges: buildPatchItemChanges({ patch }), - }), - ), -]; +}): PreviewPlanChange[] => + buildBillingUpdatedPlanChanges({ + autumnBillingPlan, + }); diff --git a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewPlanChange.ts b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewPlanChange.ts index 58947c09b..96b89764e 100644 --- a/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewPlanChange.ts +++ b/server/src/internal/migrations/v2/preview/previewMigrateCustomer/types/previewPlanChange.ts @@ -1,18 +1,14 @@ -import { z } from "zod/v4"; +import { + CustomerPlanChangeSchema, + CustomerPlanItemChangeSchema, + type CustomerPlanChange, + type CustomerPlanItemChange, +} from "@autumn/shared/api/billing/common/customerPlanChange.js"; -export const PreviewPlanItemChangeSchema = z.object({ - action: z.enum(["created", "updated", "deleted"]), - feature_id: z.string(), - previous_attributes: z.record(z.string(), z.unknown()).default({}), -}); +export const PreviewPlanItemChangeSchema = CustomerPlanItemChangeSchema; -export const PreviewPlanChangeSchema = z.object({ - action: z.enum(["created", "updated", "deleted"]), - plan_id: z.string(), - entity_id: z.string().nullable().optional(), - item_changes: z.array(PreviewPlanItemChangeSchema).default([]), -}); +export const PreviewPlanChangeSchema = CustomerPlanChangeSchema; -export type PreviewPlanItemChange = z.infer; +export type PreviewPlanItemChange = CustomerPlanItemChange; -export type PreviewPlanChange = z.infer; +export type PreviewPlanChange = CustomerPlanChange; diff --git a/server/src/internal/migrations/v2/repos/deleteMigration.ts b/server/src/internal/migrations/v2/repos/deleteMigration.ts index cfdf3beb2..7e7553948 100644 --- a/server/src/internal/migrations/v2/repos/deleteMigration.ts +++ b/server/src/internal/migrations/v2/repos/deleteMigration.ts @@ -1,4 +1,11 @@ -import { type Migration, migrationItemRuns, migrations } from "@autumn/shared"; +import { + ErrCode, + type Migration, + MigrationItemKind, + migrationItemRuns, + migrations, + RecaseError, +} from "@autumn/shared"; import { and, eq } from "drizzle-orm"; import type { RepoContext } from "@/db/repoContext.js"; @@ -10,15 +17,44 @@ export const deleteMigration = async ({ ctx: RepoContext; id: string; }): Promise => { - const [row] = await ctx.db - .delete(migrations) + const [migration] = await ctx.db + .select() + .from(migrations) .where( and( eq(migrations.id, id), eq(migrations.org_id, ctx.org.id), eq(migrations.env, ctx.env), + eq(migrations.archived, false), ), ) + .limit(1); + + if (!migration) return null; + + const [customerRun] = await ctx.db + .select({ id: migrationItemRuns.migration_item_run_id }) + .from(migrationItemRuns) + .where( + and( + eq(migrationItemRuns.migration_internal_id, migration.internal_id), + eq(migrationItemRuns.item_kind, MigrationItemKind.Customer), + eq(migrationItemRuns.dry_run, false), + ), + ) + .limit(1); + + if (customerRun) { + throw new RecaseError({ + message: `Migration ${id} has customer run history and cannot be deleted. Archive it instead.`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + const [row] = await ctx.db + .delete(migrations) + .where(eq(migrations.internal_id, migration.internal_id)) .returning(); if (row) { await ctx.db diff --git a/server/src/internal/migrations/v2/repos/findMigration.ts b/server/src/internal/migrations/v2/repos/findMigration.ts index 96e02d1d5..4c3e9cbfc 100644 --- a/server/src/internal/migrations/v2/repos/findMigration.ts +++ b/server/src/internal/migrations/v2/repos/findMigration.ts @@ -24,6 +24,7 @@ export const findMigration = async ({ and( eq(m.org_id, ctx.org.id), eq(m.env, ctx.env), + eq(m.archived, false), id !== undefined ? eq(m.id, id) : eq(m.internal_id, internalId!), ), }); diff --git a/server/src/internal/migrations/v2/repos/insertMigration.ts b/server/src/internal/migrations/v2/repos/insertMigration.ts index 218070f24..17a569017 100644 --- a/server/src/internal/migrations/v2/repos/insertMigration.ts +++ b/server/src/internal/migrations/v2/repos/insertMigration.ts @@ -16,7 +16,10 @@ export const insertMigration = async ({ insert, }: { ctx: RepoContext; - insert: Pick; + insert: Pick< + MigrationInsert, + "id" | "filter" | "operations" | "no_billing_changes" + >; }): Promise => { const row: MigrationInsert = { internal_id: generateId("mig"), @@ -25,7 +28,9 @@ export const insertMigration = async ({ env: ctx.env, filter: insert.filter ?? null, operations: insert.operations ?? null, + no_billing_changes: insert.no_billing_changes ?? null, retry_failed: false, + archived: false, created_at: Date.now(), updated_at: null, }; diff --git a/server/src/internal/migrations/v2/repos/migrationItemEvents/listLatestMigrationItemEvents.ts b/server/src/internal/migrations/v2/repos/migrationItemEvents/listLatestMigrationItemEvents.ts index e926bbded..35d44cd06 100644 --- a/server/src/internal/migrations/v2/repos/migrationItemEvents/listLatestMigrationItemEvents.ts +++ b/server/src/internal/migrations/v2/repos/migrationItemEvents/listLatestMigrationItemEvents.ts @@ -3,6 +3,7 @@ import { migrationTinybird, type TinybirdMigrationItemEvent, } from "@/external/tinybird/migrations/migrationItemEventsDataSource.js"; +import { normalizeMigrationItemEventJson } from "./listMigrationItemEvents.js"; export const listLatestMigrationItemEvents = async ({ ctx, @@ -33,7 +34,9 @@ export const listLatestMigrationItemEvents = async ({ }); const latestByItem = new Map(); - for (const event of result.data as TinybirdMigrationItemEvent[]) { + for (const event of (result.data as TinybirdMigrationItemEvent[]).map( + normalizeMigrationItemEventJson, + )) { if (event.dry_run !== dryRun) continue; const key = `${event.item_kind}:${event.item_id}`; if (!latestByItem.has(key)) latestByItem.set(key, event); diff --git a/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts b/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts index 7114a49ef..8ea3aed66 100644 --- a/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts +++ b/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts @@ -5,14 +5,45 @@ import { } from "@/external/tinybird/migrations/migrationItemEventsDataSource.js"; import { findMigration } from "../findMigration.js"; +const parseJsonish = (value: unknown): unknown => { + if (typeof value !== "string") { + if (Array.isArray(value)) return value.map(parseJsonish); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, parseJsonish(entry)]), + ); + } + return value; + } + + const trimmed = value.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value; + + try { + return parseJsonish(JSON.parse(value)); + } catch { + return value; + } +}; + +export const normalizeMigrationItemEventJson = ( + event: TinybirdMigrationItemEvent, +): TinybirdMigrationItemEvent => ({ + ...event, + item_preview: parseJsonish(event.item_preview) as TinybirdMigrationItemEvent["item_preview"], + response: parseJsonish(event.response) as TinybirdMigrationItemEvent["response"], +}); + export const listMigrationItemEvents = async ({ ctx, migrationId, migrationRunId, + itemIds, }: { ctx: RepoContext; migrationId: string; migrationRunId?: string; + itemIds?: string[]; }): Promise => { if (!migrationTinybird) { ctx.logger.debug( @@ -22,6 +53,18 @@ export const listMigrationItemEvents = async ({ } const migration = await findMigration({ ctx, id: migrationId }); + + if (itemIds && itemIds.length > 0) { + return listMigrationItemEventsBySql({ + ctx, + orgId: ctx.org.id, + env: ctx.env, + migrationInternalId: migration.internal_id, + migrationRunId, + itemIds, + }); + } + const queryParams = { org_id: ctx.org.id, env: ctx.env, @@ -37,5 +80,73 @@ export const listMigrationItemEvents = async ({ `listMigrationItemEvents: got ${result.data.length} results`, ); - return result.data as TinybirdMigrationItemEvent[]; + return (result.data as TinybirdMigrationItemEvent[]).map( + normalizeMigrationItemEventJson, + ); +}; + +const escapeString = (s: string) => s.replace(/'/g, "\\'"); + +const listMigrationItemEventsBySql = async ({ + ctx, + orgId, + env, + migrationInternalId, + migrationRunId, + itemIds, +}: { + ctx: RepoContext; + orgId: string; + env: string; + migrationInternalId: string; + migrationRunId?: string; + itemIds: string[]; +}): Promise => { + const conditions = [ + `org_id = '${escapeString(orgId)}'`, + `env = '${escapeString(env)}'`, + `migration_internal_id = '${escapeString(migrationInternalId)}'`, + ]; + + if (migrationRunId) { + conditions.push( + `migration_run_id = '${escapeString(migrationRunId)}'`, + ); + } + + const idList = itemIds.map((id) => `'${escapeString(id)}'`).join(","); + conditions.push(`item_id IN (${idList})`); + + const sql = ` + SELECT + timestamp, + org_id, + env, + migration_internal_id, + migration_run_id, + dry_run, + item_kind, + item_id, + item_preview, + status, + response + FROM migration_item_events + WHERE ${conditions.join(" AND ")} + ORDER BY timestamp DESC, item_kind ASC, item_id ASC + LIMIT 1000 + FORMAT JSON + `; + + ctx.logger.info( + `listMigrationItemEventsBySql: querying ${itemIds.length} item_ids for migration=${migrationInternalId}`, + ); + + const result = await migrationTinybird!.sql(sql); + const rows = result.data ?? []; + + ctx.logger.info( + `listMigrationItemEventsBySql: got ${rows.length} results`, + ); + + return rows.map(normalizeMigrationItemEventJson); }; diff --git a/server/src/internal/migrations/v2/repos/migrationItemRun/claimMigrationItemRun.ts b/server/src/internal/migrations/v2/repos/migrationItemRun/claimMigrationItemRun.ts index c9cfdda2d..d35506d72 100644 --- a/server/src/internal/migrations/v2/repos/migrationItemRun/claimMigrationItemRun.ts +++ b/server/src/internal/migrations/v2/repos/migrationItemRun/claimMigrationItemRun.ts @@ -4,16 +4,17 @@ import { MigrationItemRunStatus, migrationItemRuns, } from "@autumn/shared"; -import { eq, sql } from "drizzle-orm"; +import { inArray, sql } from "drizzle-orm"; import type { RepoContext } from "@/db/repoContext.js"; import { generateId } from "@/utils/genUtils.js"; +import type { RetryableMigrationItemRunStatus } from "../../run/utils/retryItemStatuses.js"; import { getMigrationItemRun } from "./getMigrationItemRun.js"; type MigrationItemRunRepoContext = RepoContext & { dbGeneral?: RepoContext["db"]; }; -export type MigrationItemRunClaimBehavior = "claim_new" | "retry_failed"; +export type MigrationItemRunClaimBehavior = "claim_new" | "retry_statuses"; export type MigrationItemRunClaimResult = | { claimed: true; itemRun: MigrationItemRun } @@ -27,6 +28,7 @@ export const claimMigrationItemRun = async ({ itemKind, itemId, claimBehavior, + retryStatuses = [], }: { ctx: MigrationItemRunRepoContext; migrationInternalId: string; @@ -35,6 +37,7 @@ export const claimMigrationItemRun = async ({ itemKind: MigrationItemKind; itemId: string; claimBehavior: MigrationItemRunClaimBehavior; + retryStatuses?: RetryableMigrationItemRunStatus[]; }): Promise => { if (dryRun && !migrationRunId) throw new Error( @@ -70,29 +73,29 @@ export const claimMigrationItemRun = async ({ ? sql`${migrationItemRuns.dry_run} = true` : sql`${migrationItemRuns.dry_run} = false`; - const [claimed] = - claimBehavior === "retry_failed" - ? await db - .insert(migrationItemRuns) - .values(values) - .onConflictDoUpdate({ - target, - targetWhere, - set: { - status: MigrationItemRunStatus.Running, - updated_at: now, - }, - setWhere: eq( - migrationItemRuns.status, - MigrationItemRunStatus.Failed, - ), - }) - .returning() - : await db - .insert(migrationItemRuns) - .values(values) - .onConflictDoNothing({ target, where: targetWhere }) - .returning(); + const shouldRetry = + claimBehavior === "retry_statuses" && retryStatuses.length > 0; + + const [claimed] = shouldRetry + ? await db + .insert(migrationItemRuns) + .values(values) + .onConflictDoUpdate({ + target, + targetWhere, + set: { + migration_run_id: migrationRunId ?? null, + status: MigrationItemRunStatus.Running, + updated_at: now, + }, + setWhere: inArray(migrationItemRuns.status, retryStatuses), + }) + .returning() + : await db + .insert(migrationItemRuns) + .values(values) + .onConflictDoNothing({ target, where: targetWhere }) + .returning(); if (claimed) return { claimed: true, itemRun: claimed }; diff --git a/server/src/internal/migrations/v2/repos/migrationItemRun/index.ts b/server/src/internal/migrations/v2/repos/migrationItemRun/index.ts index acf1d30bf..8cddbb22c 100644 --- a/server/src/internal/migrations/v2/repos/migrationItemRun/index.ts +++ b/server/src/internal/migrations/v2/repos/migrationItemRun/index.ts @@ -3,6 +3,11 @@ import { getCustomerMigrationItemRun, getMigrationItemRun, } from "./getMigrationItemRun.js"; +import { + getMigrationItemRunCounts, + listMigrationItemRunCountsByRun, +} from "./listMigrationItemRunCountsByRun.js"; +import { listMigrationItemRunsForItems } from "./listMigrationItemRunsForItems.js"; import { markMigrationItemRunFailed, markMigrationItemRunSkipped, @@ -13,9 +18,16 @@ export const migrationItemRunRepo = { claim: claimMigrationItemRun, get: getMigrationItemRun, getCustomer: getCustomerMigrationItemRun, + getCounts: getMigrationItemRunCounts, + listCountsByRun: listMigrationItemRunCountsByRun, + listForItems: listMigrationItemRunsForItems, markSucceeded: markMigrationItemRunSucceeded, markSkipped: markMigrationItemRunSkipped, markFailed: markMigrationItemRunFailed, }; export type { MigrationItemRunClaimBehavior } from "./claimMigrationItemRun.js"; +export type { + MigrationItemRunCounts, + MigrationItemRunCountsByRun, +} from "./listMigrationItemRunCountsByRun.js"; diff --git a/server/src/internal/migrations/v2/repos/migrationItemRun/listMigrationItemRunCountsByRun.ts b/server/src/internal/migrations/v2/repos/migrationItemRun/listMigrationItemRunCountsByRun.ts new file mode 100644 index 000000000..8aa8fbfca --- /dev/null +++ b/server/src/internal/migrations/v2/repos/migrationItemRun/listMigrationItemRunCountsByRun.ts @@ -0,0 +1,94 @@ +import { + MigrationItemKind, + MigrationItemRunStatus, + migrationItemRuns, +} from "@autumn/shared"; +import { and, eq, inArray, type SQL, sql } from "drizzle-orm"; +import type { RepoContext } from "@/db/repoContext.js"; + +export type MigrationItemRunCounts = { + total: number; + running: number; + succeeded: number; + skipped: number; + failed: number; +}; + +export type MigrationItemRunCountsByRun = MigrationItemRunCounts & { + migration_run_id: string | null; +}; + +const countSelection = { + total: sql`count(*)::int`, + running: sql`count(*) filter (where ${migrationItemRuns.status} = ${MigrationItemRunStatus.Running})::int`, + succeeded: sql`count(*) filter (where ${migrationItemRuns.status} = ${MigrationItemRunStatus.Succeeded})::int`, + skipped: sql`count(*) filter (where ${migrationItemRuns.status} = ${MigrationItemRunStatus.Skipped})::int`, + failed: sql`count(*) filter (where ${migrationItemRuns.status} = ${MigrationItemRunStatus.Failed})::int`, +}; + +const emptyCounts: MigrationItemRunCounts = { + total: 0, + running: 0, + succeeded: 0, + skipped: 0, + failed: 0, +}; + +export const listMigrationItemRunCountsByRun = async ({ + ctx, + migrationInternalId, + migrationRunIds, + itemKind = MigrationItemKind.Customer, +}: { + ctx: RepoContext; + migrationInternalId: string; + migrationRunIds: string[]; + itemKind?: MigrationItemKind; +}): Promise => { + if (migrationRunIds.length === 0) return []; + + return ctx.db + .select({ + migration_run_id: migrationItemRuns.migration_run_id, + ...countSelection, + }) + .from(migrationItemRuns) + .where( + and( + eq(migrationItemRuns.migration_internal_id, migrationInternalId), + eq(migrationItemRuns.item_kind, itemKind), + inArray(migrationItemRuns.migration_run_id, migrationRunIds), + ), + ) + .groupBy(migrationItemRuns.migration_run_id); +}; + +export const getMigrationItemRunCounts = async ({ + ctx, + migrationInternalId, + itemKind = MigrationItemKind.Customer, + dryRun, + migrationRunId, +}: { + ctx: RepoContext; + migrationInternalId: string; + itemKind?: MigrationItemKind; + dryRun?: boolean; + migrationRunId?: string; +}): Promise => { + const where: SQL[] = [ + eq(migrationItemRuns.migration_internal_id, migrationInternalId), + eq(migrationItemRuns.item_kind, itemKind), + ]; + + if (dryRun !== undefined) where.push(eq(migrationItemRuns.dry_run, dryRun)); + if (migrationRunId !== undefined) + where.push(eq(migrationItemRuns.migration_run_id, migrationRunId)); + + const [counts] = await ctx.db + .select(countSelection) + .from(migrationItemRuns) + .where(and(...where)); + + return counts ?? emptyCounts; +}; diff --git a/server/src/internal/migrations/v2/repos/migrationItemRun/listMigrationItemRunsForItems.ts b/server/src/internal/migrations/v2/repos/migrationItemRun/listMigrationItemRunsForItems.ts new file mode 100644 index 000000000..31841bc27 --- /dev/null +++ b/server/src/internal/migrations/v2/repos/migrationItemRun/listMigrationItemRunsForItems.ts @@ -0,0 +1,37 @@ +import { + type MigrationItemKind, + type MigrationItemRun, + migrationItemRuns, +} from "@autumn/shared"; +import { and, eq, inArray } from "drizzle-orm"; +import type { RepoContext } from "@/db/repoContext.js"; + +export const listMigrationItemRunsForItems = async ({ + ctx, + migrationInternalId, + itemKind, + itemIds, + dryRun, +}: { + ctx: RepoContext; + migrationInternalId: string; + itemKind: MigrationItemKind; + itemIds: string[]; + dryRun?: boolean; +}): Promise => { + if (itemIds.length === 0) return []; + + return ctx.db + .select() + .from(migrationItemRuns) + .where( + and( + eq(migrationItemRuns.migration_internal_id, migrationInternalId), + eq(migrationItemRuns.item_kind, itemKind), + inArray(migrationItemRuns.item_id, itemIds), + ...(dryRun === undefined + ? [] + : [eq(migrationItemRuns.dry_run, dryRun)]), + ), + ); +}; diff --git a/server/src/internal/migrations/v2/repos/updateMigration.ts b/server/src/internal/migrations/v2/repos/updateMigration.ts index 88c3f38e2..c68d80178 100644 --- a/server/src/internal/migrations/v2/repos/updateMigration.ts +++ b/server/src/internal/migrations/v2/repos/updateMigration.ts @@ -22,20 +22,31 @@ export const updateMigration = async ({ updates: Partial< Pick< MigrationInsert, - "id" | "filter" | "operations" | "prepared_state" | "retry_failed" + | "id" + | "filter" + | "operations" + | "prepared_state" + | "retry_failed" + | "no_billing_changes" + | "archived" > >; }): Promise => { + const where = [ + eq(migrations.id, id), + eq(migrations.org_id, ctx.org.id), + eq(migrations.env, ctx.env), + ]; + + // Only restrict to non-archived rows when we're NOT toggling the archive flag + if (updates.archived === undefined) { + where.push(eq(migrations.archived, false)); + } + const [row] = await ctx.db .update(migrations) .set({ ...updates, updated_at: Date.now() }) - .where( - and( - eq(migrations.id, id), - eq(migrations.org_id, ctx.org.id), - eq(migrations.env, ctx.env), - ), - ) + .where(and(...where)) .returning(); return row ?? null; diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/evaluateMigrateCustomerStripe.ts b/server/src/internal/migrations/v2/run/migrateCustomer/evaluateMigrateCustomerStripe.ts index a1c790134..7d4d942bd 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/evaluateMigrateCustomerStripe.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/evaluateMigrateCustomerStripe.ts @@ -6,10 +6,7 @@ import type { } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.js"; -import { - assertStripePlanNoCharges, - hasStripePlanActions, -} from "@/internal/billing/v2/providers/stripe/errors/assertStripePlanNoCharges.js"; +import { assertStripePlanNoCharges } from "@/internal/billing/v2/providers/stripe/errors/assertStripePlanNoCharges.js"; import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js"; import { MigrationOperationError } from "@/internal/migrations/v2/operations/errors/index.js"; import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js"; @@ -61,20 +58,22 @@ export const evaluateMigrateCustomerStripe = async ({ billingContexts: UpdateSubscriptionBillingContext[]; autumnBillingPlan: AutumnBillingPlan; }): Promise => { + if (context.migration.no_billing_changes === true) { + return { + autumn: autumnBillingPlan, + stripe: {}, + stripeBillingPlans: [], + }; + } + const stripeBillingPlans: MigrateCustomerStripeBillingPlan[] = []; for (const [subscriptionId, billingContext] of contextBySubscriptionId({ billingContexts, })) { - const shouldValidateForcedNoBillingChanges = - context.migration.no_billing_changes === true; - const evaluationContext = shouldValidateForcedNoBillingChanges - ? { ...billingContext, skipBillingChanges: false } - : billingContext; - const stripeBillingPlan = await evaluateStripeBillingPlan({ ctx, - billingContext: evaluationContext, + billingContext, autumnBillingPlan, }); appendMigrationBillingLog({ @@ -84,7 +83,7 @@ export const evaluateMigrateCustomerStripe = async ({ logStripeBillingPlan({ ctx: logCtx, stripeBillingPlan, - billingContext: evaluationContext, + billingContext, }), }); @@ -101,20 +100,6 @@ export const evaluateMigrateCustomerStripe = async ({ }), }); - if ( - shouldValidateForcedNoBillingChanges && - hasStripePlanActions(stripeBillingPlan) - ) { - throw new MigrationOperationError({ - code: "unsupported_operation_input", - operationType: "update_plan", - field: "no_billing_changes", - message: - "Migration no_billing_changes=true was set, but update_plan produced Stripe mutations", - details: { subscriptionId }, - }); - } - stripeBillingPlans.push({ subscriptionId, billingContext, diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/executeMigrateCustomerPlan.ts b/server/src/internal/migrations/v2/run/migrateCustomer/executeMigrateCustomerPlan.ts index ba111469d..ab920c0b5 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/executeMigrateCustomerPlan.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/executeMigrateCustomerPlan.ts @@ -1,7 +1,10 @@ +import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js"; import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.js"; import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult.js"; +import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook.js"; +import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.js"; import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js"; import { appendMigrationBillingLog } from "@/internal/migrations/v2/operations/utils/index.js"; @@ -11,10 +14,12 @@ export const executeMigrateCustomerPlan = async ({ ctx, context, billingPlan, + billingContexts, }: { ctx: AutumnContext; context: MigrateCustomerContext; billingPlan: MigrateCustomerBillingPlan; + billingContexts: UpdateSubscriptionBillingContext[]; }): Promise => { for (const stripeBillingPlan of billingPlan.stripeBillingPlans) { const stripeResult = await executeStripeBillingPlan({ @@ -38,6 +43,21 @@ export const executeMigrateCustomerPlan = async ({ autumnBillingPlan: billingPlan.autumn, }); + const primaryBillingContext = billingContexts[0]; + if (primaryBillingContext) { + await billingPlanToSendProductsUpdated({ + ctx, + autumnBillingPlan: billingPlan.autumn, + billingContext: primaryBillingContext, + }); + } + + await sendBillingUpdatedWebhook({ + ctx, + autumnBillingPlan: billingPlan.autumn, + originalFullCustomer: context.fullCustomer, + }); + const customerId = context.fullCustomer.id ?? context.fullCustomer.internal_id; await deleteCachedFullCustomer({ diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts b/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts index 76e43d3bb..7f9267cd2 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts @@ -83,6 +83,7 @@ export const migrateCustomer = async ({ ctx: migrationCtx, context, billingPlan, + billingContexts, }); } diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/setup/setupMigrationOperationBillingContext.ts b/server/src/internal/migrations/v2/run/migrateCustomer/setup/setupMigrationOperationBillingContext.ts index 80755c8d6..43b1a3f19 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/setup/setupMigrationOperationBillingContext.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/setup/setupMigrationOperationBillingContext.ts @@ -10,6 +10,7 @@ import { import type Stripe from "stripe"; import type { StripeSubscriptionWithDiscounts } from "@/external/stripe/subscriptions/index.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { setupUpdateSubscriptionTrialContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext.js"; import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor.js"; import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor.js"; import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js"; @@ -71,12 +72,26 @@ export const setupMigrationOperationBillingContext = async ({ stripeCustomerContext.testClockFrozenTime ?? Date.now(); const resolvedFullProduct = fullProduct ?? cusProductToProduct({ cusProduct: customerProduct }); - const billingCycleAnchorMs = setupBillingCycleAnchor({ + const trialContext = setupUpdateSubscriptionTrialContext({ + stripeSubscription, + customerProduct, + currentEpochMs, + params: {}, + fullProduct: resolvedFullProduct, + }); + + let billingCycleAnchorMs = setupBillingCycleAnchor({ stripeSubscription, customerProduct, newFullProduct: resolvedFullProduct, + trialContext, currentEpochMs, }); + + if (trialContext?.trialEndsAt) { + billingCycleAnchorMs = trialContext.trialEndsAt; + } + const resetCycleAnchorMs = setupResetCycleAnchor({ billingCycleAnchorMs, customerProduct, @@ -92,6 +107,7 @@ export const setupMigrationOperationBillingContext = async ({ currentEpochMs, billingCycleAnchorMs, resetCycleAnchorMs, + trialContext, stripeCustomer: stripeCustomerContext.stripeCustomer, stripeSubscription, stripeSubscriptionSchedule, diff --git a/server/src/internal/migrations/v2/run/orchestrators/runScopeIteration.ts b/server/src/internal/migrations/v2/run/orchestrators/runScopeIteration.ts index 29a93a552..3f27febab 100644 --- a/server/src/internal/migrations/v2/run/orchestrators/runScopeIteration.ts +++ b/server/src/internal/migrations/v2/run/orchestrators/runScopeIteration.ts @@ -13,6 +13,7 @@ import { } from "../../types/migrationDefinition.js"; import { migrateCustomer } from "../migrateCustomer/index.js"; import type { RunScopeItem, RunScopeKind } from "../types/runScope.js"; +import { isMigrationCancelRequested } from "../utils/migrationCancelToken.js"; import { iterateScope } from "./iterateScope.js"; /** Runs one filtered migration scope iteration. */ @@ -50,6 +51,10 @@ export const runScopeIteration = async ({ controls?.checkpoint !== false && (!dryRun || controls?.checkpointDryRun === true); + // In-memory latch so we hit Redis only until the first cancel detection; + // every later item short-circuits without a cache roundtrip. + let cancelRequested = false; + const perItem = async ({ item, itemCtx, @@ -62,6 +67,19 @@ export const runScopeIteration = async ({ `runMigration: per-item handler missing for kind "${item.kind}"`, ); + if (!cancelRequested && (await isMigrationCancelRequested({ migrationRunId }))) + cancelRequested = true; + if (cancelRequested) { + itemCtx.logger.info("run-migration: skipping item, cancel requested", { + data: { + migrationRunId, + customerId: item.id ?? item.internal_id, + internalId: item.internal_id, + }, + }); + return undefined; + } + itemCtx.logger.info("run-migration: processing customer", { data: { migrationRunId, @@ -89,7 +107,7 @@ export const runScopeIteration = async ({ item, dryRun, claimItemRun: checkpointReadEnabled, - retryFailed: migration.retry_failed === true, + retryItemStatuses: controls?.retryItemStatuses, run, }); }; diff --git a/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts b/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts index 1776d74ca..bff079644 100644 --- a/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts +++ b/server/src/internal/migrations/v2/run/preProcess/preProcessMigration.ts @@ -17,7 +17,10 @@ export const preProcessMigration = ( migration: M, ): M => { const operations = migration.operations - ? preProcessMigrationOperations({ operations: migration.operations }) + ? preProcessMigrationOperations({ + operations: migration.operations, + filter: migration.filter, + }) : migration.operations; const filter = preProcessMigrationFilter({ operations: operations ?? undefined, diff --git a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts index 8ffc8df6c..ead6b4e99 100644 --- a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts +++ b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts @@ -40,7 +40,7 @@ export const preProcessMigrationFilter = ({ if (!filter.customer) return filter; const planRule = filter.customer.plan; - if (planRule === undefined || planRule === "$none") return filter; + if (planRule === undefined) return filter; const nextPlan: PlanFilter | PlanQuantifier = isQuantifierObject(planRule) ? { diff --git a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts index 530e867e2..9a12114e6 100644 --- a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts +++ b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationOperations.ts @@ -2,8 +2,42 @@ import type { CustomerOperation, CustomerOperations, } from "@autumn/shared/api/migrations/operations/customer/customerOperations.js"; +import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import type { PlanFilter } from "@autumn/shared/api/migrations/filters/planFilter.js"; import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; +type PlanQuantifier = { + $some?: PlanFilter; + $every?: PlanFilter; + $none?: PlanFilter; +}; + +const isPlanQuantifier = ( + plan: PlanFilter | PlanQuantifier, +): plan is PlanQuantifier => + "$some" in plan || "$every" in plan || "$none" in plan; + +const planFilterTargetsCustom = (plan: PlanFilter): boolean => + plan.custom === true || (plan.$or ?? []).some(planFilterTargetsCustom); + +const planTargetsCustom = (plan: PlanFilter | PlanQuantifier): boolean => { + if (isPlanQuantifier(plan)) { + return [plan.$some, plan.$every, plan.$none].some((inner) => { + if (inner === undefined) return false; + return planFilterTargetsCustom(inner); + }); + } + + return planFilterTargetsCustom(plan); +}; + +const filterTargetsCustom = (filter: MigrationFilter | null | undefined) => { + const customer = filter?.customer; + if (customer?.customer_id) return true; + if (customer?.plan === undefined) return false; + return planTargetsCustom(customer.plan); +}; + /** * Op-level guard. Any `update_plan` op that bumps `version` automatically * gets `plan_filter.custom: false` so admin-customized customer_products @@ -15,24 +49,37 @@ import type { Operations } from "@autumn/shared/api/migrations/operations/operat */ export const preProcessMigrationOperations = ({ operations, + filter, }: { operations: Operations; + filter?: MigrationFilter | null; }): Operations => { - if (!operations.customer) return operations; + if (operations.customer === undefined) return operations; + + const targetsCustom = filterTargetsCustom(filter); const customerOps: CustomerOperations = operations.customer.map( (op): CustomerOperation => { - if (op.type !== "update_plan") return op; - if (op.version === undefined) return op; - if (op.plan_filter.custom !== undefined) return op; + if (op.type === "update_plan") { + if (op.version === undefined) return op; + if ( + op.plan_filter.custom === true || + op.plan_filter.custom === false + ) { + return op; + } + if (targetsCustom) return op; - return { - ...op, - plan_filter: { - ...op.plan_filter, - custom: false, - }, - }; + return { + ...op, + plan_filter: { + ...op.plan_filter, + custom: false, + }, + }; + } + + return op; }, ); diff --git a/server/src/internal/migrations/v2/run/utils/migrationCancelToken.ts b/server/src/internal/migrations/v2/run/utils/migrationCancelToken.ts new file mode 100644 index 000000000..3c31ea8ea --- /dev/null +++ b/server/src/internal/migrations/v2/run/utils/migrationCancelToken.ts @@ -0,0 +1,37 @@ +import { CacheManager } from "@/utils/cacheUtils/CacheManager.js"; + +/** "Cancellation requested" signal for a migration run. Set by the cancel + * handler; read by the batch per-item gate and the lazy enqueue/task gates so + * in-flight work finishes while no new items start. Best-effort: a degraded + * cache makes the gate a no-op. */ +const TOKEN_TTL_SECONDS = 3600; + +const cancelTokenKey = (migrationRunId: string) => + `migration_run_cancel:${migrationRunId}`; + +export const setMigrationCancelRequested = async ({ + migrationRunId, +}: { + migrationRunId: string; +}): Promise => { + await CacheManager.setJson(cancelTokenKey(migrationRunId), true, TOKEN_TTL_SECONDS); +}; + +export const isMigrationCancelRequested = async ({ + migrationRunId, +}: { + migrationRunId: string; +}): Promise => { + const value = await CacheManager.getJson( + cancelTokenKey(migrationRunId), + ); + return value === true; +}; + +export const clearMigrationCancelRequested = async ({ + migrationRunId, +}: { + migrationRunId: string; +}): Promise => { + await CacheManager.del(cancelTokenKey(migrationRunId)); +}; diff --git a/server/src/internal/migrations/v2/run/utils/retryItemStatuses.ts b/server/src/internal/migrations/v2/run/utils/retryItemStatuses.ts new file mode 100644 index 000000000..047b2c6d0 --- /dev/null +++ b/server/src/internal/migrations/v2/run/utils/retryItemStatuses.ts @@ -0,0 +1,27 @@ +import { + MigrationItemRunStatus, + type MigrationItemRunStatus as MigrationItemRunStatusType, +} from "@autumn/shared"; + +export const RETRYABLE_MIGRATION_ITEM_RUN_STATUSES = [ + MigrationItemRunStatus.Failed, + MigrationItemRunStatus.Skipped, +] as const; + +export type RetryableMigrationItemRunStatus = + (typeof RETRYABLE_MIGRATION_ITEM_RUN_STATUSES)[number]; + +export const normalizeRetryItemStatuses = ({ + retryItemStatuses, +}: { + retryItemStatuses?: RetryableMigrationItemRunStatus[]; +}): RetryableMigrationItemRunStatus[] => { + const statuses = new Set(retryItemStatuses ?? []); + return [...statuses]; +}; + +export const isRetryableMigrationItemRunStatus = ( + status: MigrationItemRunStatusType, +): status is RetryableMigrationItemRunStatus => + status === MigrationItemRunStatus.Failed || + status === MigrationItemRunStatus.Skipped; diff --git a/server/src/internal/misc/consent/handlers/handleGetConsentApiKeys.ts b/server/src/internal/misc/consent/handlers/handleGetConsentApiKeys.ts index 0a43f4a99..35717703b 100644 --- a/server/src/internal/misc/consent/handlers/handleGetConsentApiKeys.ts +++ b/server/src/internal/misc/consent/handlers/handleGetConsentApiKeys.ts @@ -1,7 +1,10 @@ -import { apiKeys, oauthConsent, Scopes } from "@autumn/shared"; -import { eq, sql } from "drizzle-orm"; +import { Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { + oauthApiKeyRepo, + oauthConsentRepo, +} from "@/internal/auth/repos/index.js"; /** * Get API keys linked to a specific OAuth consent. @@ -26,35 +29,28 @@ export const handleGetConsentApiKeys = createRoute({ return c.json({ error: "No organization found" }, 400); } - // First verify the consent belongs to this org - const consentRecords = await db - .select({ id: oauthConsent.id, referenceId: oauthConsent.referenceId }) - .from(oauthConsent) - .where(eq(oauthConsent.id, consent_id)) - .limit(1); + const consent = await oauthConsentRepo.getOwner({ + db, + consentId: consent_id, + }); - if (consentRecords.length === 0) { + if (!consent) { return c.json({ error: "Consent not found" }, 404); } - if (consentRecords[0].referenceId !== org.id) { + if (consent.referenceId !== org.id) { return c.json( { error: "Consent does not belong to this organization" }, 403, ); } - // Query API keys where meta->>'oauth_consent_id' = consent_id - // Only return prefix, env, name - NOT the hashed key - const keys = await db - .select({ - id: apiKeys.id, - prefix: apiKeys.prefix, - env: apiKeys.env, - name: apiKeys.name, + const keys = ( + await oauthApiKeyRepo.listByConsentId({ + db, + consentId: consent_id, }) - .from(apiKeys) - .where(sql`${apiKeys.meta}->>'oauth_consent_id' = ${consent_id}`); + ).map(({ hashed_key: _hashedKey, ...key }) => key); return c.json({ apiKeys: keys }); }, diff --git a/server/src/internal/misc/consent/handlers/handleGetOrgConsents.ts b/server/src/internal/misc/consent/handlers/handleGetOrgConsents.ts index 74ca921df..6d6735166 100644 --- a/server/src/internal/misc/consent/handlers/handleGetOrgConsents.ts +++ b/server/src/internal/misc/consent/handlers/handleGetOrgConsents.ts @@ -1,6 +1,6 @@ -import { ErrCode, oauthConsent, RecaseError, Scopes } from "@autumn/shared"; -import { eq } from "drizzle-orm"; +import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { oauthConsentRepo } from "@/internal/auth/repos/index.js"; /** * Get OAuth consents for the current organization. @@ -16,7 +16,7 @@ export const handleGetOrgConsents = createRoute({ scopes: [Scopes.Organisation.Read], handler: async (c) => { const ctx = c.get("ctx"); - const { db, org } = ctx; + const { db, env, org } = ctx; if (!org?.id) { throw new RecaseError({ @@ -26,19 +26,11 @@ export const handleGetOrgConsents = createRoute({ }); } - // Query consents where referenceId matches the current org - const consents = await db - .select({ - id: oauthConsent.id, - clientId: oauthConsent.clientId, - userId: oauthConsent.userId, - referenceId: oauthConsent.referenceId, - scopes: oauthConsent.scopes, - createdAt: oauthConsent.createdAt, - updatedAt: oauthConsent.updatedAt, - }) - .from(oauthConsent) - .where(eq(oauthConsent.referenceId, org.id)); + const consents = await oauthConsentRepo.listByReferenceId({ + db, + env, + referenceId: org.id, + }); return c.json({ consents }); }, diff --git a/server/src/internal/misc/consent/handlers/handleRevokeConsent.ts b/server/src/internal/misc/consent/handlers/handleRevokeConsent.ts index ea06ab7aa..27c0470ea 100644 --- a/server/src/internal/misc/consent/handlers/handleRevokeConsent.ts +++ b/server/src/internal/misc/consent/handlers/handleRevokeConsent.ts @@ -1,15 +1,12 @@ -import { - apiKeys, - ErrCode, - oauthAccessToken, - oauthConsent, - oauthRefreshToken, - RecaseError, - Scopes, -} from "@autumn/shared"; -import { and, eq, sql } from "drizzle-orm"; +import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { + oauthAccessTokenRepo, + oauthApiKeyRepo, + oauthConsentRepo, + oauthRefreshTokenRepo, +} from "@/internal/auth/repos/index.js"; import { clearSecretKeyCache } from "../../../dev/api-keys/cacheApiKeyUtils.js"; /** @@ -42,18 +39,11 @@ export const handleRevokeConsent = createRoute({ }); } - // 1. Get the consent and verify it belongs to this org - const consentRecords = await db - .select({ - id: oauthConsent.id, - clientId: oauthConsent.clientId, - referenceId: oauthConsent.referenceId, - }) - .from(oauthConsent) - .where(eq(oauthConsent.id, consent_id)) - .limit(1); - - if (consentRecords.length === 0) { + const consent = await oauthConsentRepo.getOwner({ + db, + consentId: consent_id, + }); + if (!consent) { throw new RecaseError({ message: "Consent not found", code: "not_found", @@ -61,8 +51,6 @@ export const handleRevokeConsent = createRoute({ }); } - const consent = consentRecords[0]; - if (consent.referenceId !== org.id) { throw new RecaseError({ message: "Consent does not belong to this organization", @@ -73,51 +61,38 @@ export const handleRevokeConsent = createRoute({ const { clientId, referenceId } = consent; - // 2. Get API keys linked to this consent (for cache invalidation and response) - const linkedKeys = await db - .select({ - id: apiKeys.id, - prefix: apiKeys.prefix, - hashed_key: apiKeys.hashed_key, - }) - .from(apiKeys) - .where(sql`${apiKeys.meta}->>'oauth_consent_id' = ${consent_id}`); + const linkedKeys = await oauthApiKeyRepo.listByConsentId({ + db, + consentId: consent_id, + }); const deletedKeyPrefixes = linkedKeys.map((k) => k.prefix).filter(Boolean); // 3. Delete API keys and invalidate their cache for (const key of linkedKeys) { - // Delete from database - await db.delete(apiKeys).where(eq(apiKeys.id, key.id)); + await oauthApiKeyRepo.deleteById({ db, apiKeyId: key.id }); - // Invalidate cache if (key.hashed_key) { await clearSecretKeyCache({ hashedKey: key.hashed_key }); } } // 4. Delete access tokens for this client + org - await db - .delete(oauthAccessToken) - .where( - and( - eq(oauthAccessToken.clientId, clientId), - eq(oauthAccessToken.referenceId, referenceId), - ), - ); + await oauthAccessTokenRepo.deleteByClientAndReference({ + db, + clientId, + referenceId, + }); // 5. Delete refresh tokens for this client + org - await db - .delete(oauthRefreshToken) - .where( - and( - eq(oauthRefreshToken.clientId, clientId), - eq(oauthRefreshToken.referenceId, referenceId), - ), - ); + await oauthRefreshTokenRepo.deleteByClientAndReference({ + db, + clientId, + referenceId, + }); // 6. Delete the consent - await db.delete(oauthConsent).where(eq(oauthConsent.id, consent_id)); + await oauthConsentRepo.deleteById({ db, consentId: consent_id }); return c.json({ success: true, diff --git a/server/src/internal/misc/idempotency/checkIdempotencyKey.ts b/server/src/internal/misc/idempotency/checkIdempotencyKey.ts index f1fe3193d..01c5f58e6 100644 --- a/server/src/internal/misc/idempotency/checkIdempotencyKey.ts +++ b/server/src/internal/misc/idempotency/checkIdempotencyKey.ts @@ -10,11 +10,22 @@ const hashIdempotencyKey = (key: string): string => { return hasher.digest("base64url"); }; -/** - * Checks and sets an idempotency key in Redis using atomic SET NX operation. - * If Redis is not ready, allows the request to proceed (fail-open). - * Throws if the key already exists (duplicate request). - */ +const buildRedisIdempotencyKey = ({ + orgId, + env, + idempotencyKey, +}: { + orgId: string; + env: string; + idempotencyKey: string; +}) => { + const hashedKey = hashIdempotencyKey(idempotencyKey); + return { + hashedKey, + redisKey: `${orgId}:${env}:idempotency:${hashedKey}`, + }; +}; + export const checkIdempotencyKey = async ({ orgId, env, @@ -31,8 +42,11 @@ export const checkIdempotencyKey = async ({ return; } - const hashedKey = hashIdempotencyKey(idempotencyKey); - const redisKey = `${orgId}:${env}:idempotency:${hashedKey}`; + const { hashedKey, redisKey } = buildRedisIdempotencyKey({ + orgId, + env, + idempotencyKey, + }); try { // Use SET NX (set if not exists) for atomic check-and-set to prevent race conditions @@ -64,3 +78,29 @@ export const checkIdempotencyKey = async ({ return; } }; + +export const releaseIdempotencyKey = async ({ + orgId, + env, + idempotencyKey, +}: { + orgId: string; + env: string; + idempotencyKey: string; +}): Promise => { + if (redis.status !== "ready") { + return; + } + + const { redisKey } = buildRedisIdempotencyKey({ + orgId, + env, + idempotencyKey, + }); + + try { + await redis.del(redisKey); + } catch { + return; + } +}; diff --git a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts index 77b636943..52a5f9cf1 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts @@ -12,6 +12,7 @@ export enum RateLimitType { Attach = "attach", ListCustomers = "list_customers", CustomerEntitiesGet = "customer_entities_get", + Logs = "logs", } type RoutePattern = { @@ -110,6 +111,13 @@ const RATE_LIMIT_ROUTE_GROUPS: RateLimitRouteGroup[] = [ type: RateLimitType.CustomerEntitiesGet, patterns: [route({ method: "POST", url: "/v1/entities.get" })], }, + { + type: RateLimitType.Logs, + patterns: [ + route({ method: "POST", url: "/v1/logs.search" }), + route({ method: "POST", url: "/v1/logs.query" }), + ], + }, ]; export const getRateLimitType = (c: Context) => { @@ -240,4 +248,11 @@ export const RATE_LIMIT_CONFIGS: Record = { notInRedis: false, scope: RateLimitScope.Customer, }, + [RateLimitType.Logs]: { + name: "logs", + limit: 10, + windowMs: 1000, + notInRedis: false, + scope: RateLimitScope.Org, + }, }; diff --git a/server/src/internal/misc/rateLimiter/rateLimitFactory.ts b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts index acf851453..7ae7fe61a 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitFactory.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts @@ -1,9 +1,8 @@ import type { ApiVersion } from "@autumn/shared"; -import { RedisStore } from "@hono-rate-limiter/redis"; import type { Context } from "hono"; import { rateLimiter } from "hono-rate-limiter"; import { logger } from "@/external/logtail/logtailUtils.js"; -import { redis, shouldUseRedis } from "@/external/redis/initRedis"; +import { shouldUseRedis } from "@/external/redis/initRedis"; import type { HonoEnv } from "@/honoUtils/HonoEnv"; import { RATE_LIMIT_CONFIGS, @@ -14,6 +13,7 @@ import { } from "./rateLimitConfigs"; import { getOrgRateLimitOverride } from "./rateLimitOverridesStore"; import { isCustomerInRedisAllowlist } from "./rateLimitRedisAllowlistStore"; +import { createRateLimitRedisStore } from "./rateLimitRedisStore"; // Helper to get rate limit key from context const getRateLimitKeyFromContext = (c: Context): string => { @@ -78,26 +78,7 @@ export const rateLimitFactory = ({ const getRedisLimiter = () => { redisLimiter ??= rateLimiter({ ...options, - store: new RedisStore({ - client: { - scriptLoad: (script: string) => - redis.script("LOAD", script) as Promise, - evalsha: ( - sha: string, - keys: string[], - args: TArgs, - ): Promise => { - return redis.evalsha( - sha, - keys.length, - ...keys, - ...(args as (string | number | Buffer)[]), - ) as Promise; - }, - decr: (key: string) => redis.decr(key), - del: (key: string) => redis.del(key), - }, - }), + store: createRateLimitRedisStore(), }); return redisLimiter; diff --git a/server/src/internal/misc/rateLimiter/rateLimitRedisStore.ts b/server/src/internal/misc/rateLimiter/rateLimitRedisStore.ts new file mode 100644 index 000000000..41ee97ecf --- /dev/null +++ b/server/src/internal/misc/rateLimiter/rateLimitRedisStore.ts @@ -0,0 +1,24 @@ +import { RedisStore } from "@hono-rate-limiter/redis"; +import type { Env } from "hono"; +import { redis } from "@/external/redis/initRedis.js"; + +export const createRateLimitRedisStore = () => + new RedisStore({ + client: { + scriptLoad: (script: string) => + redis.script("LOAD", script) as Promise, + evalsha: ( + sha: string, + keys: string[], + args: TArgs, + ): Promise => + redis.evalsha( + sha, + keys.length, + ...keys, + ...(args as (string | number | Buffer)[]), + ) as Promise, + decr: (key: string) => redis.decr(key), + del: (key: string) => redis.del(key), + }, + }); diff --git a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts index c00c9ac40..f7d12c1fe 100644 --- a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts +++ b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts @@ -6,6 +6,13 @@ import { UpsertRevenueCatProcessorConfigSchema, Scopes, } from "@autumn/shared"; +import { getRevenuecatAccessToken } from "@server/external/revenueCat/misc/getRevenuecatAccessToken.js"; +import { + generateRevenuecatWebhookSecret, + getRevenuecatWebhookSecret, +} from "@server/external/revenueCat/misc/getRevenuecatWebhookSecret.js"; +import { initRevenuecatCli } from "@server/external/revenueCat/misc/initRevenuecatCli.js"; +import { registerRevenuecatWebhook } from "@server/external/revenueCat/misc/registerRevenuecatWebhook.js"; import { createSvixApp } from "@server/external/svix/svixHelpers.js"; import { createSvixCli } from "@server/external/svix/svixUtils.js"; import { createRoute } from "@server/honoMiddlewares/routeHandler.js"; @@ -14,17 +21,7 @@ import { mask } from "@server/utils/genUtils.js"; import type { ApplicationOut } from "svix"; import { OrgService } from "../OrgService.js"; -// Generate a random 64-character alphanumeric string -const generateWebhookSecret = (): string => { - const chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; - const randomBytes = crypto.getRandomValues(new Uint8Array(64)); - for (let i = 0; i < 64; i++) { - result += chars[randomBytes[i] % chars.length]; - } - return result; -}; +const generateWebhookSecret = generateRevenuecatWebhookSecret; export const getRevenueCatConfigDisplay = ({ org, @@ -37,6 +34,8 @@ export const getRevenueCatConfigDisplay = ({ if (!revenueCatConfig) { return { connected: false, + connection: "none" as const, + oauth_connected: false, api_key: undefined, sandbox_api_key: undefined, project_id: undefined, @@ -61,12 +60,28 @@ export const getRevenueCatConfigDisplay = ({ const apiKeyForEnv = env === AppEnv.Live ? liveApiKeyDecrypted : sandboxApiKeyDecrypted; + const oauthForEnv = + env === AppEnv.Live + ? revenueCatConfig.oauth + : revenueCatConfig.sandbox_oauth; + + const oauthConnected = !!oauthForEnv; + const connection = oauthConnected + ? ("oauth" as const) + : apiKeyForEnv + ? ("api_key" as const) + : ("none" as const); + return { - connected: !!apiKeyForEnv && !!webhookSecret, + connected: (!!apiKeyForEnv || oauthConnected) && !!webhookSecret, + connection, + oauth_connected: oauthConnected, api_key: mask(liveApiKeyDecrypted, 3, 2), sandbox_api_key: mask(sandboxApiKeyDecrypted, 5, 5), - project_id: revenueCatConfig.project_id, - sandbox_project_id: revenueCatConfig.sandbox_project_id, + project_id: revenueCatConfig.project_id ?? oauthForEnv?.project_id, + sandbox_project_id: + revenueCatConfig.sandbox_project_id ?? + revenueCatConfig.sandbox_oauth?.project_id, webhook_secret: revenueCatConfig.webhook_secret, sandbox_webhook_secret: revenueCatConfig.sandbox_webhook_secret, }; @@ -108,6 +123,8 @@ export const handleGetRevenueCatConfig = createRoute({ // Return fresh config after update return c.json({ connected: false, + connection: "none" as const, + oauth_connected: false, api_key: undefined, sandbox_api_key: undefined, project_id: undefined, @@ -127,7 +144,7 @@ export const handleUpsertRevenueCatConfig = createRoute({ scopes: [Scopes.Organisation.Write], body: UpsertRevenueCatProcessorConfigSchema, handler: async (c) => { - const { db, org } = c.get("ctx"); + const { db, org, logger } = c.get("ctx"); const body = c.req.valid("json"); @@ -157,6 +174,37 @@ export const handleUpsertRevenueCatConfig = createRoute({ }, }); + // Best-effort: register the inbound RC webhook for any env whose project was just set + // (covers an org that connected OAuth, then selected its project here). Idempotent. + const targets: Array<{ env: AppEnv; projectId: string }> = []; + if (body.project_id) { + targets.push({ env: AppEnv.Live, projectId: body.project_id }); + } + if (body.sandbox_project_id) { + targets.push({ + env: AppEnv.Sandbox, + projectId: body.sandbox_project_id, + }); + } + for (const { env, projectId } of targets) { + try { + const accessToken = await getRevenuecatAccessToken({ db, org, env }); + const secret = getRevenuecatWebhookSecret({ org, env }); + if (!accessToken || !secret) continue; + const rcCli = initRevenuecatCli({ accessToken, projectId }); + await registerRevenuecatWebhook({ + rcCli, + orgId: org.id, + env, + secret, + }); + } catch (webhookError) { + logger.warn( + `[RC] webhook registration failed for org ${org.id} (${env}): ${webhookError}`, + ); + } + } + return c.json({ success: true, }); diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleDisconnectRevenueCat.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleDisconnectRevenueCat.ts new file mode 100644 index 000000000..4f5be5fd6 --- /dev/null +++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleDisconnectRevenueCat.ts @@ -0,0 +1,39 @@ +import { AppEnv, Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; + +/** + * POST /revenuecat/disconnect — remove the current env's RevenueCat connection + * (OAuth tokens, project, api key). Keeps webhook_secret + mappings intact so a + * reconnect reuses them. + */ +export const handleDisconnectRevenueCat = createRoute({ + scopes: [Scopes.Organisation.Write], + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + + const existing = org.processor_configs?.revenuecat; + if (!existing) return c.json({ success: true }); + + const next = { ...existing }; + if (env === AppEnv.Live) { + next.oauth = undefined; + next.project_id = undefined; + next.api_key = undefined; + } else { + next.sandbox_oauth = undefined; + next.sandbox_project_id = undefined; + next.sandbox_api_key = undefined; + } + + await OrgService.update({ + db, + orgId: org.id, + updates: { + processor_configs: { ...org.processor_configs, revenuecat: next }, + }, + }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts new file mode 100644 index 000000000..8840ff585 --- /dev/null +++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts @@ -0,0 +1,58 @@ +import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { z } from "zod/v4"; +import { + createRcAuthorizationUrl, + generateCodeVerifier, +} from "@/external/revenueCat/misc/revenuecatOAuth.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { generateOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js"; + +export const handleGetRevenueCatOAuthUrl = createRoute({ + scopes: [Scopes.Organisation.Write], + query: z.object({ + redirect_url: z.string().optional(), + }), + handler: async (c) => { + // Read `migrate` from the raw query — the validated query layer coerces + // "true"/"false" to booleans, which a z.string() field would reject. + const { redirect_url, migrate } = c.req.query(); + const ctx = c.get("ctx"); + const { org, env } = ctx; + + if ( + !process.env.REVENUECAT_OAUTH_CLIENT_ID || + !process.env.REVENUECAT_OAUTH_CLIENT_SECRET + ) { + throw new RecaseError({ + message: "RevenueCat OAuth client credentials not configured", + code: ErrCode.InternalError, + statusCode: 500, + }); + } + + const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173"; + const envPrefix = env === AppEnv.Sandbox ? "/sandbox" : ""; + const redirectUri = + redirect_url || `${frontendUrl}${envPrefix}/dev?tab=revenuecat`; + const codeVerifier = generateCodeVerifier(); + + const stateKey = await generateOAuthState({ + organizationSlug: org.slug, + env, + redirectUri, + masterOrgId: null, + codeVerifier, + provider: "revenuecat", + migration: migrate === "true", + }); + + const authUrl = createRcAuthorizationUrl({ + state: stateKey, + codeVerifier, + }); + + return c.json({ + oauth_url: authUrl.toString(), + }); + }, +}); diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts new file mode 100644 index 000000000..07aa3bf04 --- /dev/null +++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts @@ -0,0 +1,330 @@ +import { + AppEnv, + type Organization, + type RevenueCatOAuthConfig, + type RevenueCatProcessorConfig, +} from "@autumn/shared"; +import type { Context } from "hono"; +import { initDrizzle } from "@/db/initDrizzle.js"; +import { generateRevenuecatWebhookSecret } from "@/external/revenueCat/misc/getRevenuecatWebhookSecret.js"; +import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js"; +import { registerRevenuecatWebhook } from "@/external/revenueCat/misc/registerRevenuecatWebhook.js"; +import { + exchangeRcCode, + findMissingRcScopes, + RC_OAUTH_SCOPES, +} from "@/external/revenueCat/misc/revenuecatOAuth.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; +import { consumeOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js"; +import { encryptData } from "@/utils/encryptUtils.js"; + +const buildOAuthConfig = ({ + tokens, + projectId, +}: { + tokens: Awaited>; + projectId?: string; +}): RevenueCatOAuthConfig => ({ + access_token: encryptData(tokens.accessToken()), + refresh_token: encryptData(tokens.refreshToken()), + expires_at: tokens.accessTokenExpiresAt().getTime(), + ...(tokens.hasScopes() ? { scope: tokens.scopes().join(" ") } : {}), + ...(projectId ? { project_id: projectId } : {}), + connected_at: Date.now(), +}); + +const mergeRevenueCatOAuth = ({ + org, + env, + oauthConfig, + stripLegacy = false, +}: { + org: Organization; + env: AppEnv; + oauthConfig: RevenueCatOAuthConfig; + // Migration: drop the env's legacy api_key + project_id once OAuth is connected. + stripLegacy?: boolean; +}): RevenueCatProcessorConfig => { + const existing = org.processor_configs?.revenuecat || {}; + + let base = existing; + if (stripLegacy) { + if (env === AppEnv.Live) { + const { api_key, project_id, ...rest } = existing; + base = rest; + } else { + const { sandbox_api_key, sandbox_project_id, ...rest } = existing; + base = rest; + } + } + + return { + ...base, + ...(env === AppEnv.Live + ? { oauth: oauthConfig } + : { sandbox_oauth: oauthConfig }), + }; +}; + +export const handleRevenueCatOAuthCallback = async (c: Context) => { + const query = c.req.query(); + const { code, state, error } = query; + + const { db } = initDrizzle(); + + const frontendUrl = process.env.CLIENT_URL || "http://localhost:3000"; + let redirectUrl = new URL(`${frontendUrl}`); + redirectUrl.searchParams.set("tab", "revenuecat"); + let isPlatformFlow = false; + + if (error) { + redirectUrl.searchParams.set("error", error); + return c.redirect(redirectUrl.toString()); + } + + if (!code || !state) { + redirectUrl.searchParams.set("error", "missing_parameters"); + return c.redirect(redirectUrl.toString()); + } + + try { + const redisState = await consumeOAuthState({ stateKey: state }); + + if (!redisState) { + redirectUrl.searchParams.set("error", "invalid_state"); + return c.redirect(redirectUrl.toString()); + } + + const { + organization_slug, + env, + redirect_uri, + code_verifier, + provider, + master_org_id, + revenuecat_project_name, + migration, + } = redisState; + + if (provider !== "revenuecat") { + redirectUrl.searchParams.set("error", "invalid_provider"); + return c.redirect(redirectUrl.toString()); + } + + if (!code_verifier) { + redirectUrl.searchParams.set("error", "missing_code_verifier"); + return c.redirect(redirectUrl.toString()); + } + + isPlatformFlow = master_org_id !== null; + + if (isPlatformFlow) { + redirectUrl = new URL(redirect_uri); + } else { + redirectUrl = redirect_uri + ? new URL(redirect_uri) + : new URL( + `${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=revenuecat`, + ); + } + + const org = await OrgService.getBySlug({ db, slug: organization_slug }); + + if (!org) { + console.error("Organization not found:", organization_slug); + if (isPlatformFlow) { + redirectUrl.searchParams.set("success", "false"); + redirectUrl.searchParams.set("provider", "revenuecat"); + redirectUrl.searchParams.set("message", "org_not_found"); + } else { + redirectUrl.searchParams.set("error", "org_not_found"); + } + return c.redirect(redirectUrl.toString()); + } + + if (isPlatformFlow && org.created_by !== master_org_id) { + console.error("Platform org mismatch:", org.id, master_org_id); + redirectUrl.searchParams.set("success", "false"); + redirectUrl.searchParams.set("provider", "revenuecat"); + redirectUrl.searchParams.set("message", "org_permission_denied"); + return c.redirect(redirectUrl.toString()); + } + + const tokens = await exchangeRcCode({ code, codeVerifier: code_verifier }); + + const grantedScopes = tokens.hasScopes() ? tokens.scopes() : []; + const missingScopes = findMissingRcScopes(grantedScopes); + + console.log(`[RCOAuth] Requested scopes: [${RC_OAUTH_SCOPES.join(", ")}]`); + console.log(`[RCOAuth] Called back: [${grantedScopes.join(", ")}]`); + console.log(`[RCOAuth] Missing: [${missingScopes.join(", ")}]`); + + if (missingScopes.length > 0) { + if (isPlatformFlow) { + redirectUrl.searchParams.set("success", "false"); + redirectUrl.searchParams.set("provider", "revenuecat"); + redirectUrl.searchParams.set("message", "insufficient_scope"); + } else { + redirectUrl.searchParams.set("error", "insufficient_scope"); + redirectUrl.searchParams.set("missing_scopes", missingScopes.join(",")); + } + return c.redirect(redirectUrl.toString()); + } + + const isMigration = !isPlatformFlow && migration === true; + + let projectId: string | undefined; + if (isPlatformFlow) { + if (!revenuecat_project_name) { + redirectUrl.searchParams.set("success", "false"); + redirectUrl.searchParams.set("provider", "revenuecat"); + redirectUrl.searchParams.set("message", "missing_project_name"); + return c.redirect(redirectUrl.toString()); + } + + const rcCli = initRevenuecatCli({ accessToken: tokens.accessToken() }); + const project = await rcCli.createProject({ + name: revenuecat_project_name, + }); + projectId = project.id; + } else if (isMigration) { + // Migrate api-key → OAuth: the OAuth account must contain the org's existing + // project, and that project's products must cover the existing mappings. + const revenueCatConfig = org.processor_configs?.revenuecat; + const existingProjectId = + env === AppEnv.Live + ? revenueCatConfig?.project_id + : revenueCatConfig?.sandbox_project_id; + + if (!existingProjectId) { + redirectUrl.searchParams.set("error", "no_project_to_migrate"); + return c.redirect(redirectUrl.toString()); + } + + const accountCli = initRevenuecatCli({ + accessToken: tokens.accessToken(), + }); + const { projects } = await accountCli.listProjects(); + if (!projects.some((p) => p.id === existingProjectId)) { + redirectUrl.searchParams.set("error", "project_not_in_account"); + return c.redirect(redirectUrl.toString()); + } + + const projectCli = initRevenuecatCli({ + accessToken: tokens.accessToken(), + projectId: existingProjectId, + }); + const projectStoreIds = await projectCli.listProductStoreIdentifiers(); + const mappings = await RCMappingService.getAll({ + db, + orgId: org.id, + env, + }); + const mappedIds = [ + ...new Set(mappings.flatMap((m) => m.revenuecat_product_ids)), + ]; + const allPresent = mappedIds.every((id) => projectStoreIds.has(id)); + if (!allPresent) { + redirectUrl.searchParams.set("error", "products_mismatch"); + return c.redirect(redirectUrl.toString()); + } + + projectId = existingProjectId; + } + + const oauthConfig = buildOAuthConfig({ tokens, projectId }); + + // Ensure the env's webhook secret exists (the dashboard generates it lazily, which a + // platform-managed org never triggers) so we can register the webhook below. + const existingRc = org.processor_configs?.revenuecat; + const webhookSecret = + (env === AppEnv.Live + ? existingRc?.webhook_secret + : existingRc?.sandbox_webhook_secret) ?? + generateRevenuecatWebhookSecret(); + + const mergedRc = mergeRevenueCatOAuth({ + org, + env, + oauthConfig, + stripLegacy: isMigration, + }); + + await OrgService.update({ + db, + orgId: org.id, + updates: { + processor_configs: { + ...org.processor_configs, + revenuecat: + env === AppEnv.Live + ? { ...mergedRc, webhook_secret: webhookSecret } + : { ...mergedRc, sandbox_webhook_secret: webhookSecret }, + }, + }, + }); + + await clearOrgCache({ db, orgId: org.id }); + + // Best-effort: register the inbound webhook with RevenueCat (idempotent). Needs a project. + if (projectId) { + try { + const webhookCli = initRevenuecatCli({ + accessToken: tokens.accessToken(), + projectId, + }); + await registerRevenuecatWebhook({ + rcCli: webhookCli, + orgId: org.id, + env, + secret: webhookSecret, + }); + } catch (webhookError) { + console.error( + `[RC] webhook registration failed for org ${org.id} (${env}): ${webhookError}`, + ); + } + } + + console.log(`Successfully connected RevenueCat OAuth for org ${org.id}`); + + if (isPlatformFlow) { + redirectUrl.searchParams.set("success", "true"); + redirectUrl.searchParams.set("provider", "revenuecat"); + redirectUrl.searchParams.set("organization_slug", organization_slug); + redirectUrl.searchParams.set( + "env", + env === AppEnv.Live ? "live" : "test", + ); + if (projectId) { + redirectUrl.searchParams.set("revenuecat_project_id", projectId); + } + } else { + redirectUrl.searchParams.set("success", "true"); + } + return c.redirect(redirectUrl.toString()); + } catch (callbackError: unknown) { + console.error("Error in RevenueCat OAuth callback:", callbackError); + if (isPlatformFlow) { + redirectUrl.searchParams.set("success", "false"); + redirectUrl.searchParams.set("provider", "revenuecat"); + redirectUrl.searchParams.set( + "message", + callbackError instanceof Error + ? callbackError.message + : "unknown_error", + ); + } else { + redirectUrl.searchParams.set( + "error", + callbackError instanceof Error + ? callbackError.message + : "unknown_error", + ); + } + return c.redirect(redirectUrl.toString()); + } +}; diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts new file mode 100644 index 000000000..eec6aa7c2 --- /dev/null +++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts @@ -0,0 +1,116 @@ +import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import { + getRevenuecatAccessToken, + getRevenuecatProjectId, +} from "@/external/revenueCat/misc/getRevenuecatAccessToken.js"; +import { + generateRevenuecatWebhookSecret, + getRevenuecatWebhookSecret, +} from "@/external/revenueCat/misc/getRevenuecatWebhookSecret.js"; +import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js"; +import { + getRevenuecatWebhookUrl, + registerRevenuecatWebhook, +} from "@/external/revenueCat/misc/registerRevenuecatWebhook.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; + +type WebhookStatus = "registered" | "not_registered" | "unknown"; + +/** GET /revenuecat/webhook — does the current env's webhook exist on the RC project? + the URL/secret. */ +export const handleGetRevenueCatWebhook = createRoute({ + scopes: [Scopes.Organisation.Read], + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + + const url = getRevenuecatWebhookUrl({ orgId: org.id, env }); + const secret = getRevenuecatWebhookSecret({ org, env }) ?? null; + const revenueCatConfig = org.processor_configs?.revenuecat; + const projectId = revenueCatConfig + ? getRevenuecatProjectId({ revenueCatConfig, env }) + : undefined; + const accessToken = await getRevenuecatAccessToken({ db, org, env }); + + let status: WebhookStatus = "unknown"; + if (url && projectId && accessToken) { + try { + const rcCli = initRevenuecatCli({ projectId, accessToken }); + const hooks = await rcCli.listWebhookIntegrations(); + // Match a webhook pointing at THIS org's receiver whose scope covers this env + // (the specific env, or "both" = null). The host/env-suffix can drift (e.g. ngrok + // rotates), so key off the org receiver path + the RC `environment` scope, not the + // exact URL string. + const orgPath = `/webhooks/revenuecat/${org.id}`; + const targetEnv = env === AppEnv.Live ? "production" : "sandbox"; + status = hooks.some( + (hook) => + hook.url?.includes(orgPath) && + (hook.environment == null || hook.environment === targetEnv), + ) + ? "registered" + : "not_registered"; + } catch { + // e.g. the OAuth client lacks the integrations scope → can't verify + status = "unknown"; + } + } + + return c.json({ status, url, secret }); + }, +}); + +/** POST /revenuecat/webhook — register (idempotent) the current env's webhook on the RC project. */ +export const handleRegisterRevenueCatWebhook = createRoute({ + scopes: [Scopes.Organisation.Write], + handler: async (c) => { + const { db, org, env } = c.get("ctx"); + + const revenueCatConfig = org.processor_configs?.revenuecat; + const projectId = revenueCatConfig + ? getRevenuecatProjectId({ revenueCatConfig, env }) + : undefined; + const accessToken = await getRevenuecatAccessToken({ db, org, env }); + if (!projectId || !accessToken) { + throw new RecaseError({ + message: "Connect RevenueCat (and select a project) before registering a webhook", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + // Ensure the env's webhook secret exists (generate + persist if missing). + let secret = getRevenuecatWebhookSecret({ org, env }); + if (!secret) { + secret = generateRevenuecatWebhookSecret(); + const existing = org.processor_configs?.revenuecat ?? {}; + await OrgService.update({ + db, + orgId: org.id, + updates: { + processor_configs: { + ...org.processor_configs, + revenuecat: + env === AppEnv.Live + ? { ...existing, webhook_secret: secret } + : { ...existing, sandbox_webhook_secret: secret }, + }, + }, + }); + } + + const rcCli = initRevenuecatCli({ projectId, accessToken }); + const result = await registerRevenuecatWebhook({ + rcCli, + orgId: org.id, + env, + secret, + }); + + return c.json({ + status: result === "skipped" ? "unknown" : "registered", + url: getRevenuecatWebhookUrl({ orgId: org.id, env }), + secret, + }); + }, +}); diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts index bc741f220..3b69428ca 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts @@ -20,7 +20,7 @@ export const handleGetOAuthUrl = createRoute({ if (!clientId) { throw new RecaseError({ - message: `Stripe ${env === AppEnv.Live ? "live" : "test"} client ID not configured`, + message: `Stripe ${env} client ID not configured`, code: ErrCode.InternalError, statusCode: 500, }); @@ -33,7 +33,7 @@ export const handleGetOAuthUrl = createRoute({ const stateKey = await generateOAuthState({ organizationSlug: org.slug, - env: env === AppEnv.Live ? "live" : "test", + env, redirectUri, masterOrgId: null, // null for standard flow }); diff --git a/server/src/internal/orgs/invoiceTemplates/InvoiceTemplateService.ts b/server/src/internal/orgs/invoiceTemplates/InvoiceTemplateService.ts new file mode 100644 index 000000000..0b9ee6b64 --- /dev/null +++ b/server/src/internal/orgs/invoiceTemplates/InvoiceTemplateService.ts @@ -0,0 +1,124 @@ +import { + type InvoiceTemplate, + type InvoiceTemplateRow, + invoiceTemplates, +} from "@autumn/shared"; +import type { DrizzleCli } from "@server/db/initDrizzle"; +import { and, desc, eq } from "drizzle-orm"; + +const toApi = (row: InvoiceTemplateRow): InvoiceTemplate => ({ + id: row.id ?? row.internal_id, + name: row.name ?? "", + footer: row.footer ?? undefined, + memo: row.memo ?? undefined, + net_terms_days: row.net_terms_days ?? undefined, + created_at: row.created_at ?? undefined, +}); + +interface InvoiceTemplateValues { + name: string; + footer?: string; + memo?: string; + net_terms_days?: number; +} + +export class InvoiceTemplateService { + static async list({ + db, + orgId, + }: { + db: DrizzleCli; + orgId: string; + }): Promise { + const rows = await db + .select() + .from(invoiceTemplates) + .where(eq(invoiceTemplates.org_id, orgId)) + .orderBy(desc(invoiceTemplates.created_at)); + return rows.map(toApi); + } + + static async getById({ + db, + orgId, + id, + }: { + db: DrizzleCli; + orgId: string; + id: string; + }): Promise { + const rows = await db + .select() + .from(invoiceTemplates) + .where( + and(eq(invoiceTemplates.org_id, orgId), eq(invoiceTemplates.id, id)), + ) + .limit(1); + const row = rows[0]; + return row ? toApi(row) : undefined; + } + + static async create({ + db, + orgId, + internalId, + id, + values, + }: { + db: DrizzleCli; + orgId: string; + internalId: string; + id: string; + values: InvoiceTemplateValues; + }): Promise { + const rows = await db + .insert(invoiceTemplates) + .values({ + internal_id: internalId, + id, + org_id: orgId, + created_at: Date.now(), + ...values, + }) + .returning(); + return toApi(rows[0]); + } + + static async update({ + db, + orgId, + id, + update, + }: { + db: DrizzleCli; + orgId: string; + id: string; + update: InvoiceTemplateValues; + }): Promise { + const rows = await db + .update(invoiceTemplates) + .set(update) + .where( + and(eq(invoiceTemplates.org_id, orgId), eq(invoiceTemplates.id, id)), + ) + .returning(); + const row = rows[0]; + return row ? toApi(row) : undefined; + } + + static async delete({ + db, + orgId, + id, + }: { + db: DrizzleCli; + orgId: string; + id: string; + }): Promise { + await db + .delete(invoiceTemplates) + .where( + and(eq(invoiceTemplates.org_id, orgId), eq(invoiceTemplates.id, id)), + ); + } +} diff --git a/server/src/internal/orgs/invoiceTemplates/handlers/handleCreateInvoiceTemplate.ts b/server/src/internal/orgs/invoiceTemplates/handlers/handleCreateInvoiceTemplate.ts new file mode 100644 index 000000000..7cc8db75e --- /dev/null +++ b/server/src/internal/orgs/invoiceTemplates/handlers/handleCreateInvoiceTemplate.ts @@ -0,0 +1,22 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { generateId } from "@/utils/genUtils.js"; +import { InvoiceTemplateService } from "../InvoiceTemplateService.js"; +import { invoiceTemplateBodySchema } from "../invoiceTemplateBody.js"; + +export const handleCreateInvoiceTemplate = createRoute({ + scopes: [Scopes.Organisation.Write], + body: invoiceTemplateBodySchema, + handler: async (c) => { + const { db, org } = c.get("ctx"); + const values = c.req.valid("json"); + const template = await InvoiceTemplateService.create({ + db, + orgId: org.id, + internalId: generateId("itmpl"), + id: generateId("it"), + values, + }); + return c.json({ template }); + }, +}); diff --git a/server/src/internal/orgs/invoiceTemplates/handlers/handleDeleteInvoiceTemplate.ts b/server/src/internal/orgs/invoiceTemplates/handlers/handleDeleteInvoiceTemplate.ts new file mode 100644 index 000000000..b5fcf9402 --- /dev/null +++ b/server/src/internal/orgs/invoiceTemplates/handlers/handleDeleteInvoiceTemplate.ts @@ -0,0 +1,13 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { InvoiceTemplateService } from "../InvoiceTemplateService.js"; + +export const handleDeleteInvoiceTemplate = createRoute({ + scopes: [Scopes.Organisation.Write], + handler: async (c) => { + const { db, org } = c.get("ctx"); + const { id } = c.req.param(); + await InvoiceTemplateService.delete({ db, orgId: org.id, id }); + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/orgs/invoiceTemplates/handlers/handleListInvoiceTemplates.ts b/server/src/internal/orgs/invoiceTemplates/handlers/handleListInvoiceTemplates.ts new file mode 100644 index 000000000..f9d32f99c --- /dev/null +++ b/server/src/internal/orgs/invoiceTemplates/handlers/handleListInvoiceTemplates.ts @@ -0,0 +1,15 @@ +import { Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { InvoiceTemplateService } from "../InvoiceTemplateService.js"; + +export const handleListInvoiceTemplates = createRoute({ + scopes: [Scopes.Organisation.Read], + handler: async (c) => { + const { db, org } = c.get("ctx"); + const templates = await InvoiceTemplateService.list({ + db, + orgId: org.id, + }); + return c.json({ templates }); + }, +}); diff --git a/server/src/internal/orgs/invoiceTemplates/handlers/handleUpdateInvoiceTemplate.ts b/server/src/internal/orgs/invoiceTemplates/handlers/handleUpdateInvoiceTemplate.ts new file mode 100644 index 000000000..eaa25a81f --- /dev/null +++ b/server/src/internal/orgs/invoiceTemplates/handlers/handleUpdateInvoiceTemplate.ts @@ -0,0 +1,28 @@ +import { RecaseError, Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { InvoiceTemplateService } from "../InvoiceTemplateService.js"; +import { invoiceTemplateBodySchema } from "../invoiceTemplateBody.js"; + +export const handleUpdateInvoiceTemplate = createRoute({ + scopes: [Scopes.Organisation.Write], + body: invoiceTemplateBodySchema, + handler: async (c) => { + const { db, org } = c.get("ctx"); + const { id } = c.req.param(); + const values = c.req.valid("json"); + const template = await InvoiceTemplateService.update({ + db, + orgId: org.id, + id, + update: values, + }); + if (!template) { + throw new RecaseError({ + message: "Invoice template not found", + code: "invoice_template_not_found", + statusCode: 404, + }); + } + return c.json({ template }); + }, +}); diff --git a/server/src/internal/orgs/invoiceTemplates/invoiceTemplateBody.ts b/server/src/internal/orgs/invoiceTemplates/invoiceTemplateBody.ts new file mode 100644 index 000000000..a77db85d1 --- /dev/null +++ b/server/src/internal/orgs/invoiceTemplates/invoiceTemplateBody.ts @@ -0,0 +1,8 @@ +import { z } from "zod/v4"; + +export const invoiceTemplateBodySchema = z.object({ + name: z.string().trim().min(1, "Name is required"), + footer: z.string().trim().optional(), + memo: z.string().trim().optional(), + net_terms_days: z.number().int().positive().optional(), +}); diff --git a/server/src/internal/orgs/invoiceTemplates/invoiceTemplateRouter.ts b/server/src/internal/orgs/invoiceTemplates/invoiceTemplateRouter.ts new file mode 100644 index 000000000..13da9770e --- /dev/null +++ b/server/src/internal/orgs/invoiceTemplates/invoiceTemplateRouter.ts @@ -0,0 +1,13 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleCreateInvoiceTemplate } from "./handlers/handleCreateInvoiceTemplate.js"; +import { handleDeleteInvoiceTemplate } from "./handlers/handleDeleteInvoiceTemplate.js"; +import { handleListInvoiceTemplates } from "./handlers/handleListInvoiceTemplates.js"; +import { handleUpdateInvoiceTemplate } from "./handlers/handleUpdateInvoiceTemplate.js"; + +export const invoiceTemplateRouter = new Hono(); + +invoiceTemplateRouter.get("", ...handleListInvoiceTemplates); +invoiceTemplateRouter.post("", ...handleCreateInvoiceTemplate); +invoiceTemplateRouter.patch("/:id", ...handleUpdateInvoiceTemplate); +invoiceTemplateRouter.delete("/:id", ...handleDeleteInvoiceTemplate); diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts index a1e815e83..9fcb3addc 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -1,7 +1,18 @@ import { Hono } from "hono"; import { handleGetRCMappings } from "@/external/revenueCat/handlers/handleGetRevenuecatMappings.js"; import { handleGetRevenueCatProducts } from "@/external/revenueCat/handlers/handleGetRevenuecatProducts.js"; +import { + handleCreateRevenueCatProject, + handleGetRevenueCatProjects, +} from "@/external/revenueCat/handlers/handleGetRevenuecatProjects.js"; +import { handlePreflightRevenueCatSync } from "@/external/revenueCat/handlers/handlePreflightRevenueCatSync.js"; import { handleSaveRCMappings } from "@/external/revenueCat/handlers/handleSaveRevenuecatMappings.js"; +import { handleSyncRevenueCatProducts } from "@/external/revenueCat/handlers/handleSyncRevenueCatProducts.js"; +import { handleDisconnectRevenueCat } from "@/internal/orgs/handlers/revenueCatHandlers/handleDisconnectRevenueCat.js"; +import { + handleGetRevenueCatWebhook, + handleRegisterRevenueCatWebhook, +} from "@/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleDeleteOrg } from "./handlers/crudHandlers/handleDeleteOrg.js"; import { handleGetOrg } from "./handlers/crudHandlers/handleGetOrg.js"; @@ -30,6 +41,7 @@ import { handleConnectStripe } from "./handlers/stripeHandlers/handleConnectStri import { handleDeleteStripe } from "./handlers/stripeHandlers/handleDeleteStripe.js"; import { handleGetOAuthUrl } from "./handlers/stripeHandlers/handleGetOAuthUrl.js"; import { handleGetStripeAccount } from "./handlers/stripeHandlers/handleGetStripeAccount.js"; +import { handleGetRevenueCatOAuthUrl } from "./handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.js"; export const internalOrgRouter = new Hono(); @@ -69,6 +81,14 @@ honoOrgRouter.get("/vercel_sink", ...handleGetVercelSink); honoOrgRouter.get("/revenuecat", ...handleGetRevenueCatConfig); honoOrgRouter.patch("/revenuecat", ...handleUpsertRevenueCatConfig); +honoOrgRouter.get("/revenuecat/oauth_url", ...handleGetRevenueCatOAuthUrl); honoOrgRouter.post("/revenuecat/products", ...handleGetRevenueCatProducts); +honoOrgRouter.get("/revenuecat/projects", ...handleGetRevenueCatProjects); +honoOrgRouter.post("/revenuecat/projects", ...handleCreateRevenueCatProject); +honoOrgRouter.post("/revenuecat/sync", ...handleSyncRevenueCatProducts); +honoOrgRouter.post("/revenuecat/preflight", ...handlePreflightRevenueCatSync); honoOrgRouter.get("/revenuecat/mappings", ...handleGetRCMappings); honoOrgRouter.post("/revenuecat/mappings", ...handleSaveRCMappings); +honoOrgRouter.get("/revenuecat/webhook", ...handleGetRevenueCatWebhook); +honoOrgRouter.post("/revenuecat/webhook", ...handleRegisterRevenueCatWebhook); +honoOrgRouter.post("/revenuecat/disconnect", ...handleDisconnectRevenueCat); diff --git a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts index d53e129a5..38285879b 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts @@ -163,6 +163,7 @@ export const handleCreatePlatformOrg = createRoute({ } return c.json({ + org_id: org.id, test_secret_key, live_secret_key, org_slug: org.slug, diff --git a/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts b/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts index 609775dd5..f1205100d 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts @@ -1,4 +1,4 @@ -import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { generateOAuthState } from "../utils/oauthStateUtils.js"; @@ -36,7 +36,7 @@ export const handleGetPlatformOAuth = createRoute({ // Generate OAuth state and store in Redis const stateKey = await generateOAuthState({ organizationSlug: org.slug, - env, + env: env === "live" ? AppEnv.Live : AppEnv.Sandbox, redirectUri: redirect_url, masterOrgId: masterOrg.id, }); diff --git a/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts b/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts new file mode 100644 index 000000000..c4305c1ec --- /dev/null +++ b/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts @@ -0,0 +1,68 @@ +import { AppEnv, GetRevenueCatKeysSchema, Scopes } from "@autumn/shared"; +import { + getRevenuecatAccessToken, + getRevenuecatProjectId, + refreshRevenuecatOAuthAccessToken, +} from "@/external/revenueCat/misc/getRevenuecatAccessToken.js"; +import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { validatePlatformOrg } from "../utils/validatePlatformOrg.js"; + +// The stores a managed org's mobile apps actually ship against. +const KEY_APP_TYPES = new Set(["test_store", "app_store", "play_store"]); + +/** + * POST /platform.get_revenuecat_keys — return a managed org's RevenueCat public + * (SDK) API keys per app, for the test store / App Store / Play Store. + */ +export const handleGetRevenueCatKeys = createRoute({ + scopes: [Scopes.Platform.Write], + body: GetRevenueCatKeysSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org: masterOrg } = ctx; + const { organization_slug, env } = c.req.valid("json"); + const appEnv = env === "live" ? AppEnv.Live : AppEnv.Sandbox; + + const org = await validatePlatformOrg({ + db, + organizationSlug: organization_slug, + masterOrg, + }); + + const revenueCatConfig = org.processor_configs?.revenuecat; + if (!revenueCatConfig) return c.json({ apps: [], oauth_access_token: null }); + + const projectId = getRevenuecatProjectId({ revenueCatConfig, env: appEnv }); + // Force-refresh the OAuth token so the master gets a fresh, full-lifetime access token. + // We keep the rotated refresh token; only the access token is ever handed out. + const oauthAccessToken = await refreshRevenuecatOAuthAccessToken({ + db, + org, + env: appEnv, + }); + // api-key orgs have no OAuth token — fall back to the api key for the CLI only. + const accessToken = + oauthAccessToken ?? + (await getRevenuecatAccessToken({ db, org, env: appEnv })); + if (!projectId || !accessToken) { + return c.json({ apps: [], oauth_access_token: null }); + } + + const rcCli = initRevenuecatCli({ projectId, accessToken }); + const apps = (await rcCli.listApps()).filter((app) => + KEY_APP_TYPES.has(app.type), + ); + + const result = await Promise.all( + apps.map(async (app) => ({ + app_id: app.id, + app_type: app.type, + name: app.name, + api_keys: await rcCli.listAppPublicApiKeys(app.id), + })), + ); + + return c.json({ apps: result, oauth_access_token: oauthAccessToken }); + }, +}); diff --git a/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts b/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts new file mode 100644 index 000000000..b2ea06dbe --- /dev/null +++ b/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts @@ -0,0 +1,78 @@ +import { + AppEnv, + ErrCode, + LinkRevenueCatSchema, + RecaseError, + Scopes, +} from "@autumn/shared"; +import { + createRcAuthorizationUrl, + generateCodeVerifier, +} from "@/external/revenueCat/misc/revenuecatOAuth.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { generateOAuthState } from "../utils/oauthStateUtils.js"; +import { validatePlatformOrg } from "../utils/validatePlatformOrg.js"; + +/** + * POST /platform.link_revenuecat + * Generates RevenueCat OAuth URL for a platform-managed organization. + */ +export const handleLinkRevenueCat = createRoute({ + scopes: [Scopes.Platform.Write], + body: LinkRevenueCatSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org: masterOrg, logger } = ctx; + + const { organization_slug, env, project_name, redirect_url } = + c.req.valid("json"); + + const org = await validatePlatformOrg({ + db, + organizationSlug: organization_slug, + masterOrg, + }); + + const rcConfig = org.processor_configs?.revenuecat; + const isLinked = + env === "live" + ? !!(rcConfig?.oauth || rcConfig?.project_id || rcConfig?.api_key) + : !!( + rcConfig?.sandbox_oauth || + rcConfig?.sandbox_project_id || + rcConfig?.sandbox_api_key + ); + + if (isLinked) { + throw new RecaseError({ + message: `RevenueCat already linked for ${env} environment`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + const codeVerifier = generateCodeVerifier(); + const stateKey = await generateOAuthState({ + organizationSlug: org.slug, + env: env === "live" ? AppEnv.Live : AppEnv.Sandbox, + redirectUri: redirect_url, + masterOrgId: masterOrg.id, + codeVerifier, + provider: "revenuecat", + revenuecatProjectName: project_name, + }); + + const authUrl = createRcAuthorizationUrl({ + state: stateKey, + codeVerifier, + }); + + logger.info( + `Generated RevenueCat OAuth URL for platform org ${org.slug} (${env})`, + ); + + return c.json({ + oauth_url: authUrl.toString(), + }); + }, +}); diff --git a/server/src/internal/platform/platformBeta/handlers/handleSyncRevenueCat.ts b/server/src/internal/platform/platformBeta/handlers/handleSyncRevenueCat.ts new file mode 100644 index 000000000..0193470c8 --- /dev/null +++ b/server/src/internal/platform/platformBeta/handlers/handleSyncRevenueCat.ts @@ -0,0 +1,46 @@ +import { AppEnv, Scopes, SyncRevenueCatSchema } from "@autumn/shared"; +import { syncProductsToRevenueCat } from "@/external/revenueCat/sync/syncRevenueCatProducts.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { validatePlatformOrg } from "../utils/validatePlatformOrg.js"; + +/** + * POST /platform.sync_revenuecat — push a managed org's plans into RevenueCat. + * Omit product_ids to sync every plan in the org/env. + */ +export const handleSyncRevenueCat = createRoute({ + scopes: [Scopes.Platform.Write], + // Accepts "test"/"sandbox"/"live" — "test" + "sandbox" both map to AppEnv.Sandbox below. + body: SyncRevenueCatSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org: masterOrg } = ctx; + const { organization_slug, env, product_ids } = c.req.valid("json"); + const appEnv = env === "live" ? AppEnv.Live : AppEnv.Sandbox; + + const org = await validatePlatformOrg({ + db, + organizationSlug: organization_slug, + masterOrg, + }); + + const targetCtx = { ...ctx, org, env: appEnv }; + + let productIds = product_ids; + if (!productIds) { + const products = await ProductService.listFull({ + db, + orgId: org.id, + env: appEnv, + }); + productIds = products.map((p) => p.id); + } + + const results = await syncProductsToRevenueCat({ + ctx: targetCtx, + productIds, + }); + + return c.json({ results }); + }, +}); diff --git a/server/src/internal/platform/platformBeta/platformRpcRouter.ts b/server/src/internal/platform/platformBeta/platformRpcRouter.ts new file mode 100644 index 000000000..b8ef222c5 --- /dev/null +++ b/server/src/internal/platform/platformBeta/platformRpcRouter.ts @@ -0,0 +1,14 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleGetRevenueCatKeys } from "./handlers/handleGetRevenueCatKeys.js"; +import { handleLinkRevenueCat } from "./handlers/handleLinkRevenueCat.js"; +import { handleSyncRevenueCat } from "./handlers/handleSyncRevenueCat.js"; + +export const platformRpcRouter = new Hono(); + +platformRpcRouter.post("/platform.link_revenuecat", ...handleLinkRevenueCat); +platformRpcRouter.post("/platform.sync_revenuecat", ...handleSyncRevenueCat); +platformRpcRouter.post( + "/platform.get_revenuecat_keys", + ...handleGetRevenueCatKeys, +); diff --git a/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts b/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts index 7ad10d9bd..415b63e53 100644 --- a/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts +++ b/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { InternalError } from "@autumn/shared"; +import { AppEnv, InternalError } from "@autumn/shared"; import { CacheManager } from "../../../../utils/cacheUtils/CacheManager"; const STATE_KEY_PREFIX = "oauth_state:"; @@ -7,9 +7,14 @@ const STATE_EXPIRY_SECONDS = 10 * 60; // 10 minutes type OAuthState = { organization_slug: string; - env: "test" | "live"; + env: AppEnv; redirect_uri: string; master_org_id: string | null; // null for standard flow, string for platform flow + code_verifier?: string; + provider?: "stripe" | "revenuecat"; + revenuecat_project_name?: string; + // true for the API-key → OAuth migration flow + migration?: boolean; }; /** @@ -21,11 +26,19 @@ export const generateOAuthState = async ({ env, redirectUri, masterOrgId, + codeVerifier, + provider, + revenuecatProjectName, + migration, }: { organizationSlug: string; - env: "test" | "live"; + env: AppEnv; redirectUri: string; masterOrgId: string | null; + codeVerifier?: string; + provider?: "stripe" | "revenuecat"; + revenuecatProjectName?: string; + migration?: boolean; }): Promise => { const maxAttempts = 3; @@ -40,6 +53,12 @@ export const generateOAuthState = async ({ env, redirect_uri: redirectUri, master_org_id: masterOrgId, + ...(codeVerifier ? { code_verifier: codeVerifier } : {}), + ...(provider ? { provider } : {}), + ...(revenuecatProjectName + ? { revenuecat_project_name: revenuecatProjectName } + : {}), + ...(migration ? { migration: true } : {}), }; // Check if key exists first diff --git a/server/src/internal/product/actions/inPlaceUpdateUtils.ts b/server/src/internal/product/actions/inPlaceUpdateUtils.ts new file mode 100644 index 000000000..a1f55a3a4 --- /dev/null +++ b/server/src/internal/product/actions/inPlaceUpdateUtils.ts @@ -0,0 +1,190 @@ +import type { Feature, FullProduct, ProductItem } from "@autumn/shared"; +import { + findSimilarItem, + itemsAreSame, + mapToProductItems, +} from "@autumn/shared"; +import type { DrizzleCli } from "@server/db/initDrizzle"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js"; +import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; +import { PriceService } from "@/internal/products/prices/PriceService.js"; + +// Includes the base price: a base-price edit must retire the old shared row too, +// not mutate it in place under existing customers. +const currentItemsOf = ({ + currentFullProduct, + features, +}: { + currentFullProduct: FullProduct; + features: Feature[]; +}): ProductItem[] => + mapToProductItems({ + prices: currentFullProduct.prices, + entitlements: currentFullProduct.entitlements, + features, + }); + +/** + * Callers rarely echo back entitlement_id / price_id, so without this match the + * unchanged items look new and the old rows get deleted (cascading the + * customers' rows). Match incoming items to the current catalog by feature + + * interval and carry their ids forward. + */ +const backfillExistingItemIds = ({ + items, + currentFullProduct, + features, +}: { + items: ProductItem[]; + currentFullProduct: FullProduct; + features: Feature[]; +}): ProductItem[] => { + const currentItems = currentItemsOf({ currentFullProduct, features }); + + return items.map((item) => { + if (item.entitlement_id || item.price_id) return item; + const match = findSimilarItem({ item, items: currentItems }); + if (!match) return item; + return { + ...item, + ...(match.entitlement_id ? { entitlement_id: match.entitlement_id } : {}), + ...(match.price_id ? { price_id: match.price_id } : {}), + }; + }); +}; + +/** + * Retire (vs mutate/delete) a catalog ent/price so existing customers that + * reference it keep their definition. Referenced rows flip to is_custom:true + * (hidden from the catalog, FK still valid); unreferenced rows are deleted. + */ +const retireOrDeleteRows = async ({ + db, + entitlementIds, + priceIds, +}: { + db: DrizzleCli; + entitlementIds: string[]; + priceIds: string[]; +}) => { + const referencedEnts = await CusEntService.getReferencedEntitlementIds({ + db, + entitlementIds, + }); + const referencedPrices = await CusPriceService.getReferencedPriceIds({ + db, + priceIds, + }); + const priceRows = await PriceService.getInIds({ db, ids: priceIds }); + const entitlementsReferencedByRetainedPrices = new Set( + priceRows + .flatMap((price) => + referencedPrices.has(price.id) && price.entitlement_id + ? [price.entitlement_id] + : [], + ), + ); + + for (const priceId of priceIds) { + if (referencedPrices.has(priceId)) { + await PriceService.update({ + db, + id: priceId, + update: { is_custom: true }, + }); + } else { + await PriceService.deleteInIds({ db, ids: [priceId] }); + } + } + + for (const entitlementId of entitlementIds) { + if ( + referencedEnts.has(entitlementId) || + entitlementsReferencedByRetainedPrices.has(entitlementId) + ) { + await EntitlementService.update({ + db, + id: entitlementId, + updates: { is_custom: true }, + }); + } else { + await EntitlementService.deleteInIds({ db, ids: [entitlementId] }); + } + } +}; + +/** + * Resolve an in-place edit (disable_version + customers) against the current + * catalog. Carries forward unchanged ids, retires the rows behind UPDATE/DELETE + * (is_custom flip when referenced, else delete) so existing customers are + * untouched, and returns the items to insert plus the catalog prices/ents with + * the retired rows removed — handed to `handleNewProductItems` so it does not + * re-delete them. + */ +export const resolveInPlaceEdit = async ({ + db, + items, + currentFullProduct, + features, +}: { + db: DrizzleCli; + items: ProductItem[]; + currentFullProduct: FullProduct; + features: Feature[]; +}): Promise<{ + items: ProductItem[]; + curPrices: FullProduct["prices"]; + curEnts: FullProduct["entitlements"]; +}> => { + const backfilledItems = backfillExistingItemIds({ + items, + currentFullProduct, + features, + }); + const currentItems = currentItemsOf({ currentFullProduct, features }); + + const retiredEntitlementIds: string[] = []; + const retiredPriceIds: string[] = []; + + for (const currentItem of currentItems) { + const match = findSimilarItem({ + item: currentItem, + items: backfilledItems, + }); + const isDeleted = !match; + const isUpdated = + match && + !itemsAreSame({ item1: match, item2: currentItem, features }).same; + if (!(isDeleted || isUpdated)) continue; + if (currentItem.entitlement_id) + retiredEntitlementIds.push(currentItem.entitlement_id); + if (currentItem.price_id) retiredPriceIds.push(currentItem.price_id); + } + + await retireOrDeleteRows({ + db, + entitlementIds: retiredEntitlementIds, + priceIds: retiredPriceIds, + }); + + const retired = new Set([...retiredEntitlementIds, ...retiredPriceIds]); + // Updated items must mint fresh is_custom:false rows, so drop the backfilled + // ids that now point at retired rows. + const preparedItems = backfilledItems.map((item) => { + const retiresEnt = item.entitlement_id && retired.has(item.entitlement_id); + const retiresPrice = item.price_id && retired.has(item.price_id); + if (!(retiresEnt || retiresPrice)) return item; + return { ...item, entitlement_id: undefined, price_id: undefined }; + }); + + return { + items: preparedItems, + curPrices: currentFullProduct.prices.filter( + (price) => !retired.has(price.id), + ), + curEnts: currentFullProduct.entitlements.filter( + (ent) => !retired.has(ent.id), + ), + }; +}; diff --git a/server/src/internal/product/actions/updateProduct.ts b/server/src/internal/product/actions/updateProduct.ts index 6404fb6b1..57a240e34 100644 --- a/server/src/internal/product/actions/updateProduct.ts +++ b/server/src/internal/product/actions/updateProduct.ts @@ -10,6 +10,7 @@ import { UpdateProductSchema, type UpdateProductV2Params, } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { @@ -25,6 +26,7 @@ import { initProductInStripe } from "@/internal/products/productUtils.js"; import { rewardProgramRepo } from "@/internal/rewards/repos/index.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { resolveInPlaceEdit } from "./inPlaceUpdateUtils.js"; import { validateDefaultFlag } from "./validateDefaultFlag.js"; interface UpdateProductParams { @@ -55,6 +57,7 @@ export const updateProduct = async ({ idOrInternalId: productId, orgId: org.id, env, + version, }); }; @@ -121,13 +124,11 @@ export const updateProduct = async ({ // Check if versioning is needed (customers exist AND items or free trial changed) const freeTrialProvided = "free_trial" in updates; - if (cusProductExists && (itemsExist || freeTrialProvided)) { - if (disable_version) { - throw new RecaseError({ - message: "Cannot auto save product as there are existing customers", - }); - } - + if ( + cusProductExists && + !disable_version && + (itemsExist || freeTrialProvided) + ) { const { itemsSame, freeTrialsSame } = productsAreSame({ newProductV2: newProductV2, curProductV1: fullProduct, @@ -154,16 +155,42 @@ export const updateProduct = async ({ const { free_trial } = updates; if (updates.items) { - await handleNewProductItems({ - db, - curPrices: fullProduct.prices, - curEnts: fullProduct.entitlements, - newItems: updates.items, - features, - product: fullProduct, - logger: ctx.logger, - isCustom: false, - }); + const newItems = updates.items; + if (cusProductExists && disable_version) { + // Retire the shared catalog rows + insert their replacements atomically: + // a failure between the two must not leave the plan with retired rows + // and no replacement. + await db.transaction(async (transaction) => { + const tx = transaction as unknown as DrizzleCli; + const inPlace = await resolveInPlaceEdit({ + db: tx, + items: newItems, + currentFullProduct: fullProduct, + features, + }); + await handleNewProductItems({ + db: tx, + curPrices: inPlace.curPrices, + curEnts: inPlace.curEnts, + newItems: inPlace.items, + features, + product: fullProduct, + logger: ctx.logger, + isCustom: false, + }); + }); + } else { + await handleNewProductItems({ + db, + curPrices: fullProduct.prices, + curEnts: fullProduct.entitlements, + newItems, + features, + product: fullProduct, + logger: ctx.logger, + isCustom: false, + }); + } } const latestProductId = updates.id || fullProduct.id; @@ -174,6 +201,7 @@ export const updateProduct = async ({ idOrInternalId: latestProductId, orgId: org.id, env, + version: fullProduct.version, }); if (free_trial !== undefined) { diff --git a/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts b/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts index d3a64bc13..a90a153c4 100644 --- a/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts +++ b/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts @@ -145,13 +145,7 @@ export const handleUpdatePlanV1 = createRoute({ // Check if versioning is needed (customers exist AND items or free trial changed) const freeTrialProvided = "free_trial" in body; - if (cusProductExists && (itemsExist || freeTrialProvided)) { - if (disable_version) { - throw new RecaseError({ - message: "Cannot auto save product as there are existing customers", - }); - } - + if (cusProductExists && !disable_version && (itemsExist || freeTrialProvided)) { const { itemsSame, freeTrialsSame } = productsAreSame({ newProductV2: newProductV2, curProductV1: fullProduct, diff --git a/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV2.ts b/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV2.ts index 35f7973eb..8b5ab446c 100644 --- a/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV2.ts +++ b/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV2.ts @@ -1,9 +1,9 @@ import { AffectedResource, apiPlan, + Scopes, UpdatePlanParamsV2Schema, type UpdateProductV2Params, - Scopes, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { updateProduct } from "../../../product/actions/updateProduct.js"; @@ -17,7 +17,8 @@ export const handleUpdatePlanV2 = createRoute({ handler: async (c) => { const body = c.req.valid("json"); - const { plan_id, new_plan_id, ...planParams } = body; + const { plan_id, new_plan_id, disable_version, version, ...planParams } = + body; const ctx = c.get("ctx"); const initialFullProduct = await ProductService.getFull({ @@ -25,6 +26,7 @@ export const handleUpdatePlanV2 = createRoute({ idOrInternalId: plan_id, orgId: ctx.org.id, env: ctx.env, + version, }); const updateProductV2Params = apiPlan.map.paramsV1ToProductV2({ @@ -39,7 +41,7 @@ export const handleUpdatePlanV2 = createRoute({ await updateProduct({ ctx, productId: plan_id, - query: {}, + query: { version, disable_version }, updates: updateProductV2Params, initialFullProduct, }); @@ -50,6 +52,7 @@ export const handleUpdatePlanV2 = createRoute({ idOrInternalId: latestPlanId, orgId: ctx.org.id, env: ctx.env, + version, }); const latestPlan = await getPlanResponse({ diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index a4ce06080..398690b49 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -41,7 +41,13 @@ export const handleVersionProductV2 = async ({ }) => { const { db, features } = ctx; - const curVersion = latestProduct.version; + const latestForVersioning = await ProductService.getFull({ + db, + idOrInternalId: latestProduct.id, + orgId: org.id, + env, + }); + const curVersion = latestForVersioning.version; const newVersion = curVersion + 1; console.log( diff --git a/server/src/internal/products/internalHandlers/handleGetProductInternal.ts b/server/src/internal/products/internalHandlers/handleGetProductInternal.ts index 592ebf34a..9aef59b68 100644 --- a/server/src/internal/products/internalHandlers/handleGetProductInternal.ts +++ b/server/src/internal/products/internalHandlers/handleGetProductInternal.ts @@ -1,6 +1,7 @@ import { mapToProductV2, queryInteger, Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js"; import { ProductService } from "../ProductService.js"; const GetProductInternalQuerySchema = z.object({ @@ -15,7 +16,7 @@ export const handleGetProductInternal = createRoute({ const { version } = c.req.valid("query"); const { db, org, env, features } = c.get("ctx"); - const [product, latestProduct] = await Promise.all([ + const [product, latestProduct, versionCounts] = await Promise.all([ ProductService.getFull({ db, idOrInternalId: productId, @@ -29,6 +30,12 @@ export const handleGetProductInternal = createRoute({ orgId: org.id, env, }), + CusProdReadService.getCountsPerVersion({ + db, + productId, + orgId: org.id, + env, + }), ]); const productV2 = mapToProductV2({ @@ -36,6 +43,10 @@ export const handleGetProductInternal = createRoute({ features: features, }); - return c.json({ product: productV2, numVersions: latestProduct.version }); + return c.json({ + product: productV2, + numVersions: latestProduct.version, + versionCounts, + }); }, }); diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index a1b8a7678..79da803e6 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import { handleListRevenueCatMappings } from "@/external/revenueCat/handlers/handleListRevenueCatMappings.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js"; import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js"; @@ -54,3 +55,7 @@ plansRpcRouter.post("/plans.create", ...handleCreatePlanV2); plansRpcRouter.post("/plans.update", ...handleUpdatePlanV2); plansRpcRouter.post("/plans.delete", ...handleDeletePlanV2); plansRpcRouter.post("/plans.get", ...handleGetPlanV2); +plansRpcRouter.post( + "/plans.revenuecat_mappings", + ...handleListRevenueCatMappings, +); diff --git a/server/src/internal/products/productUtils/productResponseUtils/getPlanResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getPlanResponse.ts index 0b5f1fd63..633c95ff4 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getPlanResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getPlanResponse.ts @@ -119,8 +119,8 @@ export const getPlanResponse = async ({ group: product.group || null, version: product.version, - add_on: product.is_add_on, - auto_enable: product.is_default, + add_on: product.is_add_on ?? false, + auto_enable: product.is_default ?? false, price: basePrice, items: planItems ?? [], diff --git a/server/src/internal/workbench/handlers/handleListRequestLogs.ts b/server/src/internal/workbench/handlers/handleListRequestLogs.ts index 002551dd9..513e5cebf 100644 --- a/server/src/internal/workbench/handlers/handleListRequestLogs.ts +++ b/server/src/internal/workbench/handlers/handleListRequestLogs.ts @@ -1,15 +1,15 @@ import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import { z } from "zod/v4"; -import { - buildRequestLogsQuery, - type HttpMethodFilter, - type StatusBucket, -} from "@/external/axiom/aplUtils.js"; import { getAxiomClient, isAxiomConfigured, } from "@/external/axiom/initAxiom.js"; +import { + buildRequestLogsQuery, + type HttpMethodFilter, + type StatusBucket, +} from "@/external/axiom/utils/aplUtils.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { CusService } from "@/internal/customers/CusService.js"; diff --git a/server/src/routers/chatProxyRouter.ts b/server/src/routers/chatProxyRouter.ts new file mode 100644 index 000000000..9a2f03717 --- /dev/null +++ b/server/src/routers/chatProxyRouter.ts @@ -0,0 +1,34 @@ +import { type Context, Hono } from "hono"; +import type { HonoEnv } from "../honoUtils/HonoEnv.js"; + +const bodylessMethods = new Set(["GET", "HEAD"]); + +const proxyChatRequest = + (chatServerUrl: string) => async (c: Context) => { + const url = new URL(c.req.url); + const headers = new Headers(c.req.raw.headers); + headers.delete("host"); + + return fetch(`${chatServerUrl}${url.pathname}${url.search}`, { + body: bodylessMethods.has(c.req.raw.method) ? undefined : c.req.raw.body, + headers, + method: c.req.raw.method, + redirect: "manual", + } as RequestInit & { duplex: "half" }); + }; + +export const createChatProxyRouter = ( + chatServerUrl = process.env.CHAT_SERVER_URL ?? + (process.env.NODE_ENV === "production" + ? "https://chat.useautumn.com" + : "http://localhost:3099"), +) => { + const router = new Hono(); + const proxy = proxyChatRequest(chatServerUrl); + + router.get("/slack/oauth/callback", proxy); + router.post("/slack/events", proxy); + router.post("/slack/interactions", proxy); + + return router; +}; diff --git a/server/src/routers/internalRouter.ts b/server/src/routers/internalRouter.ts index 907fd6285..0ceb1b3cb 100644 --- a/server/src/routers/internalRouter.ts +++ b/server/src/routers/internalRouter.ts @@ -12,6 +12,7 @@ import { traceEnrichMiddleware } from "../honoMiddlewares/traceMiddleware"; import type { HonoEnv } from "../honoUtils/HonoEnv"; import { honoAdminRouter } from "../internal/admin/adminRouter"; import { internalAnalyticsRouter } from "../internal/analytics/internalAnalyticsRouter"; +import { chatRouter } from "../internal/chat/chatRouter"; import { internalCusRouter } from "../internal/customers/internalCusRouter"; import { internalDevRouter } from "../internal/dev/devRouter"; import { migrationRpcRouter } from "../internal/migrations/v2/migrationRouter"; @@ -19,6 +20,7 @@ import { consentRouter } from "../internal/misc/consent/consentRouter"; import { feedbackRouter } from "../internal/misc/feedback/feedbackRouter"; import { pricingAgentRouter } from "../internal/misc/pricingAgent/pricingAgentRouter"; import { savedViewsRouter } from "../internal/misc/savedViews/savedViewsRouter"; +import { invoiceTemplateRouter } from "../internal/orgs/invoiceTemplates/invoiceTemplateRouter"; import { internalOrgRouter } from "../internal/orgs/orgRouter"; import { internalProductRouter } from "../internal/products/internalProductRouter"; import { workbenchRouter } from "../internal/workbench/workbenchRouter"; @@ -38,6 +40,8 @@ internalRouter.use("/admin/*", adminAuthMiddleware); internalRouter.route("admin", honoAdminRouter); internalRouter.route("organization", internalOrgRouter); +internalRouter.route("organization/chat", chatRouter); +internalRouter.route("/invoice_templates", invoiceTemplateRouter); internalRouter.route("/products", internalProductRouter); internalRouter.route("/customers", internalCusRouter); internalRouter.route("/dev", internalDevRouter); diff --git a/server/src/routers/mcpProxyRouter.ts b/server/src/routers/mcpProxyRouter.ts deleted file mode 100644 index bffcecc49..000000000 --- a/server/src/routers/mcpProxyRouter.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Hono } from "hono"; -import type { Context } from "hono"; -import type { HonoEnv } from "../honoUtils/HonoEnv.js"; - -const hopByHopHeaders = [ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", -]; - -const getMcpUpstream = () => { - const upstream = process.env.MCP_UPSTREAM_URL; - if (!upstream) return null; - - try { - return new URL(upstream); - } catch { - return null; - } -}; - -const proxyMcp = async (c: Context) => { - const upstream = getMcpUpstream(); - if (!upstream) { - return c.json({ error: "MCP upstream not configured" }, 503); - } - - const incomingUrl = new URL(c.req.url); - const targetUrl = new URL(incomingUrl.pathname + incomingUrl.search, upstream); - const headers = new Headers(c.req.raw.headers); - const forwardedHost = - headers.get("x-forwarded-host") ?? headers.get("host") ?? incomingUrl.host; - const forwardedProto = - headers.get("x-forwarded-proto") ?? incomingUrl.protocol.replace(":", ""); - - for (const header of hopByHopHeaders) headers.delete(header); - - headers.delete("host"); - headers.set("x-autumn-forwarded-host", forwardedHost); - headers.set("x-autumn-forwarded-proto", forwardedProto); - headers.set("x-forwarded-host", forwardedHost); - headers.set("x-forwarded-proto", forwardedProto); - - const hasBody = c.req.method !== "GET" && c.req.method !== "HEAD"; - const response = await fetch(targetUrl, { - method: c.req.method, - headers, - body: hasBody ? c.req.raw.body : undefined, - duplex: hasBody ? "half" : undefined, - } as RequestInit & { duplex?: "half" }); - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); -}; - -export const mcpProxyRouter = new Hono(); - -mcpProxyRouter.all("/mcp", proxyMcp); -mcpProxyRouter.all("/mcp/*", proxyMcp); -mcpProxyRouter.all("/internal/mcp", proxyMcp); -mcpProxyRouter.all("/internal/mcp/*", proxyMcp); -mcpProxyRouter.all("/.well-known/oauth-protected-resource/mcp", proxyMcp); -mcpProxyRouter.all("/.well-known/oauth-protected-resource/internal/mcp", proxyMcp); diff --git a/server/src/routers/rpcRouter.ts b/server/src/routers/rpcRouter.ts index b36b08d42..ae0ebf180 100644 --- a/server/src/routers/rpcRouter.ts +++ b/server/src/routers/rpcRouter.ts @@ -1,11 +1,14 @@ import { Hono } from "hono"; +import { agentRulesRpcRouter } from "@/internal/agent/rules/agentRulesRouter"; import { referralRpcRouter } from "@/internal/api/rewards/referralRouter"; import { balancesRpcRouter } from "@/internal/balances/balancesRouter"; import { billingRpcRouter } from "@/internal/billing/billingRouter"; import { entityRpcRouter } from "@/internal/entities/entityRouter"; import { eventsRpcRouter } from "@/internal/events/eventsRouter"; import { featureRpcRouter } from "@/internal/features/featureRouter"; +import { logsRpcRouter } from "@/internal/logs/logsRouter"; import { migrationRpcRouter } from "@/internal/migrations/v2/migrationRouter"; +import { platformRpcRouter } from "@/internal/platform/platformBeta/platformRpcRouter"; import { plansRpcRouter } from "@/internal/products/productRouter"; import type { HonoEnv } from "../honoUtils/HonoEnv"; import { customerRpcRouter } from "../internal/customers/cusRouter"; @@ -24,6 +27,7 @@ export const rpcRouter = new Hono(); // rpcRouter.use("*", idempotencyMiddleware); rpcRouter.route("", customerRpcRouter); +rpcRouter.route("", agentRulesRpcRouter); rpcRouter.route("", plansRpcRouter); rpcRouter.route("", billingRpcRouter); rpcRouter.route("", balancesRpcRouter); @@ -31,4 +35,6 @@ rpcRouter.route("", eventsRpcRouter); rpcRouter.route("", referralRpcRouter); rpcRouter.route("", entityRpcRouter); rpcRouter.route("", featureRpcRouter); +rpcRouter.route("", logsRpcRouter); rpcRouter.route("", migrationRpcRouter); +rpcRouter.route("", platformRpcRouter); diff --git a/server/src/trigger/migrations/runMigrationCustomerTask.ts b/server/src/trigger/migrations/runMigrationCustomerTask.ts index ca05eec58..4ac9d0fc4 100644 --- a/server/src/trigger/migrations/runMigrationCustomerTask.ts +++ b/server/src/trigger/migrations/runMigrationCustomerTask.ts @@ -6,6 +6,7 @@ import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCust import { withMigrationItemTracking } from "@/internal/migrations/v2/actions/migrationItem/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; import { migrateCustomer } from "@/internal/migrations/v2/run/migrateCustomer/index.js"; +import { isMigrationCancelRequested } from "@/internal/migrations/v2/run/utils/migrationCancelToken.js"; import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js"; const PayloadSchema = z.object({ @@ -60,6 +61,13 @@ export const runMigrationCustomerTask = task({ data: { migrationInternalId, migrationRunId, customerInternalId }, }); + if (await isMigrationCancelRequested({ migrationRunId })) { + logger.info("run-migration-customer: skipping, cancel requested", { + data: { migrationInternalId, migrationRunId, customerInternalId }, + }); + return; + } + const migration = await migrationRepo.find({ ctx, internalId: migrationInternalId, diff --git a/server/src/trigger/migrations/runMigrationTask.ts b/server/src/trigger/migrations/runMigrationTask.ts index 52fc7f03c..b913cccc5 100644 --- a/server/src/trigger/migrations/runMigrationTask.ts +++ b/server/src/trigger/migrations/runMigrationTask.ts @@ -5,13 +5,22 @@ import { warmupRegionalRedis } from "@/external/redis/initUtils/redisWarmup.js"; import { withMigrationRunTracking } from "@/internal/migrations/v2/actions/migrationRun/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; import { runMigration } from "@/internal/migrations/v2/run/runMigration.js"; +import { RETRYABLE_MIGRATION_ITEM_RUN_STATUSES } from "@/internal/migrations/v2/run/utils/retryItemStatuses.js"; +import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js"; -const ControlsSchema = z.object({ - limit: z.number().int().min(1).optional(), - only: z.array(z.string()).optional(), - concurrency: z.number().int().min(1).optional(), -}).optional(); +const MAX_CONCURRENCY = 5; + +const ControlsSchema = z + .object({ + limit: z.number().int().min(1).optional(), + only: z.array(z.string()).optional(), + concurrency: z.number().int().min(1).max(MAX_CONCURRENCY).optional(), + retryItemStatuses: z + .array(z.enum(RETRYABLE_MIGRATION_ITEM_RUN_STATUSES)) + .optional(), + }) + .optional(); const PayloadSchema = z.object({ orgId: z.string(), @@ -19,6 +28,7 @@ const PayloadSchema = z.object({ migrationId: z.string(), migrationRunId: z.string(), dryRun: z.boolean().default(false), + lazyRun: z.boolean().default(false), controls: ControlsSchema, }); @@ -32,10 +42,18 @@ export const runMigrationTask = task({ id: "run-migration", queue: runMigrationTaskQueue, machine: "medium-1x", - maxDuration: 3600, + // Trigger.dev has no true "disable" — set very high to effectively remove the timeout. + maxDuration: 86400, run: async (rawPayload: unknown, { ctx: triggerCtx }) => { - const { orgId, env, migrationId, migrationRunId, dryRun, controls } = - PayloadSchema.parse(rawPayload); + const { + orgId, + env, + migrationId, + migrationRunId, + dryRun, + lazyRun, + controls, + } = PayloadSchema.parse(rawPayload); const { ctx, logger } = await createTriggerContext({ orgId, @@ -65,43 +83,50 @@ export const runMigrationTask = task({ onlyCount: controls?.only?.length, limit: controls?.limit, concurrency: controls?.concurrency, + retryItemStatuses: controls?.retryItemStatuses, }, }); - await withMigrationRunTracking({ - ctx, - migrationRunId, - run: async () => { - const migration = await migrationRepo.find({ ctx, id: migrationId }); + try { + await withMigrationRunTracking({ + ctx, + migrationRunId, + run: async () => { + const migration = await migrationRepo.find({ ctx, id: migrationId }); - // Default concurrency: 10 normally, 25 when no_billing_changes - // because we're not hitting Stripe per customer. Caller can still - // override via controls.concurrency. - const defaultConcurrency = - migration.no_billing_changes === true ? 25 : 10; - const effectiveControls = { - ...(controls ?? {}), - concurrency: controls?.concurrency ?? defaultConcurrency, - }; + const effectiveControls = { + ...(controls ?? {}), + concurrency: controls?.concurrency ?? MAX_CONCURRENCY, + }; - logger.info("run-migration: resolved controls", { - data: { + logger.info("run-migration: resolved controls", { + data: { + migrationRunId, + noBillingChanges: migration.no_billing_changes === true, + concurrency: effectiveControls.concurrency, + concurrencyExplicit: controls?.concurrency !== undefined, + }, + }); + + await runMigration({ + ctx, + migration, + dryRun, migrationRunId, - noBillingChanges: migration.no_billing_changes === true, - concurrency: effectiveControls.concurrency, - concurrencyExplicit: controls?.concurrency !== undefined, - }, + controls: effectiveControls, + }); + }, + }); + } finally { + if (lazyRun && !dryRun) { + await clearOrgCache({ + db: ctx.db, + orgId, + env, + logger, }); - - await runMigration({ - ctx, - migration, - dryRun, - migrationRunId, - controls: effectiveControls, - }); - }, - }); + } + } logger.info("run-migration: done", { data: { diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts index b878e08fc..c4a51e66f 100644 --- a/server/src/utils/auth.ts +++ b/server/src/utils/auth.ts @@ -65,13 +65,8 @@ const emulateGoogleUrl = // OAuth flow leaves and returns via a third-party host (emulate.dev), so the // state cookie must be SameSite=None+Secure to survive the round trip. const isHttpsBaseUrl = process.env.BETTER_AUTH_URL?.startsWith("https://"); -const hostedMcpResourceUrls = - process.env.MCP_UPSTREAM_URL && process.env.BETTER_AUTH_URL - ? [ - new URL("/mcp", process.env.BETTER_AUTH_URL).href, - new URL("/internal/mcp", process.env.BETTER_AUTH_URL).href, - ] - : []; +const isProductionAuth = process.env.NODE_ENV === "production"; + const parseMcpResourceUrl = (rawUrl: string) => { const resourceUrl = rawUrl.trim(); if (!resourceUrl) return null; @@ -83,15 +78,36 @@ const parseMcpResourceUrl = (rawUrl: string) => { return null; } }; -const mcpResourceUrls = - process.env.MCP_RESOURCE_URLS?.split(",") - .map(parseMcpResourceUrl) - .filter((url): url is string => Boolean(url)) ?? []; -const internalMcpResourceUrls = mcpResourceUrls.map((resourceUrl) => { - const url = new URL(resourceUrl); - url.pathname = "/internal/mcp"; - return url.href; -}); + +// Public hosts that serve OAuth-protected MCP endpoints. leaf serves both the +// MCP server (MCP_SERVER_URL) and the chat/slackbot (CHAT_SERVER_URL); the +// autumn server can also proxy /mcp under its own origin (BETTER_AUTH_URL). +// The OAuth `resource` indicator is host-based, so every public host + path +// must be a registered audience. MCP_RESOURCE_URLS is an explicit override. +const mcpServerUrl = + process.env.MCP_SERVER_URL ?? + (isProductionAuth ? "https://mcp.useautumn.com" : "http://localhost:3099"); +const chatServerUrl = + process.env.CHAT_SERVER_URL ?? + (isProductionAuth ? "https://chat.useautumn.com" : "http://localhost:3099"); + +const mcpResourcePaths = ["/mcp"]; +const mcpResourceBases = [ + process.env.BETTER_AUTH_URL, + mcpServerUrl, + chatServerUrl, +].filter((base): base is string => Boolean(base)); + +const mcpResourceUrls = [ + ...new Set([ + ...mcpResourceBases.flatMap((base) => + mcpResourcePaths.map((path) => new URL(path, base).href), + ), + ...(process.env.MCP_RESOURCE_URLS?.split(",") + .map(parseMcpResourceUrl) + .filter((url): url is string => Boolean(url)) ?? []), + ]), +]; /** * Passkey (WebAuthn) is bound to the FRONTEND origin where the browser calls @@ -255,12 +271,9 @@ const options = { // Resource-based scopes with R/W actions (plus legacy CRUDL + // meta scopes — see shared/utils/scopeDefinitions.ts). scopes: [...ALL_SCOPES], - validAudiences: [ - process.env.BETTER_AUTH_URL, - ...hostedMcpResourceUrls, - ...mcpResourceUrls, - ...internalMcpResourceUrls, - ].filter(Boolean) as string[], + validAudiences: [process.env.BETTER_AUTH_URL, ...mcpResourceUrls].filter( + Boolean, + ) as string[], allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, customAccessTokenClaims: ({ referenceId }) => ({ diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 5b68dfbdf..59a08b93d 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -1,33 +1,101 @@ import type { TestGroup } from "./types"; +const activeTempPaths = [ + "integration/billing/attach/free-trial/trial-basic.test.ts", + "integration/billing/attach/free-trial/trial-conversion.test.ts", + "integration/billing/attach/free-trial/trial-downgrade.test.ts", + "integration/billing/attach/free-trial/trial-entity-upgrade.test.ts", + "integration/billing/attach/free-trial/trial-merge.test.ts", +]; + +export const tempBacklogPhases = [ + [ + "unit/billing/setup-billing-cycle-anchor.spec.ts", + "unit/billing/stripe-backdate-start-date-utils.spec.ts", + "unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts", + "integration/billing/attach/params/start-date/starts-at-backdate.test.ts", + "integration/billing/attach/params/start-date/starts-at-backdate-invoice.test.ts", + ], + [ + "integration/billing/attach/params/start-date/starts-at-backdate-new-billing-subscription.test.ts", + "integration/billing/attach/params/start-date/starts-at-backdate-scheduled-replacement.test.ts", + "integration/billing/attach/params/start-date/starts-at-validation.test.ts", + "integration/billing/attach/params/start-date/starts-at-scheduling.test.ts", + "integration/billing/attach/params/start-date/starts-at-enable-plan-immediately.test.ts", + ], + [ + "integration/billing/attach/new-plan/attach-paid.test.ts", + "integration/billing/attach/new-plan/attach-addon.test.ts", + "integration/billing/attach/new-plan/attach-entities.test.ts", + "integration/billing/attach/new-plan/new-prepaid.test.ts", + "integration/billing/attach/new-plan/prepaid", + ], + [ + "integration/billing/attach/free-trial", + "integration/billing/attach/free-trial/override", + "integration/billing/attach/params/plan-schedule", + "integration/billing/attach/params/billing-cycle-anchor", + "integration/billing/attach/params/custom-plan/custom-plan-entity.test.ts", + ], + [ + "integration/billing/attach/discounts", + "integration/billing/attach/immediate-switch", + "integration/billing/attach/scheduled-switch", + "integration/billing/attach/checkout/stripe-checkout/stripe-checkout-entities.test.ts", + "integration/billing/attach/checkout/stripe-checkout/stripe-checkout-multi-interval.test.ts", + ], + [ + "integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-entities.test.ts", + "integration/billing/attach/invoice/attach-invoice-finalized-immediate.test.ts", + "integration/billing/attach/invoice/attach-invoice-draft-immediate.test.ts", + "integration/billing/attach/invoice-line-items/backdate-line-items.test.ts", + "integration/billing/attach/invoice-line-items/line-item-discounts.test.ts", + ], + [ + "integration/billing/multi-attach/basic", + "integration/billing/multi-attach/customize", + "integration/billing/multi-attach/multi-attach-paid-features.test.ts", + "integration/billing/multi-attach/multi-attach-multi-interval.test.ts", + "integration/billing/multi-attach/multi-attach-invoice-line-items.test.ts", + ], + [ + "integration/billing/multi-attach/scheduled-switch", + "integration/billing/create-schedule/backdate/create-schedule-backdate.test.ts", + "integration/billing/create-schedule/create-schedule-annual-proration.test.ts", + "integration/billing/create-schedule/phases/create-schedule-phases.test.ts", + "integration/billing/create-schedule/phases/create-schedule-phases-checkout.test.ts", + ], + [ + "integration/billing/create-schedule/phases/create-schedule-phases-replacements.test.ts", + "integration/billing/create-schedule/phases/create-schedule-phases-schedules.test.ts", + "integration/billing/create-schedule/phases/create-schedule-phases-validation.test.ts", + "integration/billing/create-schedule/params/create-schedule-enable-plan-immediately.test.ts", + "integration/billing/create-schedule/params/create-schedule-customize.test.ts", + ], + [ + "integration/billing/create-schedule/params/create-schedule-subscription-id.test.ts", + "integration/billing/create-schedule/one-off-prepaid-preserve/preserve-on-schedule.test.ts", + "integration/billing/update-subscription/billing-behavior/next-cycle-only.test.ts", + "integration/billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts", + "integration/billing/update-subscription/discounts/proration-discount.test.ts", + ], + [ + "integration/billing/update-subscription/discounts/discount-applies-to.test.ts", + "integration/billing/update-subscription/discounts/multiple-discounts.test.ts", + "integration/billing/update-subscription/free-trial", + "integration/billing/update-subscription/params/billing-cycle-anchor/update-sub-anchor-reset-with-changes.test.ts", + "integration/billing/update-subscription/params/billing-cycle-anchor/update-sub-anchor-reset-no-partial-refund.test.ts", + ], + [ + "integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts", + ], +]; + export const temp: TestGroup = { name: "temp", - description: "subscription schedule inline price reuse coverage", + description: + "active temp slice for starts_at and next-cycle preview regressions", tier: "domain", - paths: [ - "integration/billing/autumn-webhooks/billing-updated/billing-updated-create-schedule.test.ts", - "integration/billing/autumn-webhooks/billing-updated/billing-updated-multi-attach.test.ts", - "integration/billing/autumn-webhooks/billing-updated/billing-updated-update-subscription.test.ts", - "integration/billing/create-schedule", - "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-reset.test.ts", - "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-reset-entities.test.ts", - "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-schedule.test.ts", - "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-schedule-entities.test.ts", - "integration/billing/attach/scheduled-switch", - "integration/billing/update-subscription/cancel/end-of-cycle", - "integration/billing/multi-attach/basic/multi-attach-same-addons.test.ts", - "integration/billing/multi-attach/customize/multi-attach-same-addons.test.ts", - "integration/billing/multi-attach/multi-attach-invoice-line-items.test.ts", - "integration/billing/multi-attach/multi-attach-multi-interval.test.ts", - "integration/billing/multi-attach/scheduled-switch/multi-attach-prepaid-cancel-renewal.test.ts", - "integration/billing/multi-attach/subscription-id/multi-attach-subscription-id.test.ts", - "integration/billing/attach/invoice-line-items", - "integration/billing/attach/checkout/stripe-checkout", - "integration/billing/multi-attach/checkout", - "integration/billing/legacy/attach/checkout", - "integration/billing/stripe-webhooks/checkout-session-completed", - "integration/billing/tax/automatic-tax-checkout-session.test.ts", - "scenarios/checkout", - ], + paths: activeTempPaths, maxConcurrency: 2, }; diff --git a/server/tests/_temp/seed-scenarios.ts b/server/tests/_temp/seed-scenarios.ts index f276eecb4..cb023e081 100644 --- a/server/tests/_temp/seed-scenarios.ts +++ b/server/tests/_temp/seed-scenarios.ts @@ -9,7 +9,7 @@ * C. Free product with free messages * D. One-time plan with prepaid messages * E. Premium product (same as pro but with higher prices) - * F. Customer with 2 entity users + * F. Customer with 2 entity uasers * * Run: bun server/tests/_temp/seed-scenarios.ts */ diff --git a/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts b/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts index 390b3307b..39a9b710c 100644 --- a/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts +++ b/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts @@ -6,11 +6,11 @@ import { type ModelMarkups, type ProviderMarkups, } from "@autumn/shared"; -import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { getModelCreditCost } from "@/internal/features/aiCreditSystemUtils.js"; -// Custom models carry their own input/output costs, so getCreditCost resolves -// them without hitting the models.dev pricing fetch — ideal for unit-testing -// the tiered markup resolution (model > provider > global > none). +// Custom models carry their own input/output costs, so getModelCreditCost +// resolves them without hitting the models.dev pricing fetch — ideal for +// unit-testing the tiered markup resolution (model > provider > global > none). const CUSTOM_MODEL = "custom/foo"; const TOKENS = { input: 1000, output: 500 }; // base cost = (1000 * 1000 + 500 * 2000) / 1_000_000 = 2.0 @@ -46,14 +46,13 @@ const makeAiCredit = ({ }); const cost = (creditSystem: Feature) => - getCreditCost({ - featureId: "ai_credits", - creditSystem, - tokens: TOKENS, + getModelCreditCost({ modelName: CUSTOM_MODEL, + creditSystem, + ...TOKENS, }); -describe("getCreditCost — tiered AI markup resolution", () => { +describe("getModelCreditCost — tiered AI markup resolution", () => { test("per-model markup wins over provider and global", async () => { const creditSystem = makeAiCredit({ model_markups: { diff --git a/server/tests/advanced/creditSystems/ai-model-resolution.test.ts b/server/tests/advanced/creditSystems/ai-model-resolution.test.ts index d63689846..bb6830d2d 100644 --- a/server/tests/advanced/creditSystems/ai-model-resolution.test.ts +++ b/server/tests/advanced/creditSystems/ai-model-resolution.test.ts @@ -89,7 +89,7 @@ mock.module("@/internal/features/utils/getModelPricing.js", () => ({ getModelsDevPricing: async () => pricingData, })); -const { getModelCreditCost } = await import( +const { getModelCreditCost, getModelCreditCostBreakdown } = await import( "@/internal/features/aiCreditSystemUtils.js" ); @@ -247,4 +247,30 @@ describe("computeCost — token pools", () => { }); expect(cost).toBeCloseTo(((5 * 1000 + 25 * 500) / PER_MILLION) * 1.5, 10); }); + + test("breakdown reports tier_applied and the tier rates actually used", async () => { + const above = await getModelCreditCostBreakdown({ + modelName: "openai/gpt-5", + creditSystem: makeFeature(), + input: 300_000, + output: 1000, + }); + expect(above.tierApplied).toBe(true); + expect(above.rates.input).toBe(2); + expect(above.rates.cacheRead).toBe(1); + expect(above.baseCost).toBeCloseTo( + (2 * 300_000 + 4 * 1000) / PER_MILLION, + 10, + ); + expect(above.cost).toBe(above.baseCost); + + const below = await getModelCreditCostBreakdown({ + modelName: "openai/gpt-5", + creditSystem: makeFeature(), + input: 1000, + output: 1000, + }); + expect(below.tierApplied).toBe(false); + expect(below.rates.input).toBe(1); + }); }); diff --git a/server/tests/advanced/usage/usage2.test.ts b/server/tests/advanced/usage/usage2.test.ts index 75344a9dd..886ce2e4d 100644 --- a/server/tests/advanced/usage/usage2.test.ts +++ b/server/tests/advanced/usage/usage2.test.ts @@ -104,7 +104,7 @@ describe(`${chalk.yellowBright("usage2: Testing basic usage product")}`, () => { .toNumber(); const featureId = i % 2 === 0 ? TestFeature.Action1 : TestFeature.Action2; - const creditsUsed = await getCreditCost({ + const creditsUsed = getCreditCost({ creditSystem: creditsFeature, featureId: featureId, amount: randomVal, diff --git a/server/tests/advanced/usage/usage3.test.ts b/server/tests/advanced/usage/usage3.test.ts index 6a48c6ebe..c1474e470 100644 --- a/server/tests/advanced/usage/usage3.test.ts +++ b/server/tests/advanced/usage/usage3.test.ts @@ -121,7 +121,7 @@ describe(`${chalk.yellowBright( .toNumber(); const featureId = i % 2 === 0 ? TestFeature.Action1 : TestFeature.Action2; - const creditsUsed = await getCreditCost({ + const creditsUsed = getCreditCost({ creditSystem: creditsFeature, featureId: featureId, amount: randomVal, diff --git a/server/tests/advanced/usage/usage4.test.ts b/server/tests/advanced/usage/usage4.test.ts index 7199ecc5b..3a3d6f1a8 100644 --- a/server/tests/advanced/usage/usage4.test.ts +++ b/server/tests/advanced/usage/usage4.test.ts @@ -105,7 +105,7 @@ describe(`${chalk.yellowBright("usage4: GPU starter annual")}`, () => { .toNumber(); const featureId = i % 2 === 0 ? TestFeature.Action1 : TestFeature.Action2; - const creditsUsed = await getCreditCost({ + const creditsUsed = getCreditCost({ creditSystem: creditsFeature, featureId: featureId, amount: randomVal, diff --git a/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/README.md b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/README.md new file mode 100644 index 000000000..5b8bcf2e5 --- /dev/null +++ b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/README.md @@ -0,0 +1,5 @@ +`update_items` is retired for now. + +These files are reference-only and are excluded from active test discovery and +server typecheck. If `update_items` returns, move them back under the active +migration integration tests and rename `*.deprecated.ts` back to `*.test.ts`. diff --git a/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/update-items-interval-basic.deprecated.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/update-items-interval-basic.deprecated.ts new file mode 100644 index 000000000..bea7b1b4b --- /dev/null +++ b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/update-items-interval-basic.deprecated.ts @@ -0,0 +1,235 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + ResetInterval, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration"; +import { lifetimeCredits } from "./updateIntervalTestUtils"; + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: monthly credits become one-off with and without usage")}`, async () => { + for (const scenario of [ + { + customerId: "migration-update-items-interval-usage", + usage: 40, + remaining: 110, + }, + { + customerId: "migration-update-items-interval-no-usage", + usage: 0, + remaining: 150, + }, + ]) { + const base = products.base({ + id: `${scenario.customerId}-plan`, + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId: scenario.customerId, + setup: [s.customer(), s.products({ list: [base] })], + actions: [ + s.billing.attach({ productId: base.id }), + ...(scenario.usage > 0 + ? [ + s.track({ + featureId: TestFeature.Credits, + value: scenario.usage, + timeout: 2000, + }), + ] + : []), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${scenario.customerId}-mig`, + customerId: scenario.customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + update_items: [ + { + filter: { feature_id: TestFeature.Credits }, + included: 150, + interval: ResetInterval.OneOff, + }, + ], + }, + }, + ], + }, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get( + scenario.customerId, + ); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: scenario.remaining, + usage: scenario.usage, + nextResetAt: null, + planId: base.id, + breakdown: { + [ResetInterval.OneOff]: { + included_grant: 150, + remaining: scenario.remaining, + usage: scenario.usage, + }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get( + scenario.customerId, + ), + count: 0, + }); + } +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: mixed included and interval update carries usage")}`, async () => { + const customerId = "migration-update-items-mixed-included-interval"; + const base = products.base({ + id: "migration-update-items-mixed-included-interval-plan", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [base] })], + actions: [ + s.billing.attach({ productId: base.id }), + s.track({ featureId: TestFeature.Credits, value: 45, timeout: 2000 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + update_items: [ + { + filter: { feature_id: TestFeature.Credits }, + included: 180, + interval: ResetInterval.OneOff, + }, + ], + }, + }, + ], + }, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 135, + usage: 45, + nextResetAt: null, + planId: base.id, + breakdown: { + [ResetInterval.OneOff]: { + included_grant: 180, + remaining: 135, + usage: 45, + }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: free one-off to monthly preserves plan anchor")}`, async () => { + const customerId = "migration-update-items-one-off-to-month-free"; + const base = products.base({ + id: "migration-update-items-one-off-to-month-free-plan", + items: [lifetimeCredits({ includedUsage: 100 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [base] })], + actions: [ + s.billing.attach({ productId: base.id }), + s.track({ featureId: TestFeature.Credits, value: 40, timeout: 2000 }), + ], + }); + const before = await autumnV2_2.customers.get(customerId); + const startedAt = + before.subscriptions.find((subscription) => subscription.plan_id === base.id) + ?.started_at ?? + before.purchases.find((purchase) => purchase.plan_id === base.id) + ?.started_at; + expect(startedAt).toBeDefined(); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + update_items: [ + { + filter: { feature_id: TestFeature.Credits }, + included: 150, + interval: ResetInterval.Month, + }, + ], + }, + }, + ], + }, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 110, + usage: 40, + nextResetAt: addMonths(startedAt!, 1).getTime(), + planId: base.id, + breakdown: { + [ResetInterval.Month]: { + included_grant: 150, + remaining: 110, + usage: 40, + }, + }, + }); +}); diff --git a/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/update-items-interval-carry.deprecated.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/update-items-interval-carry.deprecated.ts new file mode 100644 index 000000000..e52339f7a --- /dev/null +++ b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/update-items-interval-carry.deprecated.ts @@ -0,0 +1,463 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + type ApiEntityV2, + BillingInterval, + BillingMethod, + ResetInterval, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration"; +import { getCreditBucket, lifetimeCredits } from "./updateIntervalTestUtils"; + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: mixed update carries per entity with same-feature cusEnts")}`, async () => { + const customerId = "migration-update-items-mixed-entity-same-feature"; + const base = products.base({ + id: "migration-update-items-mixed-entity-same-feature-plan", + items: [ + items.monthlyCredits({ includedUsage: 100 }), + lifetimeCredits({ includedUsage: 50 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer(), + s.products({ list: [base] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: base.id, entityIndex: 0 }), + s.billing.attach({ productId: base.id, entityIndex: 1 }), + s.track({ + featureId: TestFeature.Credits, + value: 30, + entityIndex: 0, + timeout: 2000, + }), + s.track({ + featureId: TestFeature.Credits, + value: 60, + entityIndex: 1, + timeout: 2000, + }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + interval: BillingInterval.Month, + }, + included: 200, + interval: ResetInterval.OneOff, + }, + ], + }, + }, + ], + }, + runOnServer: false, + }); + + for (const scenario of [ + { entityId: entities[0].id, usage: 30, remaining: 220 }, + { entityId: entities[1].id, usage: 60, remaining: 190 }, + ]) { + const entity = await autumnV2_2.entities.get( + customerId, + scenario.entityId, + ); + expectBalanceCorrect({ + customer: entity, + featureId: TestFeature.Credits, + remaining: scenario.remaining, + usage: scenario.usage, + nextResetAt: null, + planId: base.id, + }); + + const oneOffBuckets = entity.balances[ + TestFeature.Credits + ].breakdown?.filter( + (bucket) => bucket.reset?.interval === ResetInterval.OneOff, + ); + expect(oneOffBuckets).toHaveLength(2); + expect(oneOffBuckets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + included_grant: 50, + remaining: 50, + usage: 0, + }), + expect.objectContaining({ + included_grant: 200, + remaining: scenario.remaining - 50, + usage: scenario.usage, + }), + ]), + ); + } + + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: monthly to one-off preserves existing lifetime usage")}`, async () => { + const customerId = "migration-update-items-lifetime-usage"; + const base = products.base({ + id: "migration-update-items-lifetime-usage-plan", + items: [ + items.monthlyCredits({ includedUsage: 100 }), + lifetimeCredits({ includedUsage: 80 }), + ], + }); + + const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [base] })], + actions: [s.billing.attach({ productId: base.id })], + }); + const initialCustomer = + await autumnV2_2.customers.get(customerId); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 70, + balance_id: getCreditBucket({ + subject: initialCustomer, + resetInterval: ResetInterval.Month, + includedGrant: 100, + }).id, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 50, + balance_id: getCreditBucket({ + subject: initialCustomer, + resetInterval: ResetInterval.OneOff, + includedGrant: 80, + }).id, + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + interval: BillingInterval.Month, + }, + included: 200, + interval: ResetInterval.OneOff, + }, + ], + }, + }, + ], + }, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 220, + usage: 60, + nextResetAt: null, + planId: base.id, + }); + expect(getCreditBucket({ + subject: customer, + resetInterval: ResetInterval.OneOff, + includedGrant: 80, + })).toMatchObject({ remaining: 50, usage: 30 }); + expect(getCreditBucket({ + subject: customer, + resetInterval: ResetInterval.OneOff, + includedGrant: 200, + })).toMatchObject({ remaining: 170, usage: 30 }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: prepaid and usage-based one-off carry stays separated")}`, async () => { + const customerId = "migration-update-items-interval-billing-methods"; + const pro = products.pro({ + id: "migration-update-items-interval-billing-methods-plan", + items: [ + items.prepaid({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 100, + price: 10, + }), + items.consumable({ + featureId: TestFeature.Credits, + includedUsage: 50, + price: 0.1, + }), + ], + }); + + const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Credits, quantity: 300 }], + }), + ], + }); + const initialCustomer = + await autumnV2_2.customers.get(customerId); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 250, + balance_id: getCreditBucket({ + subject: initialCustomer, + resetInterval: ResetInterval.Month, + billingMethod: BillingMethod.Prepaid, + }).id, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 30, + balance_id: getCreditBucket({ + subject: initialCustomer, + resetInterval: ResetInterval.Month, + billingMethod: BillingMethod.UsageBased, + }).id, + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + billing_method: BillingMethod.Prepaid, + interval: BillingInterval.Month, + }, + included: 200, + interval: ResetInterval.OneOff, + }, + { + filter: { + feature_id: TestFeature.Credits, + billing_method: BillingMethod.UsageBased, + interval: BillingInterval.Month, + }, + included: 100, + interval: ResetInterval.OneOff, + }, + ], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 330, + usage: 70, + nextResetAt: null, + planId: pro.id, + }); + expect(getCreditBucket({ + subject: customer, + resetInterval: ResetInterval.OneOff, + billingMethod: BillingMethod.Prepaid, + })).toMatchObject({ + included_grant: 200, + prepaid_grant: 100, + remaining: 250, + usage: 50, + }); + expect(getCreditBucket({ + subject: customer, + resetInterval: ResetInterval.OneOff, + billingMethod: BillingMethod.UsageBased, + })).toMatchObject({ + included_grant: 100, + remaining: 80, + usage: 20, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: carry links do not leak across add-ons")}`, async () => { + const customerId = "migration-update-items-interval-addon-isolation"; + const pro = products.pro({ + id: "migration-update-items-interval-addon-isolation-pro", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + const addon = products.recurringAddOn({ + id: "migration-update-items-interval-addon-isolation-addon", + items: [items.monthlyCredits({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.billing.attach({ productId: addon.id }), + ], + }); + const initialCustomer = + await autumnV2_2.customers.get(customerId); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 70, + balance_id: getCreditBucket({ + subject: initialCustomer, + planId: pro.id, + resetInterval: ResetInterval.Month, + }).id, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 450, + balance_id: getCreditBucket({ + subject: initialCustomer, + planId: addon.id, + resetInterval: ResetInterval.Month, + }).id, + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + interval: BillingInterval.Month, + }, + included: 200, + interval: ResetInterval.OneOff, + }, + ], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 620, + usage: 80, + }); + expect(getCreditBucket({ + subject: customer, + planId: pro.id, + resetInterval: ResetInterval.OneOff, + })).toMatchObject({ + included_grant: 200, + remaining: 170, + usage: 30, + }); + expect(getCreditBucket({ + subject: customer, + planId: addon.id, + resetInterval: ResetInterval.Month, + })).toMatchObject({ + included_grant: 500, + remaining: 450, + usage: 50, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/update-items-interval-paid.deprecated.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/update-items-interval-paid.deprecated.ts new file mode 100644 index 000000000..c548cf842 --- /dev/null +++ b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/update-items-interval-paid.deprecated.ts @@ -0,0 +1,210 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + BillingMethod, + ResetInterval, +} from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem"; +import { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration"; +import { lifetimeCredits } from "./updateIntervalTestUtils"; + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: subscription one-off to monthly uses subscription cycle")}`, async () => { + const customerId = "migration-update-items-one-off-to-month-sub"; + const pro = products.pro({ + id: "migration-update-items-one-off-to-month-sub-plan", + items: [lifetimeCredits({ includedUsage: 100 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ days: 10 }), + s.track({ featureId: TestFeature.Credits, value: 40, timeout: 2000 }), + ], + }); + const before = await autumnV2_2.customers.get(customerId); + const currentPeriodEnd = before.subscriptions.find( + (subscription) => subscription.plan_id === pro.id, + )?.current_period_end; + expect(currentPeriodEnd).not.toBeNull(); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + update_items: [ + { + filter: { feature_id: TestFeature.Credits }, + included: 150, + interval: ResetInterval.Month, + }, + ], + }, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 110, + usage: 40, + nextResetAt: currentPeriodEnd!, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { + included_grant: 150, + remaining: 110, + usage: 40, + }, + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: monthly paid item interval changes are rejected")}`, async () => { + const customerId = "migration-update-items-monthly-paid-rejected"; + const base = products.base({ + id: "migration-update-items-monthly-paid-rejected-plan", + items: [ + items.prepaid({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 100, + price: 10, + }), + items.consumableMessages({ includedUsage: 50, price: 0.1 }), + ], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [s.billing.attach({ productId: base.id })], + }); + + const cases = [ + { + name: "prepaid", + filter: { + feature_id: TestFeature.Credits, + billing_method: BillingMethod.Prepaid, + }, + }, + { + name: "usage-based", + filter: { + feature_id: TestFeature.Messages, + billing_method: BillingMethod.UsageBased, + }, + }, + ]; + + for (const testCase of cases) { + await expect( + runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-${testCase.name}-mig`, + customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + update_items: [ + { + filter: testCase.filter, + interval: ResetInterval.OneOff, + }, + ], + }, + }, + ], + }, + runOnServer: false, + }), + ).rejects.toThrow(/paid items/i); + } +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items interval: one-off prepaid interval changes are rejected")}`, async () => { + const customerId = "migration-update-items-one-off-prepaid-rejected"; + const oneOffPrepaid = constructPrepaidItem({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 100, + price: 10, + isOneOff: true, + }); + const base = products.base({ + id: "migration-update-items-one-off-prepaid-rejected-plan", + items: [oneOffPrepaid], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [s.billing.attach({ productId: base.id })], + }); + + await expect( + runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + billing_method: BillingMethod.Prepaid, + }, + interval: ResetInterval.Month, + }, + ], + }, + }, + ], + }, + runOnServer: false, + }), + ).rejects.toThrow(/paid items/i); +}); diff --git a/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/updateIntervalTestUtils.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/updateIntervalTestUtils.ts new file mode 100644 index 000000000..4b4bc0c96 --- /dev/null +++ b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/interval/updateIntervalTestUtils.ts @@ -0,0 +1,25 @@ +import type { ApiCustomerV5, ApiEntityV2 } from "@autumn/shared"; +import { + getBalanceBucket, + getBalanceBuckets, +} from "@tests/integration/utils/getBalanceBucket"; +import { TestFeature } from "@tests/setup/v2Features"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem"; + +export const lifetimeCredits = ({ + includedUsage = 50, +}: { + includedUsage?: number; +} = {}) => + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage, + interval: null, + }); + +export const getCreditBuckets = (subject: ApiCustomerV5 | ApiEntityV2) => + getBalanceBuckets({ subject, featureId: TestFeature.Credits }); + +export const getCreditBucket = ( + params: Omit[0], "featureId">, +) => getBalanceBucket({ ...params, featureId: TestFeature.Credits }); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-basic.test.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-basic.deprecated.ts similarity index 100% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-basic.test.ts rename to server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-basic.deprecated.ts diff --git a/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-carry-groups.deprecated.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-carry-groups.deprecated.ts new file mode 100644 index 000000000..b4344bcdb --- /dev/null +++ b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-carry-groups.deprecated.ts @@ -0,0 +1,400 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + BillingInterval, + BillingMethod, + ProductItemInterval, + ResetInterval, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { + constructFeatureItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; + +type BalanceBreakdown = NonNullable< + ApiCustomerV5["balances"][string]["breakdown"] +>[number]; + +const dailyCredits = ({ includedUsage = 50 }: { includedUsage?: number } = {}) => + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage, + interval: ProductItemInterval.Day, + }); + +const oneOffPrepaidCredits = ({ + includedUsage = 0, + billingUnits = 100, + price = 10, +}: { + includedUsage?: number; + billingUnits?: number; + price?: number; +} = {}) => + constructPrepaidItem({ + featureId: TestFeature.Credits, + includedUsage, + billingUnits, + price, + isOneOff: true, + }); + +const getBucket = ({ + customer, + billingMethod, + resetInterval, +}: { + customer: ApiCustomerV5; + billingMethod?: BillingMethod; + resetInterval?: ResetInterval | null; +}): BalanceBreakdown => { + const bucket = customer.balances[TestFeature.Credits]?.breakdown?.find( + (candidate) => { + if ( + billingMethod && + candidate.price?.billing_method !== billingMethod + ) { + return false; + } + if (resetInterval === null) return candidate.reset === null; + if (resetInterval) return candidate.reset?.interval === resetInterval; + return true; + }, + ); + expect(bucket).toBeDefined(); + return bucket!; +}; + +test.concurrent(`${chalk.yellowBright("migrations update_items: daily and monthly credits carry separately when both are updated")}`, async () => { + const customerId = "migration-update-items-daily-monthly-carry"; + const base = products.base({ + id: "migration-update-items-daily-monthly-carry-plan", + items: [ + dailyCredits({ includedUsage: 50 }), + items.monthlyCredits({ includedUsage: 100 }), + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 100, + interval: null, + }), + ], + }); + + const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [base] })], + actions: [s.billing.attach({ productId: base.id })], + }); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 20, + interval: ResetInterval.Day, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 60, + interval: ResetInterval.Month, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 70, + interval: ResetInterval.OneOff, + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + interval: ProductItemInterval.Day, + }, + included: 80, + }, + { + filter: { + feature_id: TestFeature.Credits, + interval: BillingInterval.Month, + }, + included: 200, + }, + ], + }, + }, + ], + }, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 280, + usage: 100, + breakdown: { + [ResetInterval.Day]: { included_grant: 80, remaining: 50, usage: 30 }, + [ResetInterval.Month]: { included_grant: 200, remaining: 160, usage: 40 }, + [ResetInterval.OneOff]: { included_grant: 100, remaining: 70, usage: 30 }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items: prepaid and usage-based credits carry by billing method")}`, async () => { + const customerId = "migration-update-items-billing-method-carry"; + const pro = products.pro({ + id: "migration-update-items-billing-method-carry-plan", + items: [ + items.prepaid({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 100, + price: 10, + }), + items.consumable({ + featureId: TestFeature.Credits, + includedUsage: 50, + price: 0.1, + }), + ], + }); + + const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Credits, quantity: 300 }], + }), + ], + }); + const initialCustomer = + await autumnV2_2.customers.get(customerId); + const prepaidBucket = getBucket({ + customer: initialCustomer, + billingMethod: BillingMethod.Prepaid, + }); + const usageBasedBucket = getBucket({ + customer: initialCustomer, + billingMethod: BillingMethod.UsageBased, + }); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 250, + balance_id: prepaidBucket.id, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 30, + balance_id: usageBasedBucket.id, + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + billing_method: BillingMethod.Prepaid, + interval: BillingInterval.Month, + }, + included: 200, + }, + { + filter: { + feature_id: TestFeature.Credits, + billing_method: BillingMethod.UsageBased, + interval: BillingInterval.Month, + }, + included: 100, + }, + ], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 330, + usage: 70, + breakdown: { + [BillingMethod.Prepaid]: { + included_grant: 200, + prepaid_grant: 100, + remaining: 250, + usage: 50, + }, + [BillingMethod.UsageBased]: { + included_grant: 100, + remaining: 80, + usage: 20, + }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items: one-off prepaid balance survives alongside monthly carry")}`, async () => { + const customerId = "migration-update-items-one-off-prepaid-carry"; + const pro = products.pro({ + id: "migration-update-items-one-off-prepaid-carry-plan", + items: [ + items.monthlyCredits({ includedUsage: 100 }), + oneOffPrepaidCredits({ includedUsage: 0, billingUnits: 100 }), + ], + }); + + const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Credits, quantity: 200 }], + }), + ], + }); + const initialCustomer = + await autumnV2_2.customers.get(customerId); + const monthlyBucket = getBucket({ + customer: initialCustomer, + resetInterval: ResetInterval.Month, + }); + const oneOffBucket = getBucket({ + customer: initialCustomer, + billingMethod: BillingMethod.Prepaid, + resetInterval: ResetInterval.OneOff, + }); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 60, + balance_id: monthlyBucket.id, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 150, + balance_id: oneOffBucket.id, + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + interval: BillingInterval.Month, + }, + included: 200, + }, + { + filter: { + feature_id: TestFeature.Credits, + billing_method: BillingMethod.Prepaid, + interval: BillingInterval.OneOff, + }, + included: 25, + }, + ], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 335, + usage: 40, + breakdown: { + [ResetInterval.Month]: { included_grant: 200, remaining: 160, usage: 40 }, + [BillingMethod.Prepaid]: { + included_grant: 175, + prepaid_grant: 0, + remaining: 175, + usage: 0, + }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-credits.deprecated.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-credits.deprecated.ts new file mode 100644 index 000000000..68ab6b752 --- /dev/null +++ b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-credits.deprecated.ts @@ -0,0 +1,416 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + type ApiEntityV2, + BillingInterval, + BillingMethod, + ProductItemInterval, + ResetInterval, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; + +const dailyCredits = ({ includedUsage = 50 }: { includedUsage?: number } = {}) => + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage, + interval: ProductItemInterval.Day, + }); + +const lifetimeCredits = ({ + includedUsage = 50, +}: { + includedUsage?: number; +} = {}) => + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage, + interval: null, + }); + +test.concurrent(`${chalk.yellowBright("migrations update_items: removes daily credits while monthly usage carry stays scoped")}`, async () => { + const customerId = "migration-update-items-credits-daily-remove"; + const base = products.base({ + id: "migration-update-items-credits-daily-remove-plan", + items: [ + dailyCredits({ includedUsage: 50 }), + items.monthlyCredits({ includedUsage: 100 }), + lifetimeCredits({ includedUsage: 100 }), + ], + }); + + const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [base] })], + actions: [s.billing.attach({ productId: base.id })], + }); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 20, + interval: ResetInterval.Day, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 60, + interval: ResetInterval.Month, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 70, + interval: ResetInterval.OneOff, + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + remove_items: [ + { + feature_id: TestFeature.Credits, + interval: ResetInterval.Day, + }, + ], + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + interval: BillingInterval.Month, + }, + included: 200, + }, + ], + }, + }, + ], + }, + }); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [base.id] }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 230, + usage: 70, + planId: base.id, + breakdown: { + [ResetInterval.Month]: { included_grant: 200, remaining: 160, usage: 40 }, + [ResetInterval.OneOff]: { included_grant: 100, remaining: 70, usage: 30 }, + }, + }); + expect( + customer.balances[TestFeature.Credits]?.breakdown?.some( + (bucket) => bucket.reset?.interval === ResetInterval.Day, + ), + ).toBe(false); + + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items: prepaid credits keep prepaid bucket beside lifetime credits")}`, async () => { + const customerId = "migration-update-items-prepaid-credits"; + const pro = products.pro({ + id: "migration-update-items-prepaid-credits-plan", + items: [ + items.prepaid({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 100, + price: 10, + }), + lifetimeCredits({ includedUsage: 50 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Credits, quantity: 300 }], + }), + s.track({ featureId: TestFeature.Credits, value: 125, timeout: 2000 }), + ], + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + interval: BillingInterval.Month, + }, + included: 200, + }, + ], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 225, + usage: 125, + planId: pro.id, + breakdown: { + [BillingMethod.Prepaid]: { + included_grant: 200, + prepaid_grant: 100, + remaining: 175, + usage: 125, + }, + [ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 }, + }, + }); + expect( + customer.balances[TestFeature.Credits]?.breakdown?.filter( + (bucket) => bucket.reset?.interval === ResetInterval.OneOff, + ).length, + ).toBe(1); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items: customer plan monthly credits and addon lifetime credits stay separate")}`, async () => { + const customerId = "migration-update-items-addon-lifetime-credits"; + const pro = products.pro({ + id: "migration-update-items-addon-lifetime-credits-pro", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + const addon = products.recurringAddOn({ + id: "migration-update-items-addon-lifetime-credits-addon", + items: [lifetimeCredits({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.billing.attach({ productId: addon.id }), + ], + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 80, + interval: ResetInterval.Month, + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 400, + interval: ResetInterval.OneOff, + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + interval: BillingInterval.Month, + }, + included: 200, + }, + ], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id, addon.id] }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 580, + usage: 120, + breakdown: { + [ResetInterval.Month]: { included_grant: 200, remaining: 180, usage: 20 }, + [ResetInterval.OneOff]: { + included_grant: 500, + remaining: 400, + usage: 100, + }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_items: entity-level credits are migrated per entity product")}`, async () => { + const customerId = "migration-update-items-entity-credits"; + const pro = products.pro({ + id: "migration-update-items-entity-credits-plan", + items: [ + items.monthlyCredits({ includedUsage: 100 }), + lifetimeCredits({ includedUsage: 50 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + s.track({ + featureId: TestFeature.Credits, + value: 30, + entityIndex: 0, + timeout: 2000, + }), + s.track({ + featureId: TestFeature.Credits, + value: 60, + entityIndex: 1, + timeout: 2000, + }), + ], + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + update_items: [ + { + filter: { + feature_id: TestFeature.Credits, + interval: BillingInterval.Month, + }, + included: 200, + }, + ], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: false, + }); + + const firstEntity = await autumnV2_2.entities.get( + customerId, + entities[0].id, + ); + const secondEntity = await autumnV2_2.entities.get( + customerId, + entities[1].id, + ); + + expectBalanceCorrect({ + customer: firstEntity, + featureId: TestFeature.Credits, + remaining: 220, + usage: 30, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { included_grant: 200, remaining: 170, usage: 30 }, + [ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 }, + }, + }); + expectBalanceCorrect({ + customer: secondEntity, + featureId: TestFeature.Credits, + remaining: 190, + usage: 60, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { included_grant: 200, remaining: 140, usage: 60 }, + [ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-cycle.test.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-cycle.deprecated.ts similarity index 100% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-cycle.test.ts rename to server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-cycle.deprecated.ts diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-mixed.test.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-mixed.deprecated.ts similarity index 100% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-mixed.test.ts rename to server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-mixed.deprecated.ts diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.deprecated.ts similarity index 83% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts rename to server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.deprecated.ts index 94cc9b197..50cc28ae7 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts +++ b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.deprecated.ts @@ -1,20 +1,4 @@ -/** - * TDD coverage for update_items targeting one of several customer entitlements - * for the same feature (monthly + lifetime case). - * - * Contract under test: - * New behaviors: - * - A `PlanItemFilter` that includes `interval` only matches entitlements - * with that interval. Untouched entitlements (different interval) keep - * their balance and reset state exactly as-is. - * - Usage carried via update_items only applies to the entitlement(s) it - * replaced — sibling entitlements for the same feature with usage of - * their own do not get double-deducted. - * - When a single update_items[i].filter matches multiple customer - * entitlements (e.g. feature_id only), all matches are updated. - */ - -import { expect, test } from "bun:test"; +import { test } from "bun:test"; import { type ApiCustomerV3, type ApiCustomerV5, diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-paid-features.test.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-paid-features.deprecated.ts similarity index 100% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-paid-features.test.ts rename to server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-paid-features.deprecated.ts diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-rollover.test.ts b/server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-rollover.deprecated.ts similarity index 100% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-rollover.test.ts rename to server/tests/archives/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-rollover.deprecated.ts diff --git a/server/tests/balances/check/credit-systems/credit-systems1.test.ts b/server/tests/balances/check/credit-systems/credit-systems1.test.ts index 6fe182bee..22f1a2fef 100644 --- a/server/tests/balances/check/credit-systems/credit-systems1.test.ts +++ b/server/tests/balances/check/credit-systems/credit-systems1.test.ts @@ -71,7 +71,7 @@ describe(`${chalk.yellowBright("credit-systems1: test /check on action that uses required_balance: requiredActionUnits, })) as unknown as CheckResponseV2; - const creditCost = await getCreditCost({ + const creditCost = getCreditCost({ featureId: action, creditSystem: creditFeature!, amount: requiredActionUnits, @@ -172,7 +172,7 @@ describe(`${chalk.yellowBright("credit-systems1: test /check on action that uses required_balance: requiredAction1Units, })) as unknown as CheckResponseV0; - const meteredCost = await getCreditCost({ + const meteredCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: requiredAction1Units, @@ -196,7 +196,7 @@ describe(`${chalk.yellowBright("credit-systems1: test /check on action that uses required_balance: requiredAction2Units, })) as unknown as CheckResponseV0; - const meteredCost = await getCreditCost({ + const meteredCost = getCreditCost({ featureId: TestFeature.Action2, creditSystem: creditFeature!, amount: requiredAction2Units, diff --git a/server/tests/balances/check/send-event/send-event3.test.ts b/server/tests/balances/check/send-event/send-event3.test.ts index 4d84de2ca..866be0b0b 100644 --- a/server/tests/balances/check/send-event/send-event3.test.ts +++ b/server/tests/balances/check/send-event/send-event3.test.ts @@ -93,7 +93,7 @@ describe(`${chalk.yellowBright("send-event3: Testing check with track, credit sy send_event: true, })) as unknown as CheckResponseV2; - const creditCost = await getCreditCost({ + const creditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 10, @@ -156,7 +156,7 @@ describe(`${chalk.yellowBright("send-event3: Testing check with track, credit sy allowed: true, customer_id: customerId, feature_id: TestFeature.Credits, - required_balance: await getCreditCost({ + required_balance: getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 10, @@ -167,7 +167,7 @@ describe(`${chalk.yellowBright("send-event3: Testing check with track, credit sy test("should check with track and deduct from credits", async () => { const value = 2.5; - const creditCost = await getCreditCost({ + const creditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: value, diff --git a/server/tests/balances/check/send-event/send-event4.test.ts b/server/tests/balances/check/send-event/send-event4.test.ts index 05ce02f20..aa1a91409 100644 --- a/server/tests/balances/check/send-event/send-event4.test.ts +++ b/server/tests/balances/check/send-event/send-event4.test.ts @@ -82,7 +82,7 @@ describe(`${chalk.yellowBright("send-event4: Testing check with track, unlimited send_event: true, }); - const requiredBalance = await getCreditCost({ + const requiredBalance = getCreditCost({ featureId: TestFeature.Action1, creditSystem: ctx.features.find((f) => f.id === TestFeature.Credits)!, amount: 1000, diff --git a/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts index 5336d752e..85399f694 100644 --- a/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts +++ b/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts @@ -77,7 +77,7 @@ test.concurrent(`${chalk.yellowBright("auto-topup cs1: action track depletes cre // Track 845 units → 845 × 0.2 = 169 credits deducted // Balance: 200 - 169 = 31 → strictly above threshold (30) → does NOT trigger // (exact threshold uses <= in code, so landing on 30 would fire auto top-up) - const action1Cost = await getCreditCost({ + const action1Cost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 845, @@ -98,7 +98,7 @@ test.concurrent(`${chalk.yellowBright("auto-topup cs1: action track depletes cre // Track 10 units of action1 → 10 × 0.2 = 2 credits // Balance: 31 - 2 = 29 → 29 <= threshold → auto top-up fires → 29 + 100 = 129 - const action1CostSmall = await getCreditCost({ + const action1CostSmall = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 10, @@ -169,7 +169,7 @@ test.concurrent(`${chalk.yellowBright("auto-topup cs2: action track depletes cre // Action1 costs 0.2 credits per unit // Track 900 units of action1 → 900 × 0.2 = 180 credits deducted // Balance: 200 - 180 = 20 → auto top-up fires - const action1Cost = await getCreditCost({ + const action1Cost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 900, diff --git a/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts b/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts index 988e87ed8..94487bbe0 100644 --- a/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts +++ b/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts @@ -362,7 +362,7 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit5: credit const creditsFeature = ctx.features.find( (f) => f.id === TestFeature.Credits, )!; - const action1CreditCost = await getCreditCost({ + const action1CreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts b/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts index dab02bfdf..83d0f53c8 100644 --- a/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts +++ b/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts @@ -210,7 +210,7 @@ test.concurrent(`${chalk.yellowBright("check-per-entity-spend-limit4: credit-sys const creditsFeature = ctx.features.find( (f) => f.id === TestFeature.Credits, )!; - const action1CreditCost = await getCreditCost({ + const action1CreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts b/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts index b60c09c95..3c4598022 100644 --- a/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts +++ b/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts @@ -131,12 +131,12 @@ test.concurrent(`${chalk.yellowBright("lock-credit CS-2: cross-boundary lock=8 c }); const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; - const lockCreditCost = await getCreditCost({ + const lockCreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 3, // overflow during lock: 8 - 5 remaining = 3 }); - const extraCreditCost = await getCreditCost({ + const extraCreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 4, // confirm delta: 12 - 8 = 4 more units @@ -289,7 +289,7 @@ test.concurrent(`${chalk.yellowBright("lock-credit CS-4: lock within action1, co // Lock deducted 10 from action1 (→90). Confirm delta=+105: // exhaust remaining 90 from action1 (→0), then 15 overflow → 15×0.2=3 credits. const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; - const overflowCreditCost = await getCreditCost({ + const overflowCreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 15, @@ -628,12 +628,12 @@ test.concurrent(`${chalk.yellowBright("lock-credit CS-9: cross-boundary lock=8 c // Lock deducted: 5 from action1 + 3 overflow (0.6 credits). // Confirm delta = 20 - 8 = 12 more units, action1 is already 0, all go to credits: 12×0.2=2.4. const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; - const lockOverflowCost = await getCreditCost({ + const lockOverflowCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 3, }); - const confirmExtraCost = await getCreditCost({ + const confirmExtraCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 12, @@ -721,7 +721,7 @@ test.concurrent(`${chalk.yellowBright("lock-credit CS-10: confirm no override_va // Balances unchanged from what the lock left const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; - const lockOverflowCost = await getCreditCost({ + const lockOverflowCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 3, // overflow during lock: 8 - 5 remaining = 3 diff --git a/server/tests/integration/balances/recalculate/recalculate-balance.test.ts b/server/tests/integration/balances/recalculate/recalculate-balance.test.ts new file mode 100644 index 000000000..ab95acd6a --- /dev/null +++ b/server/tests/integration/balances/recalculate/recalculate-balance.test.ts @@ -0,0 +1,653 @@ +import { expect, test } from "bun:test"; +import type { + CheckResponseV2, + RecalculateBalancePreview, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// Preview returns the projected remaining per entitlement; these helpers sum +// the before/after sides so tests can assert conservation without depending on +// per-balance ordering. +const sumBefore = (preview: RecalculateBalancePreview) => + preview.entitlements.reduce((sum, entry) => sum + entry.before_remaining, 0); +const sumAfter = (preview: RecalculateBalancePreview) => + preview.entitlements.reduce((sum, entry) => sum + entry.after_remaining, 0); + +// Map a check response's breakdown to { balanceId: current_balance } so tests +// can compare distribution without depending on breakdown ordering. +const breakdownById = (check: CheckResponseV2) => + Object.fromEntries( + (check.balance?.breakdown ?? []).map((entry) => [ + entry.id, + entry.current_balance, + ]), + ); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-1: An overdrawn balance is healed by redistributing its +// usage onto a sibling balance that still has remaining. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-1: overage is redistributed onto a positive balance")}`, + async () => { + const { customerId, autumnV2 } = await initScenario({ + customerId: "recalc-1", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + balance_id: "balance-a", + }); + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 200, + balance_id: "balance-b", + }); + + // Drive balance-a into overage (usage 130 on a grant of 100 -> -30). + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 130, + balance_id: "balance-a", + }); + + // Preview: total usage 130, aggregate remaining conserved at 170, and the + // overdrawn balance recovers while no balance is left negative. + const preview = await autumnV2.balances.previewRecalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(preview.total_usage).toBe(130); + expect(preview.entitlements).toHaveLength(2); + expect(sumBefore(preview)).toBe(170); + expect(sumAfter(preview)).toBe(170); + for (const entry of preview.entitlements) { + expect(entry.after_remaining).toBeGreaterThanOrEqual(0); + } + expect( + preview.entitlements.some( + (entry) => entry.after_remaining > entry.before_remaining, + ), + ).toBe(true); + + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Aggregate remaining is unchanged and nothing is in overage anymore. + const check = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + expect(check.balance?.current_balance).toBe(170); + for (const entry of check.balance?.breakdown ?? []) { + expect(entry.current_balance).toBeGreaterThanOrEqual(0); + } + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-2: Recalculation nets a sibling's overage into the +// displayed remaining, so the aggregate reflects the true +// (granted - usage) remaining afterwards. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-2: nets overage into the displayed remaining")}`, + async () => { + const { customerId, autumnV2 } = await initScenario({ + customerId: "recalc-2", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + balance_id: "balance-a", + }); + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 200, + balance_id: "balance-b", + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 130, + balance_id: "balance-a", + }); + + // Before: balance-a's overage is not netted against balance-b, so the + // displayed remaining is inflated (200 rather than the true 170). + const before = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + + const preview = await autumnV2.balances.previewRecalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const after = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + + // Recalculation reduces the inflated remaining to the true remaining. + expect(after.balance?.current_balance).toBeLessThan( + before.balance?.current_balance ?? 0, + ); + expect(after.balance?.current_balance).toBe(sumAfter(preview)); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-3: The preview endpoint returns the projected diff and +// does NOT persist any changes. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-3: preview returns the diff without persisting")}`, + async () => { + const { customerId, autumnV2 } = await initScenario({ + customerId: "recalc-3", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + balance_id: "balance-a", + }); + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 200, + balance_id: "balance-b", + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 130, + balance_id: "balance-a", + }); + + const before = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + + const preview = await autumnV2.balances.previewRecalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(preview.total_usage).toBe(130); + expect(preview.entitlements).toHaveLength(2); + expect(sumAfter(preview)).toBe(sumBefore(preview)); + expect( + preview.entitlements.some( + (entry) => entry.before_remaining !== entry.after_remaining, + ), + ).toBe(true); + + // Nothing was written: the on-disk distribution is identical. + const after = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + expect(breakdownById(after)).toEqual(breakdownById(before)); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-4: When balances are already distributed (no overage), +// recalculation is a no-op and the preview reports no changes. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-4: no overage is a no-op")}`, + async () => { + const { customerId, autumnV2 } = await initScenario({ + customerId: "recalc-4", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + balance_id: "balance-a", + }); + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 200, + balance_id: "balance-b", + }); + + const before = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + + const preview = await autumnV2.balances.previewRecalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(preview.total_usage).toBe(0); + for (const entry of preview.entitlements) { + expect(entry.after_remaining).toBe(entry.before_remaining); + } + + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const after = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + expect(breakdownById(after)).toEqual(breakdownById(before)); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-5: Recalculating a feature with no balances errors. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-5: missing balance returns an error")}`, + async () => { + const { customerId, autumnV2 } = await initScenario({ + customerId: "recalc-5", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await expectAutumnError({ + func: async () => { + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + }, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-6: When total usage exceeds total grant, the residual +// overage is consolidated onto a single balance and the aggregate is +// still conserved. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-6: residual overage is consolidated when usage exceeds grant")}`, + async () => { + const { customerId, autumnV2 } = await initScenario({ + customerId: "recalc-6", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + balance_id: "balance-a", + }); + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 50, + balance_id: "balance-b", + }); + // Total grant 150, total usage 170 -> aggregate remaining -20. + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 130, + balance_id: "balance-a", + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 40, + balance_id: "balance-b", + }); + + const preview = await autumnV2.balances.previewRecalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(preview.total_usage).toBe(170); + expect(preview.entitlements).toHaveLength(2); + expect(sumAfter(preview)).toBe(-20); + expect(sumBefore(preview)).toBe(-20); + // The overage is consolidated onto exactly one balance. + expect( + preview.entitlements.filter((entry) => entry.after_remaining < 0), + ).toHaveLength(1); + + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Re-previewing the now-recalculated state shows nothing left to do. + const rerun = await autumnV2.balances.previewRecalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + for (const entry of rerun.entitlements) { + expect(entry.after_remaining).toBe(entry.before_remaining); + } + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-7: Redistribution works across three balances. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-7: redistributes across three balances")}`, + async () => { + const { customerId, autumnV2 } = await initScenario({ + customerId: "recalc-7", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + for (const balanceId of ["balance-a", "balance-b", "balance-c"]) { + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + balance_id: balanceId, + }); + } + + // balance-a overdrawn to -30. + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 130, + balance_id: "balance-a", + }); + + const preview = await autumnV2.balances.previewRecalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(preview.total_usage).toBe(130); + expect(preview.entitlements).toHaveLength(3); + expect(sumBefore(preview)).toBe(170); + expect(sumAfter(preview)).toBe(170); + for (const entry of preview.entitlements) { + expect(entry.after_remaining).toBeGreaterThanOrEqual(0); + } + + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const check = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + expect(check.balance?.current_balance).toBe(170); + for (const entry of check.balance?.breakdown ?? []) { + expect(entry.current_balance).toBeGreaterThanOrEqual(0); + } + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-8: Recalculation is idempotent - running it again does +// not change an already-balanced feature. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-8: recalculation is idempotent")}`, + async () => { + const { customerId, autumnV2 } = await initScenario({ + customerId: "recalc-8", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + balance_id: "balance-a", + }); + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 200, + balance_id: "balance-b", + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 130, + balance_id: "balance-a", + }); + + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + const first = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + const second = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + + expect(breakdownById(second)).toEqual(breakdownById(first)); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-9: Redistribution stays within scope - a customer-level +// overage is NOT absorbed by an entity-scoped balance. +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-9: redistribution stays within entity scope")}`, + async () => { + const { customerId, autumnV2, entities } = await initScenario({ + customerId: "recalc-9", + setup: [ + s.customer({ testClock: false }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + const entityId = entities[0].id; + + // Customer-level: one overdrawn (-30), one with remaining (200). + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + balance_id: "cust-a", + }); + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 200, + balance_id: "cust-b", + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 130, + balance_id: "cust-a", + }); + + // Entity-scoped: partially used and positive (no overage in this scope). + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + included_grant: 100, + balance_id: "ent-a", + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + current_balance: 60, + balance_id: "ent-a", + }); + + const entityBefore = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + skip_cache: true, + }); + + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // The entity-owned balance itself is untouched - the customer-level + // overage was not absorbed by it. (The entity-level aggregate also + // reflects shared customer balances, so we target ent-a specifically.) + const entityAfter = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entityId, + skip_cache: true, + }); + expect(breakdownById(entityAfter)["ent-a"]).toBe( + breakdownById(entityBefore)["ent-a"], + ); + expect(breakdownById(entityAfter)["ent-a"]).toBe(60); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// RECALCULATE-10: A fully-used balance (remaining 0, not overdrawn) +// next to positive balances is NOT recalculable - there is no overage to +// absorb, so the preview reports no changes ("already up to date"). +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("recalculate-10: a fully-used balance alongside positives is a no-op")}`, + async () => { + const { customerId, autumnV2 } = await initScenario({ + customerId: "recalc-10", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + balance_id: "balance-a", + }); + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 200, + balance_id: "balance-b", + }); + await autumnV2.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 500, + balance_id: "balance-c", + }); + + // balance-a is fully used (remaining 0) but NOT overdrawn; balance-c is + // partially used but still positive. + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 100, + balance_id: "balance-a", + }); + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + usage: 200, + balance_id: "balance-c", + }); + + const before = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + + // No scope has an overage, so the preview reports no changes. + const preview = await autumnV2.balances.previewRecalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + for (const entry of preview.entitlements) { + expect(entry.after_remaining).toBe(entry.before_remaining); + } + + await autumnV2.balances.recalculate({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const after = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + }); + expect(breakdownById(after)).toEqual(breakdownById(before)); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-credit-system.test.ts b/server/tests/integration/balances/track/basic/track-credit-system.test.ts index a84933213..7cd345c7d 100644 --- a/server/tests/integration/balances/track/basic/track-credit-system.test.ts +++ b/server/tests/integration/balances/track/basic/track-credit-system.test.ts @@ -88,7 +88,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system2: track metered featu expect(customerBefore.features[TestFeature.Credits].balance).toBe(200); const action1Value = 50.25; - const expectedAction1CreditCost = await getCreditCost({ + const expectedAction1CreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: action1Value, @@ -108,7 +108,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system2: track metered featu }); const action2Value = 33.67; - const expectedAction2CreditCost = await getCreditCost({ + const expectedAction2CreditCost = getCreditCost({ featureId: TestFeature.Action2, creditSystem: creditFeature!, amount: action2Value, @@ -209,7 +209,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system3: test deduction orde const deduct2 = 80; const remainingAction1 = 100 - deduct1; const overflowAmount = deduct2 - remainingAction1; - const creditCostForOverflow = await getCreditCost({ + const creditCostForOverflow = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: overflowAmount, @@ -244,7 +244,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system3: test deduction orde const creditsBefore = customer2.features[TestFeature.Credits].balance; const deduct3 = 50.75; - const creditCost3 = await getCreditCost({ + const creditCost3 = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: deduct3, @@ -367,13 +367,13 @@ test.concurrent(`${chalk.yellowBright("track-credit-system4: test deduction with const overflowAction1 = deduct2 - remainingAction1; const overflowAction3 = deduct2 - remainingAction3; - const creditCostAction1 = await getCreditCost({ + const creditCostAction1 = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: overflowAction1, }); - const creditCostAction3 = await getCreditCost({ + const creditCostAction3 = getCreditCost({ featureId: TestFeature.Action3, creditSystem: credit2Feature!, amount: overflowAction3, @@ -419,13 +419,13 @@ test.concurrent(`${chalk.yellowBright("track-credit-system4: test deduction with const deduct3 = 40.25; - const creditCostAction1Deduct3 = await getCreditCost({ + const creditCostAction1Deduct3 = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: deduct3, }); - const creditCostAction3Deduct3 = await getCreditCost({ + const creditCostAction3Deduct3 = getCreditCost({ featureId: TestFeature.Action3, creditSystem: credit2Feature!, amount: deduct3, @@ -556,7 +556,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system5: test deduction orde const deduct2 = 80; const remainingAction1 = 100 - deduct1; const overflowAmount = deduct2 - remainingAction1; - const creditCostForOverflow = await getCreditCost({ + const creditCostForOverflow = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: overflowAmount, @@ -596,7 +596,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system5: test deduction orde const creditsBefore = customer2.features[TestFeature.Credits].balance; const deduct3 = 50.75; - const creditCost3 = await getCreditCost({ + const creditCost3 = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: deduct3, diff --git a/server/tests/integration/balances/track/basic/track-deductions.test.ts b/server/tests/integration/balances/track/basic/track-deductions.test.ts index 7a4b6e6c8..55eb828be 100644 --- a/server/tests/integration/balances/track/basic/track-deductions.test.ts +++ b/server/tests/integration/balances/track/basic/track-deductions.test.ts @@ -306,7 +306,7 @@ test.concurrent( }); const overflowAmount = 50; - const expectedCreditCost = await getCreditCost({ + const expectedCreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: overflowAmount, diff --git a/server/tests/integration/balances/track/basic/track-tokens-limits.test.ts b/server/tests/integration/balances/track/basic/track-tokens-limits.test.ts new file mode 100644 index 000000000..35afcd6dd --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-limits.test.ts @@ -0,0 +1,254 @@ +import { expect, test } from "bun:test"; + +import type { ApiCustomerV5, TrackResponseV3 } from "@autumn/shared"; +import { ErrCode } from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0% +// in=5000/out=2500 -> 0.0625; in=10000/out=5000 -> 0.125 + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-LIM-1: default behavior caps deduction at zero balance +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-lim-1: default behavior caps token deduction at zero balance")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 0.1, + }); + const freeProd = products.base({ id: "free", items: [aiCreditsItem] }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-tokens-lim-1", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // First track: cost 0.0625 fits within the 0.1 balance + const trackRes1: TrackResponseV3 = await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 5000, + output_tokens: 2500, + }); + expect(trackRes1.value).toBeCloseTo(0.0625, 10); + + // Second track: cost 0.125 exceeds the remaining 0.0375 — capped at zero + await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 10000, + output_tokens: 5000, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.AiCredits, + remaining: 0, + usage: 0.1, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-LIM-2: overage_behavior "reject" errors, balance intact +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-lim-2: overage_behavior reject errors with InsufficientBalance")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 0.1, + }); + const freeProd = products.base({ id: "free", items: [aiCreditsItem] }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-tokens-lim-2", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: () => + autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 10000, + output_tokens: 5000, + overage_behavior: "reject", + }), + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.AiCredits, + remaining: 0.1, + usage: 0, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-LIM-3: explicit overage_behavior "cap" deducts up to zero +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-lim-3: explicit overage_behavior cap deducts up to zero")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 0.1, + }); + const freeProd = products.base({ id: "free", items: [aiCreditsItem] }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-tokens-lim-3", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 10000, + output_tokens: 5000, + overage_behavior: "cap", + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.AiCredits, + remaining: 0, + usage: 0.1, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-LIM-4: unlimited balance never rejects or deducts +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-lim-4: unlimited AI credit balance never rejects or deducts")}`, + async () => { + const aiCreditsItem = items.unlimited({ featureId: TestFeature.AiCredits }); + const freeProd = products.base({ id: "free", items: [aiCreditsItem] }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-tokens-lim-4", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 10000, + output_tokens: 5000, + }); + + expect(trackRes.value).toBeCloseTo(0.125, 10); + expect(trackRes.balance).toMatchObject({ + feature_id: TestFeature.AiCredits, + unlimited: true, + usage: 0, + }); + + // Second track: still no deduction, never rejected + await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 20000, + output_tokens: 10000, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expect(customer.balances[TestFeature.AiCredits]).toMatchObject({ + unlimited: true, + usage: 0, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-LIM-5: duplicate idempotency_key rejected, deducts once +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-lim-5: duplicate idempotency_key rejected, deducts once")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 1000, + }); + const freeProd = products.base({ id: "free", items: [aiCreditsItem] }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-tokens-lim-5", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const body = { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 10000, + output_tokens: 5000, + idempotency_key: `track-tokens-idem-${Date.now().toString(36)}`, + }; + + const trackRes: TrackResponseV3 = await autumnV2_2.post( + "/track_tokens", + body, + ); + expect(trackRes.value).toBeCloseTo(0.125, 10); + + await expectAutumnError({ + errCode: ErrCode.DuplicateIdempotencyKey, + func: () => autumnV2_2.post("/track_tokens", body), + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.AiCredits, + remaining: 999.875, + usage: 0.125, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens-orbs.test.ts b/server/tests/integration/balances/track/basic/track-tokens-orbs.test.ts index 36dcde8b8..77f2a4576 100644 --- a/server/tests/integration/balances/track/basic/track-tokens-orbs.test.ts +++ b/server/tests/integration/balances/track/basic/track-tokens-orbs.test.ts @@ -10,13 +10,14 @@ import { Decimal } from "decimal.js"; // ═══════════════════════════════════════════════════════════════════ // TRACK-TOKENS-ORBS: AI credit system nested inside a parent credit system -// Verifies that a single /track/tokens call deducts USD from the AI credit -// feature AND deducts the ratio-mapped amount from any parent credit -// system whose schema references it. +// +// Parent credit systems are overflow pools (same semantics as classic +// metered → credits deduction order): a token track drains the AI credit +// balance first, and only the overflow is ratio-mapped onto the parent. // ═══════════════════════════════════════════════════════════════════ test.concurrent( - `${chalk.yellowBright("track-tokens-orbs: AI credit system inside parent credit system deducts both balances")}`, + `${chalk.yellowBright("track-tokens-orbs-1: AI balance covers the cost — parent orbs untouched")}`, async () => { const aiCreditsItem = items.free({ featureId: TestFeature.AiCredits, @@ -24,7 +25,7 @@ test.concurrent( }); const orbsItem = items.free({ featureId: TestFeature.Orbs, - includedUsage: 50_000, // 50,000 orbs + includedUsage: 50_000, // orbs schema: 1000 orbs per $1 of AI usage }); const freeProd = products.base({ id: "free", @@ -32,7 +33,7 @@ test.concurrent( }); const { customerId, autumnV1, autumnV2 } = await initScenario({ - customerId: "track-tokens-orbs", + customerId: "track-tokens-orbs-1", setup: [ s.customer({ testClock: false }), s.products({ list: [freeProd] }), @@ -49,9 +50,6 @@ test.concurrent( .div(1_000_000) .toNumber(); // 0.125 - // Orbs schema: 1000 orbs per $1 of AI usage - const expectedOrbsCost = new Decimal(expectedUsdCost).mul(1000).toNumber(); // 125 - const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { customer_id: customerId, feature_id: TestFeature.AiCredits, @@ -71,10 +69,61 @@ test.concurrent( usage: expectedUsdCost, }); - // Parent orbs balance dropped by USD cost × 1000 + // AI balance covered the full cost, so the parent overflow pool is untouched expect(customer.features[TestFeature.Orbs]).toMatchObject({ - balance: new Decimal(50_000).minus(expectedOrbsCost).toNumber(), - usage: expectedOrbsCost, + balance: 50_000, + usage: 0, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("track-tokens-orbs-2: cost exceeding AI balance overflows into parent orbs at the schema ratio")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 100, // $100 of AI usage + }); + const orbsItem = items.free({ + featureId: TestFeature.Orbs, + includedUsage: 50_000, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem, orbsItem], + }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "track-tokens-orbs-2", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // (5 * 24M) / 1M = $120 > the $100 AI balance + const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 24_000_000, + output_tokens: 0, + }); + expect(trackRes.value).toBeCloseTo(120, 10); + + const customer = await autumnV1.customers.get(customerId); + + // AI pool fully drained + expect(customer.features[TestFeature.AiCredits]).toMatchObject({ + balance: 0, + usage: 100, + }); + + // $20 overflow lands on orbs at 1000 orbs per $1 + expect(customer.features[TestFeature.Orbs]).toMatchObject({ + balance: new Decimal(50_000).minus(20_000).toNumber(), + usage: 20_000, }); }, ); diff --git a/server/tests/integration/balances/track/basic/track-tokens-paid.test.ts b/server/tests/integration/balances/track/basic/track-tokens-paid.test.ts new file mode 100644 index 000000000..ec1240354 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-paid.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from "bun:test"; + +import type { + ApiCustomerV3, + ApiCustomerV5, + TrackResponseV3, +} from "@autumn/shared"; +import { ErrCode } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0% + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-PAID-1: prepaid AI credits deduct through purchased balance +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-paid-1: prepaid AI credits deduct through purchased balance")}`, + async () => { + const prepaidItem = items.prepaid({ + featureId: TestFeature.AiCredits, + price: 1, + billingUnits: 1, + includedUsage: 2, + }); + const prepaidProduct = products.pro({ + id: "prepaid-ai", + items: [prepaidItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-tokens-paid-1", + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [prepaidProduct] }), + ], + actions: [ + s.attach({ + productId: prepaidProduct.id, + options: [{ feature_id: TestFeature.AiCredits, quantity: 3 }], + }), + ], + }); + + // 2 included + 3 purchased = 5 + const customerBefore = + await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer: customerBefore, + featureId: TestFeature.AiCredits, + granted: 5, + remaining: 5, + }); + + // (5*100000 + 15*100000) / 1e6 = $2.00 + const trackRes1: TrackResponseV3 = await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 100000, + output_tokens: 100000, + }); + expect(trackRes1.value).toBeCloseTo(2, 10); + + // Cost $4 > remaining 3 with reject — errors, balance unchanged + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: () => + autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 200000, + output_tokens: 200000, + overage_behavior: "reject", + }), + }); + + const customerMid = + await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer: customerMid, + featureId: TestFeature.AiCredits, + remaining: 3, + usage: 2, + }); + + // Cost $3.00 drains the remaining balance exactly + const trackRes2: TrackResponseV3 = await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 150000, + output_tokens: 150000, + }); + expect(trackRes2.value).toBeCloseTo(3, 10); + + // Cached vs DB agreement (mutation-log sync is async) + await timeout(6000); + const customerNonCached = await autumnV2_2.customers.get( + customerId, + { skip_cache: "true" }, + ); + expectBalanceCorrect({ + customer: customerNonCached, + featureId: TestFeature.AiCredits, + granted: 5, + remaining: 0, + usage: 5, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-PAID-2: consumable AI credit overage lands on the invoice +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-paid-2: consumable AI credit overage lands on the renewal invoice")}`, + async () => { + const consumableItem = items.consumable({ + featureId: TestFeature.AiCredits, + includedUsage: 1, + price: 1, + billingUnits: 1, + }); + const proProduct = products.pro({ + id: "consumable-ai", + items: [consumableItem], + }); + + const { customerId, autumnV1, autumnV2_2, testClockId } = + await initScenario({ + customerId: "track-tokens-paid-2", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proProduct] }), + ], + actions: [s.attach({ productId: proProduct.id })], + }); + + // (5*200000 + 15*200000) / 1e6 = $4.00 exactly + const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 200000, + output_tokens: 200000, + }); + expect(trackRes.value).toBeCloseTo(4, 10); + + const customerMid = + await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer: customerMid, + featureId: TestFeature.AiCredits, + remaining: 0, + usage: 4, + }); + + await timeout(2000); + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + withPause: true, + }); + + // Renewal invoice: $20 pro base + 3 overage units × $1 = $23. + // Invoice lands via Stripe webhook — poll briefly before asserting. + for (let attempt = 0; attempt < 5; attempt++) { + const customer = await autumnV1.customers.get(customerId); + if ((customer.invoices?.length ?? 0) >= 2) break; + await timeout(10000); + } + await expectCustomerInvoiceCorrect({ + customerId, + count: 2, + latestTotal: 23, + latestInvoiceProductId: proProduct.id, + }); + + // Balance resets for the new cycle + const customerReset = + await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer: customerReset, + featureId: TestFeature.AiCredits, + remaining: 1, + usage: 0, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens-replay.test.ts b/server/tests/integration/balances/track/basic/track-tokens-replay.test.ts new file mode 100644 index 000000000..23e501696 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-replay.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "bun:test"; + +import type { ApiCustomerV3 } from "@autumn/shared"; +import { ApiVersion, ApiVersionClass } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; +import { runQueuedTrack } from "@/internal/balances/track/runQueuedTrack.js"; + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-REPLAY: queued replay + plain value tracks on AI credit features +// +// When Redis fails open, track_tokens queues only the TrackParams body — the +// token context (FeatureDeduction.tokens) is not serialized. The +// replay worker rebuilds deductions from {feature_id, value}, so the USD value +// must deduct 1:1 from the AI credit balance, exactly like the original token +// track would have. Parent credit systems are overflow pools: untouched while +// the AI balance covers the deduction (same as live track_tokens behavior). +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-replay-1: queued replay body deducts AI credits 1:1")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 100, // $100 of AI usage + }); + const orbsItem = items.free({ + featureId: TestFeature.Orbs, + includedUsage: 50_000, // orbs schema: 1000 orbs per $1 of AI usage + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem, orbsItem], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "track-tokens-replay-1", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // The USD cost computed by the original track_tokens call; only this + // survives in the queued body. + const usdCost = 0.125; + + await runQueuedTrack({ + ctx: { ...ctx, apiVersion: new ApiVersionClass(ApiVersion.V2_1) }, + body: { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + value: usdCost, + idempotency_key: `replay-${crypto.randomUUID()}`, + }, + apiVersion: ApiVersion.V2_1, + }); + + const customer = await autumnV1.customers.get(customerId); + + expect(customer.features[TestFeature.AiCredits]).toMatchObject({ + balance: new Decimal(100).minus(usdCost).toNumber(), + usage: usdCost, + }); + expect(customer.features[TestFeature.Orbs]).toMatchObject({ + balance: 50_000, + usage: 0, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("track-tokens-replay-2: plain /track with a USD value deducts an AI credit balance 1:1")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 100, + }); + const orbsItem = items.free({ + featureId: TestFeature.Orbs, + includedUsage: 50_000, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem, orbsItem], + }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "track-tokens-replay-2", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const usdValue = 5; + await autumnV2.post("/track", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + value: usdValue, + }); + + const customer = await autumnV1.customers.get(customerId); + + expect(customer.features[TestFeature.AiCredits]).toMatchObject({ + balance: new Decimal(100).minus(usdValue).toNumber(), + usage: usdValue, + }); + expect(customer.features[TestFeature.Orbs]).toMatchObject({ + balance: 50_000, + usage: 0, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens-resolution.test.ts b/server/tests/integration/balances/track/basic/track-tokens-resolution.test.ts new file mode 100644 index 000000000..691cf5944 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-resolution.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from "bun:test"; + +import type { + ApiCustomerV5, + ApiEntityV2, + TrackResponseV3, +} from "@autumn/shared"; +import { ApiVersion, FeatureType } from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +// custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0% + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-RES-1: entity_id deducts entity balance via auto-resolution +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-res-1: entity_id deducts entity balance via auto-resolution")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const freeProd = products.base({ id: "free", items: [aiCreditsItem] }); + + const { customerId, autumnV2_2, entities } = await initScenario({ + customerId: "track-tokens-res-1", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // No feature_id — exercises AI credit auto-resolution with entity scoping + const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + entity_id: entities[0].id, + model_id: "custom/internal-model", + input_tokens: 10000, + output_tokens: 5000, + }); + + expect(trackRes.customer_id).toBe(customerId); + expect(trackRes.value).toBeCloseTo(0.125, 10); + + const entity0 = await autumnV2_2.entities.get( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: entity0, + featureId: TestFeature.AiCredits, + remaining: 99.875, + usage: 0.125, + }); + + const entity1 = await autumnV2_2.entities.get( + customerId, + entities[1].id, + ); + expectBalanceCorrect({ + customer: entity1, + featureId: TestFeature.AiCredits, + remaining: 100, + usage: 0, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-RES-2: updated model markup applies to subsequent tracks +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-res-2: updated model markup applies to subsequent tracks")}`, + async () => { + const autumn = new AutumnInt({ version: ApiVersion.V2_2 }); + + // Throwaway feature — never mutate the shared AiCredits fixtures + const featureId = `ai_credits_mut_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; + await autumn.post("/features.create", { + feature_id: featureId, + name: "AI Credits Mutable", + type: FeatureType.AiCreditSystem, + model_markups: { + "custom/mut-model": { markup: 0, input_cost: 10, output_cost: 20 }, + }, + }); + + const aiCreditsItem = items.free({ featureId, includedUsage: 1000 }); + const freeProd = products.base({ id: "free-mut", items: [aiCreditsItem] }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-tokens-res-2", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const trackBody = { + customer_id: customerId, + feature_id: featureId, + model_id: "custom/mut-model", + input_tokens: 10000, + output_tokens: 5000, + }; + + // Markup 0 → base cost (10*10000 + 20*5000)/1e6 = 0.2 + const trackRes1: TrackResponseV3 = await autumnV2_2.post( + "/track_tokens", + trackBody, + ); + expect(trackRes1.value).toBeCloseTo(0.2, 10); + + // Bump the model markup to 100% + await autumn.post("/features.update", { + feature_id: featureId, + model_markups: { + "custom/mut-model": { markup: 100, input_cost: 10, output_cost: 20 }, + }, + }); + + // Explicit feature_id resolves from freshly loaded org features → 0.4 + const trackRes2: TrackResponseV3 = await autumnV2_2.post( + "/track_tokens", + trackBody, + ); + expect(trackRes2.value).toBeCloseTo(0.4, 10); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId, + remaining: 999.4, + usage: 0.6, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens.test.ts b/server/tests/integration/balances/track/basic/track-tokens.test.ts index d500f4e8a..fb891fac8 100644 --- a/server/tests/integration/balances/track/basic/track-tokens.test.ts +++ b/server/tests/integration/balances/track/basic/track-tokens.test.ts @@ -1,13 +1,21 @@ import { expect, test } from "bun:test"; -import type { ApiCustomerV3, TrackResponseV2 } from "@autumn/shared"; +import type { + ApiCustomerV3, + ApiCustomerV5, + TrackResponseV2, + TrackResponseV3, +} from "@autumn/shared"; +import { ErrCode } from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { Decimal } from "decimal.js"; -import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { getModelCreditCost } from "@/internal/features/aiCreditSystemUtils.js"; // ═══════════════════════════════════════════════════════════════════ // TRACK-TOKENS-1: Basic trackTokens with models.dev pricing @@ -49,11 +57,11 @@ test.concurrent( const outputTokens = 500; const modelId = "anthropic/claude-sonnet-4-20250514"; - const expectedCost = await getCreditCost({ - featureId: aiCreditFeature.id, - creditSystem: aiCreditFeature, + const expectedCost = await getModelCreditCost({ modelName: modelId, - tokens: { input: inputTokens, output: outputTokens }, + creditSystem: aiCreditFeature, + input: inputTokens, + output: outputTokens, }); const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { @@ -132,11 +140,11 @@ test.concurrent( const outputTokens = 1000; const modelId = "anthropic/claude-sonnet-4-20250514"; - const expectedCost = await getCreditCost({ - featureId: aiCreditFeature.id, - creditSystem: aiCreditFeature, + const expectedCost = await getModelCreditCost({ modelName: modelId, - tokens: { input: inputTokens, output: outputTokens }, + creditSystem: aiCreditFeature, + input: inputTokens, + output: outputTokens, }); const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { @@ -276,11 +284,11 @@ test.concurrent( const outputTokens = 10000; const modelId = "anthropic/claude-3-5-haiku-20241022"; - const expectedCost = await getCreditCost({ - featureId: aiCreditFeature.id, - creditSystem: aiCreditFeature, + const expectedCost = await getModelCreditCost({ modelName: modelId, - tokens: { input: inputTokens, output: outputTokens }, + creditSystem: aiCreditFeature, + input: inputTokens, + output: outputTokens, }); const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { @@ -350,11 +358,11 @@ test.concurrent( } // First track: custom/internal-model (input_cost=5, output_cost=15, markup=0%) - const cost1 = await getCreditCost({ - featureId: aiCreditFeature.id, - creditSystem: aiCreditFeature, + const cost1 = await getModelCreditCost({ modelName: "custom/internal-model", - tokens: { input: 5000, output: 2000 }, + creditSystem: aiCreditFeature, + input: 5000, + output: 2000, }); await autumnV2.post("/track_tokens", { @@ -366,11 +374,11 @@ test.concurrent( }); // Second track: custom/marked-up-model (input_cost=10, output_cost=30, markup=50%) - const cost2 = await getCreditCost({ - featureId: aiCreditFeature.id, - creditSystem: aiCreditFeature, + const cost2 = await getModelCreditCost({ modelName: "custom/marked-up-model", - tokens: { input: 3000, output: 1000 }, + creditSystem: aiCreditFeature, + input: 3000, + output: 1000, }); await autumnV2.post("/track_tokens", { @@ -390,3 +398,186 @@ test.concurrent( }); }, ); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-6: custom/* model without configured costs errors +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-6: custom model missing input_cost/output_cost errors")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 1000, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-tokens-6", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: "missing input_cost or output_cost", + func: () => + autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/unconfigured-model", + input_tokens: 100, + output_tokens: 50, + }), + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.AiCredits, + remaining: 1000, + usage: 0, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-7: cache/audio/reasoning pools forwarded end-to-end +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-7: cache/audio/reasoning token pools are billed end-to-end")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 1000, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem], + }); + + const { customerId, autumnV2_2, ctx } = await initScenario({ + customerId: "track-tokens-7", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const aiCreditFeature = ctx.features.find( + (f) => f.id === TestFeature.AiCredits, + ); + if (!aiCreditFeature) { + throw new Error(`${TestFeature.AiCredits} feature not found`); + } + + // Total input (input + cache pools) stays far below the 200k tier threshold + const modelId = "anthropic/claude-sonnet-4-20250514"; + const pools = { + input: 10000, + output: 5000, + cacheRead: 20000, + cacheWrite: 8000, + audioInput: 1000, + audioOutput: 1000, + reasoning: 4000, + }; + + const expectedCost = await getModelCreditCost({ + modelName: modelId, + creditSystem: aiCreditFeature, + ...pools, + }); + + // Pools must increase the bill vs text-only — otherwise the assertion + // below couldn't tell whether the HTTP layer forwarded them at all. + const textOnlyCost = await getModelCreditCost({ + modelName: modelId, + creditSystem: aiCreditFeature, + input: pools.input, + output: pools.output, + }); + expect(expectedCost).toBeGreaterThan(textOnlyCost); + + const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: modelId, + input_tokens: pools.input, + output_tokens: pools.output, + cache_read_tokens: pools.cacheRead, + cache_write_tokens: pools.cacheWrite, + audio_input_tokens: pools.audioInput, + audio_output_tokens: pools.audioOutput, + reasoning_tokens: pools.reasoning, + }); + + expect(trackRes.value).toBeCloseTo(expectedCost, 10); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.AiCredits, + remaining: new Decimal(1000).minus(expectedCost).toNumber(), + usage: expectedCost, + }); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// TRACK-TOKENS-8: custom models bill input/output only +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("track-tokens-8: custom models ignore cache/audio/reasoning pools")}`, + async () => { + const aiCreditsItem = items.free({ + featureId: TestFeature.AiCredits, + includedUsage: 1000, + }); + const freeProd = products.base({ + id: "free", + items: [aiCreditsItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-tokens-8", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0% + // Pool tokens are dropped for custom models, so cost is text-only. + const expectedCost = new Decimal(5) + .mul(10000) + .add(new Decimal(15).mul(5000)) + .div(1_000_000) + .toNumber(); // 0.125 + + const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", { + customer_id: customerId, + feature_id: TestFeature.AiCredits, + model_id: "custom/internal-model", + input_tokens: 10000, + output_tokens: 5000, + cache_read_tokens: 20000, + cache_write_tokens: 8000, + audio_input_tokens: 1000, + audio_output_tokens: 1000, + reasoning_tokens: 4000, + }); + + expect(trackRes.value).toBeCloseTo(expectedCost, 10); + }, +); diff --git a/server/tests/integration/balances/track/overage-allowed/track-overage-allowed-consumable.test.ts b/server/tests/integration/balances/track/overage-allowed/track-overage-allowed-consumable.test.ts index 636513eb9..6e3242fad 100644 --- a/server/tests/integration/balances/track/overage-allowed/track-overage-allowed-consumable.test.ts +++ b/server/tests/integration/balances/track/overage-allowed/track-overage-allowed-consumable.test.ts @@ -368,7 +368,7 @@ test.concurrent(`${chalk.yellowBright("track-consumable-overage-8: credit system (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = await getCreditCost({ + const action1CreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts index 25c11d2ec..64e0b3622 100644 --- a/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts +++ b/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts @@ -430,7 +430,7 @@ test.concurrent(`${chalk.yellowBright("track-customer-spend-limit6: credit-syste const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = await getCreditCost({ + const action1CreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts index 79fae21fa..85c5ab273 100644 --- a/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts +++ b/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts @@ -463,7 +463,7 @@ test.concurrent(`${chalk.yellowBright("track-entity-product-spend-limit5: credit const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = await getCreditCost({ + const action1CreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts index 89822ddad..e61f687bd 100644 --- a/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts +++ b/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts @@ -489,7 +489,7 @@ test.concurrent(`${chalk.yellowBright("track-per-entity-spend-limit5: credit-sys const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = await getCreditCost({ + const action1CreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts index 72ebef0b4..00422b328 100644 --- a/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts +++ b/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts @@ -216,7 +216,7 @@ test.concurrent(`${chalk.yellowBright("track-postgres-entity-spend-limit3: credi const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = await getCreditCost({ + const action1CreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, @@ -334,7 +334,7 @@ test.concurrent(`${chalk.yellowBright("track-postgres-entity-spend-limit4: prepa const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = await getCreditCost({ + const action1CreditCost = getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/track/track-global-idempotency-4xx.test.ts b/server/tests/integration/balances/track/track-global-idempotency-4xx.test.ts new file mode 100644 index 000000000..57ea54007 --- /dev/null +++ b/server/tests/integration/balances/track/track-global-idempotency-4xx.test.ts @@ -0,0 +1,52 @@ +/** + * Regression: pre-side-effect 4xx track failures must not burn the global Idempotency-Key. + * Before this, retrying returned duplicate_idempotency_key instead of the original 4xx. + */ + +import { expect, test } from "bun:test"; + +import { ErrCode } from "@autumn/shared"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +test.concurrent( + `${chalk.yellowBright("track-global-idempotency-4xx: retries return original 4xx")}`, + async () => { + const { autumnV1, customerId } = await initScenario({ + customerId: "track-global-idempotency-4xx", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + const idempotencyKey = `track-global-idempotency-4xx-${Date.now().toString(36)}`; + const trackMissingEntity = async () => + autumnV1.post( + "/track", + { + customer_id: customerId, + entity_id: `${customerId}-missing-entity`, + event_name: "messages", + value: 1, + }, + { "Idempotency-Key": idempotencyKey }, + ); + + const getErrorCode = async () => { + try { + await trackMissingEntity(); + } catch (error) { + if (error && typeof error === "object" && "code" in error) { + return String(error.code); + } + + throw error; + } + + throw new Error("Expected track to fail"); + }; + + const firstCode = await getErrorCode(); + expect(firstCode).not.toBe(ErrCode.DuplicateIdempotencyKey); + expect(await getErrorCode()).toBe(firstCode); + }, +); diff --git a/server/tests/integration/balances/track/usage-alerts/usage-alert-basic.test.ts b/server/tests/integration/balances/track/usage-alerts/usage-alert-basic.test.ts index 72cdad3b5..491aa862a 100644 --- a/server/tests/integration/balances/track/usage-alerts/usage-alert-basic.test.ts +++ b/server/tests/integration/balances/track/usage-alerts/usage-alert-basic.test.ts @@ -114,6 +114,51 @@ test(`${chalk.yellowBright("usage-alert1: usage threshold crossing triggers webh expect(data.usage_alert.threshold_type).toBe("usage"); }); +test(`${chalk.yellowBright("usage-alert1b: usage threshold fires when usage lands exactly on threshold")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const freeProd = products.base({ + id: "ua-threshold-exact-1", + items: [messagesItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "usage-alert-threshold-exact-1", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await setCustomerUsageAlerts({ + autumn: autumnV2_1, + customerId, + usageAlerts: [ + { + feature_id: TestFeature.Messages, + threshold: 500, + threshold_type: "usage", + enabled: true, + }, + ], + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 500, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "balances.usage_alert_triggered" && + payload.data?.customer_id === customerId && + payload.data?.usage_alert?.threshold === 500, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result!.payload.data.usage_alert.threshold_type).toBe("usage"); +}); + // ═══════════════════════════════════════════════════════════════════════════════ // TEST 2: Usage percentage threshold crossing triggers webhook // ═══════════════════════════════════════════════════════════════════════════════ @@ -170,6 +215,53 @@ test(`${chalk.yellowBright("usage-alert2: usage percentage threshold crossing tr expect(data.usage_alert.threshold_type).toBe("usage_percentage"); }); +test(`${chalk.yellowBright("usage-alert2b: usage percentage threshold fires at exact percentage")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const freeProd = products.base({ + id: "ua-pct-exact-1", + items: [messagesItem], + }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "usage-alert-pct-exact-1", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + await setCustomerUsageAlerts({ + autumn: autumnV2_1, + customerId, + usageAlerts: [ + { + feature_id: TestFeature.Messages, + threshold: 100, + threshold_type: "usage_percentage", + enabled: true, + }, + ], + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1000, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "balances.usage_alert_triggered" && + payload.data?.customer_id === customerId && + payload.data?.usage_alert?.threshold === 100, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result!.payload.data.usage_alert.threshold_type).toBe( + "usage_percentage", + ); +}); + // ═══════════════════════════════════════════════════════════════════════════════ // TEST 3: Alert does not re-fire after already crossed // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/server/tests/integration/balances/track/usage-alerts/usage-alert-org.test.ts b/server/tests/integration/balances/track/usage-alerts/usage-alert-org.test.ts index 13a4e3329..90b649412 100644 --- a/server/tests/integration/balances/track/usage-alerts/usage-alert-org.test.ts +++ b/server/tests/integration/balances/track/usage-alerts/usage-alert-org.test.ts @@ -9,8 +9,8 @@ * customer-level and entity-level alerts. * - Org alerts fire INDEPENDENTLY of customer alerts (idempotency key * takes a scope segment so Svix does not dedup them). - * - Org alerts evaluate against the customer-level balance only — they do - * not iterate per entity. + * - Org alerts evaluate against the tracked subject balance, including + * the entity balance when the track call is entity-scoped. * - Disabled org alerts (enabled: false) do not fire. * - Org alert with no feature_id fires on usage of any feature (global). * Side effects: @@ -158,6 +158,62 @@ test(`${chalk.yellowBright("org-alert1: org-level alert fires when customer cros expect(data.usage_alert.name).toBe("org-threshold-750"); }); +// Red: org/global alerts used the customer balance and missed entity usage. +// Green: a 100% org alert fires when an entity-scoped balance lands exactly at 100%. +test(`${chalk.yellowBright("org-alert1b: org usage_percentage alert fires for entity balance at 100%")}`, async () => { + const perEntityMessages = items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const prod = products.base({ + id: "org-ua-entity-100pct", + items: [perEntityMessages], + }); + + await setOrgUsageAlerts([ + { + feature_id: TestFeature.Messages, + threshold: 100, + threshold_type: "usage_percentage", + enabled: true, + name: "org-entity-100pct", + }, + ]); + + const { customerId, autumnV2_1, entities } = await initScenario({ + customerId: "org-usage-alert-entity-100pct", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [prod] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: prod.id })], + }); + + await autumnV2_1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 100, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "balances.usage_alert_triggered" && + payload.data?.customer_id === customerId && + payload.data?.entity_id === entities[0].id && + payload.data?.usage_alert?.name === "org-entity-100pct", + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result!.payload.data.usage_alert.threshold).toBe(100); + expect(result!.payload.data.usage_alert.threshold_type).toBe( + "usage_percentage", + ); +}); + // ═══════════════════════════════════════════════════════════════════════════════ // TEST 2: Org alert applies to ALL customers (not customer-specific) // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/server/tests/integration/billing/attach/attach-metadata.test.ts b/server/tests/integration/billing/attach/attach-metadata.test.ts index cdb166b6f..1c3d894ee 100644 --- a/server/tests/integration/billing/attach/attach-metadata.test.ts +++ b/server/tests/integration/billing/attach/attach-metadata.test.ts @@ -215,14 +215,24 @@ test.concurrent(`${chalk.yellowBright("metadata: passthrough via Stripe checkout customer_id: customerId, plan_id: pro.id, metadata: { - source: "web", - campaign_id: "camp-789", + datafast_visitor_id: "visitor-789", + datafast_session_id: "session-789", }, }); expect(result.payment_url).toBeDefined(); expect(result.payment_url).toContain("checkout.stripe.com"); + const checkoutPathParts = new URL(result.payment_url).pathname.split("/"); + const checkoutSessionId = checkoutPathParts[checkoutPathParts.length - 1]; + expect(checkoutSessionId).toBeDefined(); + + const checkoutSession = await ctx.stripeCli.checkout.sessions.retrieve( + checkoutSessionId!, + ); + expect(checkoutSession.metadata?.datafast_visitor_id).toBe("visitor-789"); + expect(checkoutSession.metadata?.datafast_session_id).toBe("session-789"); + await completeStripeCheckoutForm({ url: result.payment_url }); await timeout(12000); @@ -251,6 +261,6 @@ test.concurrent(`${chalk.yellowBright("metadata: passthrough via Stripe checkout (sub) => sub.status === "active" || sub.status === "trialing", ); expect(subscription).toBeDefined(); - expect(subscription!.metadata.source).toBe("web"); - expect(subscription!.metadata.campaign_id).toBe("camp-789"); + expect(subscription!.metadata.datafast_visitor_id).toBe("visitor-789"); + expect(subscription!.metadata.datafast_session_id).toBe("session-789"); }); diff --git a/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-entities.test.ts b/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-entities.test.ts index 166fa58bf..4a4c63544 100644 --- a/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-entities.test.ts +++ b/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-entities.test.ts @@ -3,6 +3,7 @@ import type { ApiCustomerV3, ApiCustomerV5, ApiEntityV0, + ApiEntityV2, CustomerBillingControls, } from "@autumn/shared"; import { BillingMethod } from "@autumn/shared"; @@ -334,6 +335,10 @@ test.concurrent(`${chalk.yellowBright("attach: stripe checkout monthly volume pr customerId, }); + if (!entityAfter.id) { + throw new Error("Expected checkout entity to have an id"); + } + await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entityAfter.id, @@ -350,8 +355,10 @@ test.concurrent(`${chalk.yellowBright("attach: stripe checkout monthly volume pr }); const customerAfter = await autumnV1.customers.get(customerId); - const customerAfterV2_2 = - await autumnV2_2.customers.get(customerId); + const entityAfterV2_2 = await autumnV2_2.entities.get( + customerId, + entityAfter.id, + ); expectCustomerFeatureCorrect({ customerId, @@ -363,7 +370,7 @@ test.concurrent(`${chalk.yellowBright("attach: stripe checkout monthly volume pr }); expectBalanceCorrect({ - customer: customerAfterV2_2, + customer: entityAfterV2_2, featureId: TestFeature.Messages, remaining: checkoutPrepaidQuantity + consumableIncludedUsage, usage: 0, diff --git a/server/tests/integration/billing/attach/discounts/attach-discounts-backdate.test.ts b/server/tests/integration/billing/attach/discounts/attach-discounts-backdate.test.ts new file mode 100644 index 000000000..8b3971f73 --- /dev/null +++ b/server/tests/integration/billing/attach/discounts/attach-discounts-backdate.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test"; +import { + type AttachParamsV1Input, + type AttachPreviewResponse, + ms, +} from "@autumn/shared"; +import { expectInvoiceLineItemsCorrect } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { createAmountCoupon } from "../../utils/discounts/discountTestUtils"; + +test.concurrent( + `${chalk.yellowBright("attach-discount backdate: amount-off coupon applies once to backdated invoice")}`, + async () => { + const customerId = "att-disc-backdate-amt-off"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 500, + durationInMonths: 12, + }); + const startsAt = advancedTo - ms.days(40); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + discounts: [{ reward_id: coupon.id }], + }; + const preview = + (await autumnV2_2.billing.previewAttach(params)) as AttachPreviewResponse; + + expect(preview.subtotal).toBe(40); + expect(preview.total).toBe(35); + expect(preview.line_items[0]?.period).toEqual({ + start: startsAt, + end: addMonths(startsAt, 2).getTime(), + }); + expect(preview.line_items[0]?.description).toContain("from"); + expectPreviewNextCycleCorrect({ + preview, + startsAt: addMonths(startsAt, 2).getTime(), + total: 15, + }); + + const result = await autumnV2_2.billing.attach(params); + expect(result.invoice?.total).toBe(preview.total); + + await expectInvoiceLineItemsCorrect({ + stripeInvoiceId: result.invoice!.stripe_id, + expectedTotal: 35, + allCharges: true, + expectedLineItems: [ + { + isBasePrice: true, + billingTiming: "in_advance", + totalAmount: 35, + minCount: 2, + }, + ], + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("attach-discount backdate: one-month coupon expires before next cycle")}`, + async () => { + const customerId = "att-disc-backdate-one-month"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 500, + durationInMonths: 1, + }); + const startsAt = advancedTo - ms.days(40); + + const preview = + (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + discounts: [{ reward_id: coupon.id }], + })) as AttachPreviewResponse; + + expect(preview.subtotal).toBe(40); + expect(preview.total).toBe(35); + expect(preview.line_items[0]?.period).toEqual({ + start: startsAt, + end: addMonths(startsAt, 2).getTime(), + }); + expectPreviewNextCycleCorrect({ + preview, + startsAt: addMonths(startsAt, 2).getTime(), + total: 20, + }); + }, +); diff --git a/server/tests/integration/billing/attach/free-trial/trial-merge.test.ts b/server/tests/integration/billing/attach/free-trial/trial-merge.test.ts index 791102978..87fc332df 100644 --- a/server/tests/integration/billing/attach/free-trial/trial-merge.test.ts +++ b/server/tests/integration/billing/attach/free-trial/trial-merge.test.ts @@ -84,7 +84,7 @@ test.concurrent(`${chalk.yellowBright("trial-merge 1: add-on to trialing subscri expectPreviewNextCycleCorrect({ preview, startsAt: advancedTo + ms.days(7), - total: 20, // Add-on ($20) after trial + total: 40, }); // 2. Attach add-on @@ -296,7 +296,7 @@ test.concurrent(`${chalk.yellowBright("trial-merge 3: entity attach to trialing expectPreviewNextCycleCorrect({ preview, startsAt: advancedTo + ms.days(7), // Trial end - total: 20, // 2 entities x $20 = $40 after trial + total: 40, // 2 entities x $20 after trial }); // 2. Attach to entity-2 diff --git a/server/tests/integration/billing/attach/invoice-line-items/backdate-line-items.test.ts b/server/tests/integration/billing/attach/invoice-line-items/backdate-line-items.test.ts new file mode 100644 index 000000000..74a6295b9 --- /dev/null +++ b/server/tests/integration/billing/attach/invoice-line-items/backdate-line-items.test.ts @@ -0,0 +1,94 @@ +/** + * Backdated attach: persisted Autumn invoice + invoice_line_items match the + * multi-cycle amount Stripe bills via backdate_start_date. + * + * Contract under test: + * - A backdated new subscription that spans N elapsed cycles produces an Autumn + * invoice whose total and persisted line items sum to N x the per-cycle price. + * - Computed previews use the aggregate backdated period, while persisted + * invoice_line_items keep Stripe's per-cycle periods and descriptions. + */ + +import { expect, test } from "bun:test"; +import { type AttachParamsV1Input, ms } from "@autumn/shared"; +import { expectInvoiceLineItemsCorrect } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; + +test.concurrent( + `${chalk.yellowBright("backdate-line-items: 35-day backdate bills 2 cycles and persists matching line items")}`, + async () => { + const customerId = "attach-backdate-line-items"; + const basePrice = 20; // pro = $20/mo + const cycles = 2; // 35-day backdate on a monthly plan spans 2 cycle starts + const expectedTotal = basePrice * cycles; // $40 + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const startsAt = advancedTo - ms.days(35); + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + }); + + expect(result.invoice?.stripe_id).toBeDefined(); + // Autumn invoice header reflects the full backdated charge + expect(result.invoice?.total).toBe(expectedTotal); + + // Persisted invoice_line_items sum to the same multi-cycle total + const lineItems = await expectInvoiceLineItemsCorrect({ + stripeInvoiceId: result.invoice!.stripe_id, + expectedTotal, + allCharges: true, + expectedLineItems: [ + { + isBasePrice: true, + billingTiming: "in_advance", + totalAmount: expectedTotal, + minCount: cycles, + }, + ], + }); + + const baseLineItems = lineItems + .filter((lineItem) => lineItem.feature_id === null) + .sort( + (a, b) => + (a.effective_period_start ?? 0) - (b.effective_period_start ?? 0), + ); + + expect(baseLineItems).toHaveLength(cycles); + for (let index = 0; index < cycles; index++) { + const lineItem = baseLineItems[index]!; + expect(lineItem.description_source).toBe("stripe"); + expect( + Math.abs( + (lineItem.effective_period_start ?? 0) - + addMonths(startsAt, index).getTime(), + ), + ).toBeLessThan(1000); + expect( + Math.abs( + (lineItem.effective_period_end ?? 0) - + addMonths(startsAt, index + 1).getTime(), + ), + ).toBeLessThan(1000); + } + }, +); diff --git a/server/tests/integration/billing/attach/invoice/invoice-mode-deferred-metadata-expiry.test.ts b/server/tests/integration/billing/attach/invoice/invoice-mode-deferred-metadata-expiry.test.ts new file mode 100644 index 000000000..c4a7525fc --- /dev/null +++ b/server/tests/integration/billing/attach/invoice/invoice-mode-deferred-metadata-expiry.test.ts @@ -0,0 +1,162 @@ +// Contract: deferred invoice metadata is unbounded; action-required upgrade metadata keeps a short expiry. +// Side effect: cron selection excludes null expires_at rows. + +import { expect, test } from "bun:test"; +import { type AttachParamsV1Input, MetadataType, ms } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { + getExpiredInvoiceMetadata, + handleVoidInvoiceCron, +} from "@/cron/invoiceCron/runInvoiceCron"; +import { getDeferredBillingMetadataExpiresAt } from "@/internal/billing/v2/providers/stripe/execute/getDeferredBillingMetadataExpiresAt"; +import { MetadataService } from "@/internal/metadata/MetadataService"; + +test(`${chalk.yellowBright("invoice-mode metadata expiry: custom payment methods are unbounded")}`, () => { + const now = Date.now(); + + expect( + getDeferredBillingMetadataExpiresAt({ + deferredInvoiceMode: false, + paymentMethod: { type: "custom" }, + now, + }), + ).toBeNull(); + expect( + getDeferredBillingMetadataExpiresAt({ + deferredInvoiceMode: false, + paymentMethod: { type: "card" }, + now, + }), + ).toBe(now + ms.minutes(10)); +}); + +test.concurrent( + `${chalk.yellowBright("invoice-mode metadata expiry: deferred invoices do not auto-void")}`, + async () => { + const customerId = "invoice-mode-deferred-no-expiry"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + invoice_mode: { + enabled: true, + enable_plan_immediately: false, + finalize: true, + }, + }); + + expect(result.invoice?.stripe_id).toBeDefined(); + + const deferredMetadata = await MetadataService.getByStripeInvoiceId({ + db: ctx.db, + stripeInvoiceId: result.invoice!.stripe_id, + type: MetadataType.DeferredInvoice, + }); + + expect(deferredMetadata).toBeDefined(); + expect(deferredMetadata!.expires_at).toBeNull(); + + const expiredMetadata = await MetadataService.insert({ + db: ctx.db, + data: { + id: `meta_invoice_mode_deferred_selector_${Date.now()}`, + type: MetadataType.DeferredInvoice, + stripe_invoice_id: "in_invoice_mode_deferred_selector", + created_at: Date.now(), + expires_at: Date.now() - ms.minutes(1), + data: {}, + }, + }); + + const voidableMetadata = await getExpiredInvoiceMetadata({ db: ctx.db }); + const voidableIds = new Set(voidableMetadata.map((row) => row.id)); + + expect(voidableIds.has(deferredMetadata!.id)).toBe(false); + expect(voidableIds.has(expiredMetadata.id)).toBe(true); + + await MetadataService.delete({ db: ctx.db, id: expiredMetadata.id }); + }, +); + +test.concurrent( + `${chalk.yellowBright("invoice-mode metadata expiry: action-required upgrades keep short expiry")}`, + async () => { + const customerId = "invoice-mode-action-required-short-expiry"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 300 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attachPaymentMethod({ type: "authenticate" }), + ], + }); + + const beforeAttach = Date.now(); + await autumnV1.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + const customer = await autumnV1.customers.get(customerId); + const stripeInvoices = await ctx.stripeCli.invoices.list({ + customer: customer.stripe_id!, + limit: 1, + }); + const latestInvoice = stripeInvoices.data[0]; + const metadataId = latestInvoice.metadata?.autumn_metadata_id; + + expect(metadataId).toBeDefined(); + + const actionRequiredMetadata = await MetadataService.get({ + db: ctx.db, + id: metadataId!, + }); + + expect(actionRequiredMetadata).toBeDefined(); + expect(actionRequiredMetadata!.expires_at).toBeGreaterThanOrEqual( + beforeAttach, + ); + expect(actionRequiredMetadata!.expires_at).toBeLessThanOrEqual( + beforeAttach + ms.minutes(11), + ); + + await handleVoidInvoiceCron({ + ctx, + metadata: actionRequiredMetadata!, + }); + + const voidedInvoice = await ctx.stripeCli.invoices.retrieve( + latestInvoice.id, + ); + expect(voidedInvoice.status).toBe("void"); + }, +); diff --git a/server/tests/integration/billing/attach/new-plan/attach-entities.test.ts b/server/tests/integration/billing/attach/new-plan/attach-entities.test.ts index 6257d52b7..a707c769c 100644 --- a/server/tests/integration/billing/attach/new-plan/attach-entities.test.ts +++ b/server/tests/integration/billing/attach/new-plan/attach-entities.test.ts @@ -40,71 +40,74 @@ import chalk from "chalk"; * - Entity has product * - Customer does not have product */ -test.concurrent(`${chalk.yellowBright("new-plan: create entity, attach pro to entity")}`, async () => { - const customerId = "new-plan-attach-entity-pro"; +test.concurrent( + `${chalk.yellowBright("new-plan: create entity, attach pro to entity")}`, + async () => { + const customerId = "new-plan-attach-entity-pro"; - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro-entity", - items: [messagesItem], - }); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro-entity", + items: [messagesItem], + }); - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [], - }); + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); - // 1. Preview attach to entity - $20 - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - }); - expect(preview.total).toBe(20); + // 1. Preview attach to entity - $20 + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + }); + expect(preview.total).toBe(20); - // 2. Attach to entity - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - redirect_mode: "if_required", - }); + // 2. Attach to entity + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + redirect_mode: "if_required", + }); - // Get entity and verify it has the product - const entity = await autumnV1.entities.get( - customerId, - entities[0].id, - ); + // Get entity and verify it has the product + const entity = await autumnV1.entities.get( + customerId, + entities[0].id, + ); - await expectProductActive({ - customer: entity, - productId: pro.id, - }); + await expectProductActive({ + customer: entity, + productId: pro.id, + }); - // Verify entity has messages feature - expectCustomerFeatureCorrect({ - customer: entity, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); + // Verify entity has messages feature + expectCustomerFeatureCorrect({ + customer: entity, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); - // Get customer and verify they don't have the product - const customer = await autumnV1.customers.get(customerId); + // Get customer and verify they don't have the product + const customer = await autumnV1.customers.get(customerId); - // Verify invoice on customer matches preview total: $20 - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: 20, - }); -}); + // Verify invoice on customer matches preview total: $20 + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 2: Create 2 entities, attach pro to each @@ -119,94 +122,95 @@ test.concurrent(`${chalk.yellowBright("new-plan: create entity, attach pro to en * - Independent balances * - 2 separate subscriptions */ -test.concurrent(`${chalk.yellowBright("new-plan: create 2 entities, attach pro to each")}`, async () => { - const customerId = "new-plan-attach-2-entities"; +test.concurrent( + `${chalk.yellowBright("new-plan: create 2 entities, attach pro to each")}`, + async () => { + const customerId = "new-plan-attach-2-entities"; - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro-2ent", - items: [messagesItem], - }); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro-2ent", + items: [messagesItem], + }); - const { autumnV1, entities, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })], - }); + const { autumnV1, entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })], + }); - return; + // 2. Preview and attach to entity 2 - $20 + const preview2 = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[1].id, + }); + expect(preview2.total).toBe(20); - // 2. Preview and attach to entity 2 - $20 - const preview2 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - }); - expect(preview2.total).toBe(20); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[1].id, + redirect_mode: "if_required", + }); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - redirect_mode: "if_required", - }); + // Get both entities and verify independent balances + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); - // Get both entities and verify independent balances - const entity1 = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - const entity2 = await autumnV1.entities.get( - customerId, - entities[1].id, - ); + // Both entities should have the product + await expectProductActive({ + customer: entity1, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2, + productId: pro.id, + }); - // Both entities should have the product - await expectProductActive({ - customer: entity1, - productId: pro.id, - }); - await expectProductActive({ - customer: entity2, - productId: pro.id, - }); + // Both should have independent balances + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); - // Both should have independent balances - expectCustomerFeatureCorrect({ - customer: entity1, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); - expectCustomerFeatureCorrect({ - customer: entity2, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); + // Verify 2 invoices, each $20 + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, + }); - // Verify 2 invoices, each $20 - const customer = await autumnV1.customers.get(customerId); - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: 20, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - subCount: 1, - }); -}); + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + subCount: 1, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 3: Attach pro to entity 1, advance 2 weeks, attach pro to entity 2 @@ -221,93 +225,96 @@ test.concurrent(`${chalk.yellowBright("new-plan: create 2 entities, attach pro t * Expected Result: * - Prorated billing for entity 2 */ -test.concurrent(`${chalk.yellowBright("new-plan: attach pro to entity 1, advance 2 weeks, attach pro to entity 2")}`, async () => { - const customerId = "new-plan-attach-entity-midcycle"; +test.concurrent( + `${chalk.yellowBright("new-plan: attach pro to entity 1, advance 2 weeks, attach pro to entity 2")}`, + async () => { + const customerId = "new-plan-attach-entity-midcycle"; - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro-midcycle", - items: [messagesItem], - }); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro-midcycle", + items: [messagesItem], + }); - const { autumnV1, entities, ctx, testClockId } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [], - }); + const { autumnV1, entities, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [], + }); - // 1. Preview and attach to entity 1 - $20 (full price) - const preview1 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - }); - expect(preview1.total).toBe(20); + // 1. Preview and attach to entity 1 - $20 (full price) + const preview1 = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + }); + expect(preview1.total).toBe(20); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - redirect_mode: "if_required", - }); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + redirect_mode: "if_required", + }); - // Advance 2 weeks - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - numberOfWeeks: 2, - }); + // Advance 2 weeks + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + numberOfWeeks: 2, + }); - // 2. Preview attach to entity 2 mid-cycle (prorated) - const preview2 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - }); - const entity2Total = preview2.total; + // 2. Preview attach to entity 2 mid-cycle (prorated) + const preview2 = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[1].id, + }); + const entity2Total = preview2.total; - // 3. Attach to entity 2 mid-cycle - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[1].id, - redirect_mode: "if_required", - }); + // 3. Attach to entity 2 mid-cycle + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[1].id, + redirect_mode: "if_required", + }); - // Get both entities - const entity1 = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - const entity2 = await autumnV1.entities.get( - customerId, - entities[1].id, - ); + // Get both entities + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); - // Both should have the product - await expectProductActive({ - customer: entity1, - productId: pro.id, - }); - await expectProductActive({ - customer: entity2, - productId: pro.id, - }); + // Both should have the product + await expectProductActive({ + customer: entity1, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2, + productId: pro.id, + }); - // Get customer to check invoices - const customer = await autumnV1.customers.get(customerId); + // Get customer to check invoices + const customer = await autumnV1.customers.get(customerId); - // Should have 2 invoices: one full price ($20), one prorated - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: entity2Total, // Prorated amount matches preview - }); -}); + // Should have 2 invoices: one full price ($20), one prorated + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: entity2Total, // Prorated amount matches preview + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 4: Attach pro annual to entity @@ -320,70 +327,73 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro to entity 1, advance * Expected Result: * - Correct billing interval (annual) */ -test.concurrent(`${chalk.yellowBright("new-plan: attach pro annual to entity")}`, async () => { - const customerId = "new-plan-attach-entity-annual"; +test.concurrent( + `${chalk.yellowBright("new-plan: attach pro annual to entity")}`, + async () => { + const customerId = "new-plan-attach-entity-annual"; - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const proAnnual = products.proAnnual({ - id: "pro-annual-ent", - items: [messagesItem], - }); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const proAnnual = products.proAnnual({ + id: "pro-annual-ent", + items: [messagesItem], + }); - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [proAnnual] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [], - }); + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proAnnual] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); - // 1. Preview attach to entity - $200 (annual) - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: proAnnual.id, - entity_id: entities[0].id, - }); - expect(preview.total).toBe(200); + // 1. Preview attach to entity - $200 (annual) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: proAnnual.id, + entity_id: entities[0].id, + }); + expect(preview.total).toBe(200); - // 2. Attach to entity - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: proAnnual.id, - entity_id: entities[0].id, - redirect_mode: "if_required", - }); + // 2. Attach to entity + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: proAnnual.id, + entity_id: entities[0].id, + redirect_mode: "if_required", + }); - // Get entity and verify product - const entity = await autumnV1.entities.get( - customerId, - entities[0].id, - ); + // Get entity and verify product + const entity = await autumnV1.entities.get( + customerId, + entities[0].id, + ); - await expectProductActive({ - customer: entity, - productId: proAnnual.id, - }); + await expectProductActive({ + customer: entity, + productId: proAnnual.id, + }); - // Verify messages feature - expectCustomerFeatureCorrect({ - customer: entity, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); + // Verify messages feature + expectCustomerFeatureCorrect({ + customer: entity, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); - // Get customer and verify invoice matches preview total: $200 - const customer = await autumnV1.customers.get(customerId); + // Get customer and verify invoice matches preview total: $200 + const customer = await autumnV1.customers.get(customerId); - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: 200, - }); -}); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 200, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 5: Attach pro to customer, then pro to entity @@ -397,93 +407,96 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro annual to entity")}` * Expected Result: * - Both have product independently */ -test.concurrent(`${chalk.yellowBright("new-plan: attach pro to customer, then pro to entity")}`, async () => { - const customerId = "new-plan-attach-cust-then-entity"; +test.concurrent( + `${chalk.yellowBright("new-plan: attach pro to customer, then pro to entity")}`, + async () => { + const customerId = "new-plan-attach-cust-then-entity"; - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro-cust-ent", - items: [messagesItem], - }); + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro-cust-ent", + items: [messagesItem], + }); - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [], - }); + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); - // 1. Preview and attach to customer - $20 - const previewCust = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - }); - expect(previewCust.total).toBe(20); + // 1. Preview and attach to customer - $20 + const previewCust = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + }); + expect(previewCust.total).toBe(20); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - redirect_mode: "if_required", - }); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + }); - // 2. Preview and attach to entity - $20 - const previewEnt = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - }); - expect(previewEnt.total).toBe(20); + // 2. Preview and attach to entity - $20 + const previewEnt = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + }); + expect(previewEnt.total).toBe(20); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - entity_id: entities[0].id, - redirect_mode: "if_required", - }); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + redirect_mode: "if_required", + }); - // Get customer and entity - const customer = await autumnV1.customers.get(customerId); - const entity = await autumnV1.entities.get( - customerId, - entities[0].id, - ); + // Get customer and entity + const customer = await autumnV1.customers.get(customerId); + const entity = await autumnV1.entities.get( + customerId, + entities[0].id, + ); - // Both should have the product - await expectProductActive({ - customer, - productId: pro.id, - }); - await expectProductActive({ - customer: entity, - productId: pro.id, - }); + // Both should have the product + await expectProductActive({ + customer, + productId: pro.id, + }); + await expectProductActive({ + customer: entity, + productId: pro.id, + }); - // Features are inherited across scopes: customer (100) + entity (100) = 200 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: 200, - balance: 200, - usage: 0, - }); - expectCustomerFeatureCorrect({ - customer: entity, - featureId: TestFeature.Messages, - includedUsage: 200, - balance: 200, - usage: 0, - }); + // Features are inherited across scopes: customer (100) + entity (100) = 200 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer: entity, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + }); - // Verify 2 invoices, each $20 - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: 20, - }); -}); + // Verify 2 invoices, each $20 + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 6: Attach free to customer, then free to entity @@ -497,92 +510,95 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro to customer, then pr * Expected Result: * - Both have product independently */ -test.concurrent(`${chalk.yellowBright("new-plan: attach free to customer, then free to entity")}`, async () => { - const customerId = "new-plan-attach-free-cust-ent"; +test.concurrent( + `${chalk.yellowBright("new-plan: attach free to customer, then free to entity")}`, + async () => { + const customerId = "new-plan-attach-free-cust-ent"; - const messagesItem = items.monthlyMessages({ includedUsage: 50 }); - const free = products.base({ - id: "free-cust-ent", - items: [messagesItem], - }); + const messagesItem = items.monthlyMessages({ includedUsage: 50 }); + const free = products.base({ + id: "free-cust-ent", + items: [messagesItem], + }); - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({}), - s.products({ list: [free] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [], - }); + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({}), + s.products({ list: [free] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); - // 1. Preview and attach to customer - $0 (free) - const previewCust = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: free.id, - }); - expect(previewCust.total).toBe(0); + // 1. Preview and attach to customer - $0 (free) + const previewCust = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: free.id, + }); + expect(previewCust.total).toBe(0); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: free.id, - redirect_mode: "if_required", - }); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: free.id, + redirect_mode: "if_required", + }); - // 2. Preview and attach to entity - $0 (free) - const previewEnt = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: free.id, - entity_id: entities[0].id, - }); - expect(previewEnt.total).toBe(0); + // 2. Preview and attach to entity - $0 (free) + const previewEnt = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: free.id, + entity_id: entities[0].id, + }); + expect(previewEnt.total).toBe(0); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: free.id, - entity_id: entities[0].id, - redirect_mode: "if_required", - }); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: free.id, + entity_id: entities[0].id, + redirect_mode: "if_required", + }); - // Get customer and entity - const customer = await autumnV1.customers.get(customerId); - const entity = await autumnV1.entities.get( - customerId, - entities[0].id, - ); + // Get customer and entity + const customer = await autumnV1.customers.get(customerId); + const entity = await autumnV1.entities.get( + customerId, + entities[0].id, + ); - // Both should have the product - await expectProductActive({ - customer, - productId: free.id, - }); - await expectProductActive({ - customer: entity, - productId: free.id, - }); + // Both should have the product + await expectProductActive({ + customer, + productId: free.id, + }); + await expectProductActive({ + customer: entity, + productId: free.id, + }); - // Features are inherited across scopes: customer (50) + entity (50) = 100 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); - expectCustomerFeatureCorrect({ - customer: entity, - featureId: TestFeature.Messages, - includedUsage: 100, - balance: 100, - usage: 0, - }); + // Features are inherited across scopes: customer (50) + entity (50) = 100 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer: entity, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); - // Verify no invoices (both free) - matches preview total of 0 - await expectCustomerInvoiceCorrect({ - customer, - count: 0, - }); -}); + // Verify no invoices (both free) - matches preview total of 0 + await expectCustomerInvoiceCorrect({ + customer, + count: 0, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 7: Entity has monthly, add customer-level annual (different scopes) @@ -605,110 +621,113 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach free to customer, then f * - Day 30: Monthly renews ($20) * - Day 45: Add customer annual ($500 - no credit from entity scope) */ -test.concurrent(`${chalk.yellowBright("new-plan: entity monthly, add customer annual (different scopes)")}`, async () => { - const customerId = "new-plan-ent-monthly-cust-annual"; +test.concurrent( + `${chalk.yellowBright("new-plan: entity monthly, add customer annual (different scopes)")}`, + async () => { + const customerId = "new-plan-ent-monthly-cust-annual"; - const proMessages = items.monthlyMessages({ includedUsage: 500 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [proMessages], - }); + const proMessages = items.monthlyMessages({ includedUsage: 500 }); + const proMonthly = products.pro({ + id: "pro-monthly", + items: [proMessages], + }); - // Enterprise annual at customer level ($500/yr) - const enterpriseMessages = items.monthlyMessages({ includedUsage: 10000 }); - const enterpriseAnnual = products.base({ - id: "enterprise-annual", - items: [enterpriseMessages, items.annualPrice({ price: 500 })], - }); + // Enterprise annual at customer level ($500/yr) + const enterpriseMessages = items.monthlyMessages({ includedUsage: 10000 }); + const enterpriseAnnual = products.base({ + id: "enterprise-annual", + items: [enterpriseMessages, items.annualPrice({ price: 500 })], + }); - const { autumnV1, entities, advancedTo } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [proMonthly, enterpriseAnnual] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [ - s.billing.attach({ productId: proMonthly.id, entityIndex: 0 }), - // Advance 1 month to trigger renewal, then 15 more days - s.advanceTestClock({ months: 1 }), - s.advanceTestClock({ days: 15 }), - ], - }); + const { autumnV1, entities, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proMonthly, enterpriseAnnual] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: proMonthly.id, entityIndex: 0 }), + // Advance 1 month to trigger renewal, then 15 more days + s.advanceTestClock({ months: 1 }), + s.advanceTestClock({ days: 15 }), + ], + }); - // Verify entity still has monthly before adding customer product - const entityBefore = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - await expectProductActive({ - customer: entityBefore, - productId: proMonthly.id, - }); + // Verify entity still has monthly before adding customer product + const entityBefore = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductActive({ + customer: entityBefore, + productId: proMonthly.id, + }); - // Calculate expected total using cross-interval proration utility - const expectedTotal = await calculateCrossIntervalUpgrade({ - customerId, - advancedTo, - // oldAmount: 20, // Entity monthly price - newAmount: 500, // Customer annual price - }); + // Calculate expected total using cross-interval proration utility + const expectedTotal = await calculateCrossIntervalUpgrade({ + customerId, + advancedTo, + // oldAmount: 20, // Entity monthly price + newAmount: 500, // Customer annual price + }); - // 1. Preview adding customer-level annual (prorated with credit from entity) - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: enterpriseAnnual.id, - // No entity_id - this is customer-level - }); + // 1. Preview adding customer-level annual (prorated with credit from entity) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: enterpriseAnnual.id, + // No entity_id - this is customer-level + }); - // Prorated annual charge with credit from entity monthly - expect(preview.total).toBeCloseTo(expectedTotal, 0); + // Prorated annual charge with credit from entity monthly + expect(preview.total).toBeCloseTo(expectedTotal, 0); - // 2. Attach enterprise annual at customer level - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: enterpriseAnnual.id, - redirect_mode: "if_required", - }); + // 2. Attach enterprise annual at customer level + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: enterpriseAnnual.id, + redirect_mode: "if_required", + }); - // Get customer and entity - const customer = await autumnV1.customers.get(customerId); - const entity = await autumnV1.entities.get( - customerId, - entities[0].id, - ); + // Get customer and entity + const customer = await autumnV1.customers.get(customerId); + const entity = await autumnV1.entities.get( + customerId, + entities[0].id, + ); - // Customer has enterprise annual - await expectProductActive({ - customer, - productId: enterpriseAnnual.id, - }); + // Customer has enterprise annual + await expectProductActive({ + customer, + productId: enterpriseAnnual.id, + }); - // Entity STILL has monthly (different scope, not replaced) - await expectProductActive({ - customer: entity, - productId: proMonthly.id, - }); + // Entity STILL has monthly (different scope, not replaced) + await expectProductActive({ + customer: entity, + productId: proMonthly.id, + }); - // Verify features at customer level: customer (10000) + entity (500) = 10500 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: 10500, - balance: 10500, - usage: 0, - }); + // Verify features at customer level: customer (10000) + entity (500) = 10500 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 10500, + balance: 10500, + usage: 0, + }); - // Verify invoices: - // 1. Entity monthly ($20) - // 2. Entity monthly renewal ($20) - // 3. Customer annual (prorated with entity credit) - await expectCustomerInvoiceCorrect({ - customer, - count: 3, - latestTotal: preview.total, - }); -}); + // Verify invoices: + // 1. Entity monthly ($20) + // 2. Entity monthly renewal ($20) + // 3. Customer annual (prorated with entity credit) + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: preview.total, + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 8: Entity has monthly + add-on, add customer-level annual (different scopes) @@ -725,105 +744,108 @@ test.concurrent(`${chalk.yellowBright("new-plan: entity monthly, add customer an * - No refund from entity products (different scope) * - Total: $500 (full annual, no credit) */ -test.concurrent(`${chalk.yellowBright("new-plan: entity monthly + add-on, add customer annual (different scopes)")}`, async () => { - const customerId = "new-plan-ent-addon-cust-annual"; +test.concurrent( + `${chalk.yellowBright("new-plan: entity monthly + add-on, add customer annual (different scopes)")}`, + async () => { + const customerId = "new-plan-ent-addon-cust-annual"; - // Pro monthly ($20/mo) - const proMessages = items.monthlyMessages({ includedUsage: 500 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [proMessages], - }); + // Pro monthly ($20/mo) + const proMessages = items.monthlyMessages({ includedUsage: 500 }); + const proMonthly = products.pro({ + id: "pro-monthly", + items: [proMessages], + }); - // Storage add-on monthly ($10/mo) - const storageItem = items.monthlyMessages({ includedUsage: 1000 }); - const storageAddOn = products.base({ - id: "storage-addon", - isAddOn: true, - items: [storageItem, items.monthlyPrice({ price: 10 })], - }); + // Storage add-on monthly ($10/mo) + const storageItem = items.monthlyMessages({ includedUsage: 1000 }); + const storageAddOn = products.base({ + id: "storage-addon", + isAddOn: true, + items: [storageItem, items.monthlyPrice({ price: 10 })], + }); - // Enterprise annual bundle at customer level ($500/yr) - const enterpriseMessages = items.monthlyMessages({ includedUsage: 10000 }); - const enterpriseAnnual = products.base({ - id: "enterprise-annual", - items: [enterpriseMessages, items.annualPrice({ price: 500 })], - }); + // Enterprise annual bundle at customer level ($500/yr) + const enterpriseMessages = items.monthlyMessages({ includedUsage: 10000 }); + const enterpriseAnnual = products.base({ + id: "enterprise-annual", + items: [enterpriseMessages, items.annualPrice({ price: 500 })], + }); - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [proMonthly, storageAddOn, enterpriseAnnual] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [ - s.billing.attach({ productId: proMonthly.id, entityIndex: 0 }), - s.billing.attach({ productId: storageAddOn.id, entityIndex: 0 }), - ], - }); + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proMonthly, storageAddOn, enterpriseAnnual] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: proMonthly.id, entityIndex: 0 }), + s.billing.attach({ productId: storageAddOn.id, entityIndex: 0 }), + ], + }); - // Verify entity has both products before adding customer product - const entityBefore = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - await expectCustomerProducts({ - customer: entityBefore, - active: [proMonthly.id, storageAddOn.id], - }); + // Verify entity has both products before adding customer product + const entityBefore = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectCustomerProducts({ + customer: entityBefore, + active: [proMonthly.id, storageAddOn.id], + }); - // 1. Preview adding customer-level annual (no refund from entity scope) - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: enterpriseAnnual.id, - }); + // 1. Preview adding customer-level annual (no refund from entity scope) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: enterpriseAnnual.id, + }); - // Full annual charge - NO credit from entity products (different scope) - expect(preview.total).toBe(500); + // Full annual charge - NO credit from entity products (different scope) + expect(preview.total).toBe(500); - // 2. Attach enterprise annual at customer level - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: enterpriseAnnual.id, - redirect_mode: "if_required", - }); + // 2. Attach enterprise annual at customer level + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: enterpriseAnnual.id, + redirect_mode: "if_required", + }); - // Get customer and entity - const customer = await autumnV1.customers.get(customerId); - const entity = await autumnV1.entities.get( - customerId, - entities[0].id, - ); + // Get customer and entity + const customer = await autumnV1.customers.get(customerId); + const entity = await autumnV1.entities.get( + customerId, + entities[0].id, + ); - // Customer has enterprise annual - await expectProductActive({ - customer, - productId: enterpriseAnnual.id, - }); + // Customer has enterprise annual + await expectProductActive({ + customer, + productId: enterpriseAnnual.id, + }); - // Entity STILL has both products (different scope, not replaced) - await expectCustomerProducts({ - customer: entity, - active: [proMonthly.id, storageAddOn.id], - }); + // Entity STILL has both products (different scope, not replaced) + await expectCustomerProducts({ + customer: entity, + active: [proMonthly.id, storageAddOn.id], + }); - // Verify features at customer level: customer (10000) + entity pro (500) + entity addon (1000) = 11500 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: 11500, - balance: 11500, - usage: 0, - }); + // Verify features at customer level: customer (10000) + entity pro (500) + entity addon (1000) = 11500 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 11500, + balance: 11500, + usage: 0, + }); - // Verify invoices: - // 1. Entity pro monthly ($20) - // 2. Entity storage add-on ($10) - // 3. Customer annual ($500) - await expectCustomerInvoiceCorrect({ - customer, - count: 3, - latestTotal: 500, - }); -}); + // Verify invoices: + // 1. Entity pro monthly ($20) + // 2. Entity storage add-on ($10) + // 3. Customer annual ($500) + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: 500, + }); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/starts-at-annual-monthly-entity.test.ts b/server/tests/integration/billing/attach/params/start-date/starts-at-annual-monthly-entity.test.ts new file mode 100644 index 000000000..80a07efbd --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/starts-at-annual-monthly-entity.test.ts @@ -0,0 +1,283 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiEntityV2, + type AttachParamsV1Input, + type AttachPreviewResponse, + ms, +} from "@autumn/shared"; +import { + ANNUAL_MONTHLY_MESSAGES_PHASES, + annualMonthlyMessagesPlan, + countMonthlyPeriods, + expectAnnualMonthlyPreviewCorrect, + expectAnnualMonthlyStripeInvoiceCorrect, + expectedAnnualMonthlyImmediateTotal, + monthlyPeriodsFrom, + prepaidMessagesAmount, +} from "@tests/integration/billing/utils/annualMonthlyMessagesTestUtils"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { Decimal } from "decimal.js"; +import { expectAttachBackdateCorrect } from "./utils/expectAttachBackdateCorrect"; + +const latestStripeInvoice = async ({ + ctx, + customer, +}: { + ctx: Awaited>["ctx"]; + customer: ApiCustomerV3; +}) => { + const stripeId = customer.invoices?.[0]?.stripe_id; + if (!stripeId) throw new Error("Expected latest invoice to have stripe_id"); + + return await ctx.stripeCli.invoices.retrieve(stripeId, { + expand: ["lines.data.price"], + }); +}; + +const attachParams = ({ + customerId, + entityId, + planId, + startsAt, +}: { + customerId: string; + entityId: string; + planId: string; + startsAt?: number; +}): AttachParamsV1Input => { + const firstPhase = ANNUAL_MONTHLY_MESSAGES_PHASES[0]!; + + return { + customer_id: customerId, + entity_id: entityId, + plan_id: planId, + starts_at: startsAt, + redirect_mode: "if_required", + customize: { + price: itemsV2.annualPrice({ amount: firstPhase.annualAmount }), + }, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: firstPhase.prepaidQuantity, + }, + ], + }; +}; + +test.concurrent( + `${chalk.yellowBright("attach annual monthly entity backdate: preview and Stripe invoice match elapsed cycles")}`, + async () => { + const customerId = "attach-annual-monthly-entity-backdate"; + const plan = annualMonthlyMessagesPlan(); + + const { autumnV1, autumnV2_2, ctx, entities, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const entityId = entities[0]!.id; + const startsAt = advancedTo - ms.days(40); + const firstPhase = ANNUAL_MONTHLY_MESSAGES_PHASES[0]!; + const params = attachParams({ + customerId, + entityId, + planId: plan.id, + startsAt, + }); + + const preview = + (await autumnV2_2.billing.previewAttach( + params, + )) as AttachPreviewResponse; + expectAnnualMonthlyPreviewCorrect({ + preview, + annualAmount: firstPhase.annualAmount, + prepaidQuantity: firstPhase.prepaidQuantity, + startsAt, + currentEpochMs: advancedTo, + }); + + const result = await autumnV2_2.billing.attach(params); + expect(result.invoice?.total).toBe(preview.total); + + const cycleCount = countMonthlyPeriods({ + startsAt, + currentEpochMs: advancedTo, + }); + await expectAttachBackdateCorrect({ + autumn: autumnV1, + ctx, + customerId, + productId: plan.id, + startsAt, + result, + minInvoiceTotal: + expectedAnnualMonthlyImmediateTotal({ + annualAmount: firstPhase.annualAmount, + prepaidQuantity: firstPhase.prepaidQuantity, + startsAt, + currentEpochMs: advancedTo, + }) * + 100 - + 1, + minInvoiceLineCount: cycleCount + 1, + }); + + const customer = await autumnV1.customers.get(customerId); + const invoice = await latestStripeInvoice({ ctx, customer }); + expectAnnualMonthlyStripeInvoiceCorrect({ + invoice, + annualAmount: firstPhase.annualAmount, + monthlyAmount: prepaidMessagesAmount({ + quantity: firstPhase.prepaidQuantity, + }), + monthlyPeriods: monthlyPeriodsFrom({ startsAt, count: cycleCount }), + expectedTotal: preview.total, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + +test.concurrent( + `${chalk.yellowBright("attach annual monthly multi entity: second entity remains exact after first renewal")}`, + async () => { + const customerId = "attach-annual-monthly-multi-entity"; + const plan = annualMonthlyMessagesPlan(); + + const { + autumnV1, + autumnV2_2, + ctx, + entities, + testClockId, + advancedTo, + } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const firstPhase = ANNUAL_MONTHLY_MESSAGES_PHASES[0]!; + const entity0Params = attachParams({ + customerId, + entityId: entities[0]!.id, + planId: plan.id, + }); + + const entity0Result = + await autumnV2_2.billing.attach(entity0Params); + expect(entity0Result.invoice?.total).toBe( + firstPhase.annualAmount + + prepaidMessagesAmount({ quantity: firstPhase.prepaidQuantity }), + ); + + const currentEpochMs = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + const prepaidAmount = prepaidMessagesAmount({ + quantity: firstPhase.prepaidQuantity, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: prepaidAmount, + }); + + const entity1StartsAt = currentEpochMs; + const entity1Params = attachParams({ + customerId, + entityId: entities[1]!.id, + planId: plan.id, + startsAt: entity1StartsAt, + }); + const preview = + (await autumnV2_2.billing.previewAttach( + entity1Params, + )) as AttachPreviewResponse; + + const annualLine = preview.line_items.find((item) => item.feature_id === null); + const prepaidLine = preview.line_items.find( + (item) => item.feature_id === TestFeature.Messages, + ); + expect(annualLine).toBeDefined(); + expect(prepaidLine).toBeDefined(); + expect(annualLine!.total).toBeLessThan(firstPhase.annualAmount); + expect(prepaidLine!.total).toBeLessThan(prepaidAmount); + const annualLineTotal = new Decimal(annualLine!.total) + .toDecimalPlaces(2) + .toNumber(); + const prepaidLineTotal = new Decimal(prepaidLine!.total) + .toDecimalPlaces(2) + .toNumber(); + + const expectedEntity1Total = new Decimal(annualLineTotal) + .plus(prepaidLineTotal) + .toDecimalPlaces(2) + .toNumber(); + expect(preview.subtotal).toBe(expectedEntity1Total); + expect(preview.total).toBe(preview.subtotal); + expectPreviewNextCycleCorrect({ + preview, + startsAt: addMonths(advancedTo, 2).getTime(), + total: prepaidAmount * 2, + }); + + const entity1Result = + await autumnV2_2.billing.attach(entity1Params); + expect(entity1Result.invoice?.total).toBe(preview.total); + + const customer = await autumnV1.customers.get(customerId); + const invoice = await latestStripeInvoice({ ctx, customer }); + expectAnnualMonthlyStripeInvoiceCorrect({ + invoice, + annualAmount: annualLineTotal, + monthlyAmount: prepaidLineTotal, + monthlyPeriods: [ + { + start: entity1StartsAt, + end: addMonths(advancedTo, 2).getTime(), + }, + ], + expectedTotal: preview.total, + }); + + const entity1 = await autumnV2_2.entities.get( + customerId, + entities[1]!.id, + ); + expectBalanceCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + remaining: firstPhase.prepaidQuantity, + usage: 0, + nextResetAt: addMonths(entity1StartsAt, 1).getTime(), + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-invoice.test.ts b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-invoice.test.ts new file mode 100644 index 000000000..cf22b8abd --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-invoice.test.ts @@ -0,0 +1,61 @@ +/** + * TDD test for backdated starts_at with invoice mode. + * + * Contract under test: + * New behaviors: + * - A paid recurring attach with invoice_mode and past starts_at creates a backdated Stripe subscription + * - Stripe owns the first invoice for the elapsed time and returns the hosted invoice URL when finalized + * Side effects: + * - Autumn customer_product is active, stores the past starts_at, and links to the created Stripe subscription + */ + +import { expect, test } from "bun:test"; +import { type AttachParamsV1Input, ms } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { expectAttachBackdateCorrect } from "./utils/expectAttachBackdateCorrect"; + +test.concurrent( + `${chalk.yellowBright("starts_at backdate invoice mode: new subscription sends catch-up invoice")}`, + async () => { + const customerId = "attach-starts-at-backdate-invoice-mode"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [s.customer({}), s.products({ list: [pro] })], + actions: [], + }); + + const startsAt = advancedTo - ms.days(35); + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + invoice_mode: { + enabled: true, + enable_plan_immediately: true, + finalize: true, + }, + }); + + expect(result.invoice?.status).toBe("open"); + expect(result.invoice?.hosted_invoice_url).toBeTruthy(); + + await expectAttachBackdateCorrect({ + autumn: autumnV1, + ctx, + customerId, + productId: pro.id, + startsAt, + result, + minInvoiceTotal: 2000, + minInvoiceLineCount: 2, + }); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-new-billing-subscription.test.ts b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-new-billing-subscription.test.ts new file mode 100644 index 000000000..541a82165 --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-new-billing-subscription.test.ts @@ -0,0 +1,110 @@ +/** + * TDD test for backdated starts_at with new_billing_subscription. + * + * Contract under test: + * New behaviors: + * - A backdated recurring add-on can create a separate Stripe subscription when new_billing_subscription is true + * - An entity-scoped backdated attach can create a separate Stripe subscription when new_billing_subscription is true + * Side effects: + * - The new customer_product rows store the past starts_at and link to their new Stripe subscriptions + */ + +import { test } from "bun:test"; +import { type AttachParamsV1Input, ms } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { expectAttachBackdateCorrect } from "./utils/expectAttachBackdateCorrect"; + +test.concurrent( + `${chalk.yellowBright("starts_at backdate new sub: recurring add-on gets separate backdated subscription")}`, + async () => { + const customerId = "attach-starts-at-backdate-addon-new-sub"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyUsers({ includedUsage: 5 })], + }); + + const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const startsAt = advancedTo - ms.days(35); + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: addon.id, + starts_at: startsAt, + new_billing_subscription: true, + }); + + await expectAttachBackdateCorrect({ + autumn: autumnV1, + ctx, + customerId, + productId: addon.id, + startsAt, + result, + minInvoiceTotal: 2000, + minInvoiceLineCount: 2, + expectedInvoiceCount: 2, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("starts_at backdate new sub: entity attach gets separate backdated subscription")}`, + async () => { + const customerId = "attach-starts-at-backdate-entity-new-sub"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + const { autumnV1, autumnV2_2, ctx, advancedTo, entities } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const startsAt = advancedTo - ms.days(35); + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[0]!.id, + plan_id: premium.id, + starts_at: startsAt, + new_billing_subscription: true, + }); + + await expectAttachBackdateCorrect({ + autumn: autumnV1, + ctx, + customerId, + productId: premium.id, + startsAt, + result, + minInvoiceTotal: 5000, + minInvoiceLineCount: 2, + expectedInvoiceCount: 2, + }); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-preview.test.ts b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-preview.test.ts new file mode 100644 index 000000000..cd6c41952 --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-preview.test.ts @@ -0,0 +1,168 @@ +/** + * Preview accuracy for backdated starts_at on new Stripe subscriptions. + * + * Contract under test: + * - Immediate preview total = base price × number of elapsed billing periods + * (Stripe flexible billing emits one line item per backdated period). + * - next_cycle.starts_at = the renewal boundary anchored to the past starts_at + * (getCycleEnd(startsAt, now)), with next_cycle.total = one full cycle. + * - Feature resets (next_reset_at) align to the backdated anchor. + * + * preview.total / subtotal must equal the executed invoice total, so these + * assertions are cross-checked against the real Stripe backdated invoice. + */ + +import { expect, test } from "bun:test"; +import { + type AttachParamsV1Input, + type AttachPreviewResponse, + ms, +} from "@autumn/shared"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { expectResetAnchoredTo } from "./utils"; +import { expectAttachBackdateCorrect } from "./utils/expectAttachBackdateCorrect"; + +test.concurrent( + `${chalk.yellowBright("starts_at backdate preview: single elapsed cycle bills one period, renews next month")}`, + async () => { + const customerId = "attach-backdate-preview-one-cycle"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const startsAt = advancedTo - ms.days(10); + + const preview = + (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + })) as AttachPreviewResponse; + + // One elapsed period -> one full base charge now. + expect(preview.total).toBe(20); + expect(preview.subtotal).toBe(20); + expect( + preview.line_items.reduce((sum, lineItem) => sum + lineItem.total, 0), + ).toBe(preview.total); + + // Renewal is anchored to starts_at + 1 month, charging a full cycle. + expectPreviewNextCycleCorrect({ + preview, + startsAt: addMonths(startsAt, 1).getTime(), + total: 20, + }); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + }); + + // preview.total must equal the real backdated invoice total. + expect(result.invoice?.total).toBe(preview.total); + + const cusProduct = await expectAttachBackdateCorrect({ + autumn: autumnV1, + ctx, + customerId, + productId: pro.id, + startsAt, + result, + minInvoiceTotal: 1900, + minInvoiceLineCount: 1, + }); + + expectResetAnchoredTo({ + cusProduct, + featureId: TestFeature.Messages, + startDate: startsAt, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("starts_at backdate preview: two elapsed cycles bill two periods, renews two months out")}`, + async () => { + const customerId = "attach-backdate-preview-two-cycles"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + // 40 days back reliably spans two monthly periods regardless of month length. + const startsAt = advancedTo - ms.days(40); + + const preview = + (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + })) as AttachPreviewResponse; + + // Two elapsed periods -> two full base charges now. + expect(preview.total).toBe(40); + expect(preview.subtotal).toBe(40); + expect( + preview.line_items.reduce((sum, lineItem) => sum + lineItem.total, 0), + ).toBe(preview.total); + + // Renewal is anchored to starts_at + 2 months, charging a single full cycle. + expectPreviewNextCycleCorrect({ + preview, + startsAt: addMonths(startsAt, 2).getTime(), + total: 20, + }); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + }); + + expect(result.invoice?.total).toBe(preview.total); + + const cusProduct = await expectAttachBackdateCorrect({ + autumn: autumnV1, + ctx, + customerId, + productId: pro.id, + startsAt, + result, + minInvoiceTotal: 3900, + minInvoiceLineCount: 2, + }); + + // Next reset aligns to the anchor two months out (one month past now). + expectResetAnchoredTo({ + cusProduct, + featureId: TestFeature.Messages, + startDate: addMonths(startsAt, 1).getTime(), + }); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-scheduled-replacement.test.ts b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-scheduled-replacement.test.ts new file mode 100644 index 000000000..6afe7f7a9 --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate-scheduled-replacement.test.ts @@ -0,0 +1,58 @@ +/** + * TDD test for backdated starts_at when a future schedule already exists. + * + * Contract under test: + * New behaviors: + * - A customer with an existing future Stripe subscription schedule cannot replace it with a past starts_at + * - This keeps backdating scoped to fresh Stripe subscription creation instead of rewriting scheduled subscription history + */ + +import { test } from "bun:test"; +import { type AttachParamsV1Input, ErrCode, ms } from "@autumn/shared"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +test.concurrent( + `${chalk.yellowBright("starts_at backdate: replacing a future schedule is rejected")}`, + async () => { + const customerId = "attach-starts-at-backdate-scheduled-replace"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + const { autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: advancedTo + ms.days(30), + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: + "Past starts_at is only supported when creating a new Stripe subscription", + func: () => + autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + starts_at: advancedTo - ms.days(10), + }), + }); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/starts-at-backdate.test.ts b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate.test.ts new file mode 100644 index 000000000..3021d5338 --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/starts-at-backdate.test.ts @@ -0,0 +1,132 @@ +/** + * TDD test for backdated starts_at on new Stripe subscriptions. + * + * Contract under test: + * New types/fields: + * - Internal BillingContext.subscriptionBackdateStartMs?: epoch milliseconds + * New endpoints: + * - Existing billing.attach accepts starts_at in the past for supported new-subscription creation + * New behaviors: + * - Paid recurring attach with payment method and no existing Stripe subscription creates one Stripe subscription with start_date backdated to starts_at + * - The first invoice is created by Stripe for the backdated subscription + * - Past starts_at is rejected when the customer already has a Stripe subscription + * - Past starts_at is rejected when Stripe Checkout would be required + * Side effects: + * - Autumn customer_product is active, stores the past starts_at, and links to the created Stripe subscription + * + * Pre-impl red: attach rejects all past starts_at values before Stripe subscription creation can run. + * Post-impl green: supported new subscriptions use Stripe backdate_start_date and unsupported paths fail fast. + */ + +import { expect, test } from "bun:test"; +import { type AttachParamsV1Input, ErrCode, ms } from "@autumn/shared"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { expectAttachBackdateCorrect } from "./utils/expectAttachBackdateCorrect"; + +test.concurrent( + `${chalk.yellowBright("starts_at backdate: new paid recurring subscription is backdated")}`, + async () => { + const customerId = "attach-starts-at-backdate-new-sub"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const startsAt = advancedTo - ms.days(35); + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + }); + + expect(result.invoice?.stripe_id).toBeDefined(); + await expectAttachBackdateCorrect({ + autumn: autumnV1, + ctx, + customerId, + productId: pro.id, + startsAt, + result, + minInvoiceTotal: 2000, + minInvoiceLineCount: 2, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("starts_at backdate: existing subscriptions are rejected")}`, + async () => { + const customerId = "attach-starts-at-backdate-existing-sub"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + const { autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: + "Past starts_at is only supported when creating a new Stripe subscription", + func: () => + autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + starts_at: advancedTo - ms.days(10), + }), + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("starts_at backdate: Stripe Checkout-required attaches are rejected")}`, + async () => { + const customerId = "attach-starts-at-backdate-checkout"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [s.customer({}), s.products({ list: [pro] })], + actions: [], + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: + "Past starts_at cannot be used when Stripe Checkout is required", + func: () => + autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: advancedTo - ms.days(10), + }), + }); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/starts-at-validation.test.ts b/server/tests/integration/billing/attach/params/start-date/starts-at-validation.test.ts index b2619ea67..3970699d4 100644 --- a/server/tests/integration/billing/attach/params/start-date/starts-at-validation.test.ts +++ b/server/tests/integration/billing/attach/params/start-date/starts-at-validation.test.ts @@ -13,11 +13,11 @@ import chalk from "chalk"; import { addDays, subDays } from "date-fns"; test.concurrent( - `${chalk.yellowBright("starts_at: past dates are rejected")}`, + `${chalk.yellowBright("starts_at: past date rejects free plans")}`, async () => { - const customerId = "attach-start-date-past"; - const pro = products.pro({ - id: "pro", + const customerId = "attach-start-date-past-free"; + const free = products.base({ + id: "free", items: [items.monthlyMessages({ includedUsage: 100 })], }); @@ -25,18 +25,18 @@ test.concurrent( customerId, setup: [ s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), + s.products({ list: [free] }), ], actions: [], }); await expectAutumnError({ errCode: ErrCode.InvalidRequest, - errMessage: "starts_at cannot be set to a past timestamp", + errMessage: "Past starts_at is only supported for paid recurring plans", func: () => autumnV2_2.billing.attach({ customer_id: customerId, - plan_id: pro.id, + plan_id: free.id, starts_at: subDays(advancedTo, 1).getTime(), }), }); @@ -268,6 +268,72 @@ test.concurrent( }, ); +test.concurrent( + `${chalk.yellowBright("starts_at: past date rejects free trials")}`, + async () => { + const customerId = "attach-start-date-past-trial"; + const proTrial = products.proWithTrial({ + id: "pro-trial", + trialDays: 7, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [], + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: "Past starts_at cannot be used together with a free trial", + func: () => + autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: proTrial.id, + starts_at: subDays(advancedTo, 1).getTime(), + }), + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("starts_at: past date rejects free trials at preview time")}`, + async () => { + const customerId = "attach-start-date-past-trial-preview"; + const proTrial = products.proWithTrial({ + id: "pro-trial", + trialDays: 7, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [], + }); + + // The trial check is intentionally not preview-gated, so the form surfaces + // the error before the user clicks confirm. + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: "Past starts_at cannot be used together with a free trial", + func: () => + autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: proTrial.id, + starts_at: subDays(advancedTo, 1).getTime(), + }), + }); + }, +); + test.concurrent( `${chalk.yellowBright("starts_at: beta future date rejects custom free trial")}`, async () => { diff --git a/server/tests/integration/billing/attach/params/start-date/utils/expectAttachBackdateCorrect.ts b/server/tests/integration/billing/attach/params/start-date/utils/expectAttachBackdateCorrect.ts new file mode 100644 index 000000000..567084a02 --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/utils/expectAttachBackdateCorrect.ts @@ -0,0 +1,59 @@ +import { expect } from "bun:test"; +import { + type ApiCustomerV3, + type BillingResponse, + CusProductStatus, +} from "@autumn/shared"; +import { expectBackdatedStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectBackdatedStripeSubscriptionCorrect"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import type { AutumnInt } from "@/external/autumn/autumnCli"; +import { getCustomerProduct } from "."; + +export const expectAttachBackdateCorrect = async ({ + autumn, + ctx, + customerId, + productId, + startsAt, + result, + minInvoiceTotal = 2000, + minInvoiceLineCount, + expectedInvoiceCount = 1, +}: { + autumn: AutumnInt; + ctx: TestContext; + customerId: string; + productId: string; + startsAt: number; + result: BillingResponse; + minInvoiceTotal?: number; + minInvoiceLineCount?: number; + expectedInvoiceCount?: number; +}) => { + expect(result.invoice?.stripe_id).toBeDefined(); + expect(result.invoice?.total).toBeGreaterThan(minInvoiceTotal / 100); + + const customer = await autumn.customers.get(customerId); + expect(customer.invoices).toHaveLength(expectedInvoiceCount); + + const cusProduct = await getCustomerProduct({ + ctx, + customerId, + productId, + }); + expect(cusProduct.status).toBe(CusProductStatus.Active); + expect(cusProduct.starts_at).toBe(startsAt); + expect(cusProduct.scheduled_ids ?? []).toEqual([]); + expect(cusProduct.subscription_ids).toHaveLength(1); + + await expectBackdatedStripeSubscriptionCorrect({ + ctx, + stripeSubscriptionId: cusProduct.subscription_ids![0]!, + startsAt, + stripeInvoiceId: result.invoice!.stripe_id, + minInvoiceTotal, + minInvoiceLineCount, + }); + + return cusProduct; +}; diff --git a/server/tests/integration/billing/attach/params/start-date/utils.ts b/server/tests/integration/billing/attach/params/start-date/utils/index.ts similarity index 100% rename from server/tests/integration/billing/attach/params/start-date/utils.ts rename to server/tests/integration/billing/attach/params/start-date/utils/index.ts diff --git a/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-migration.test.ts b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-migration.test.ts new file mode 100644 index 000000000..40b997236 --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-migration.test.ts @@ -0,0 +1,132 @@ +/** + * Migration execution should emit billing.updated like normal billing actions. + * Red: server-run migrations mutate Autumn but never send the webhook. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { + BillingChangeResponse, + CustomerPlanChange, + PlanChangeAction, +} from "@autumn/shared"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { runUpdatePlanMigration } from "@tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type BillingUpdatedPayload = { + type: string; + data: BillingChangeResponse; +}; + +const findChange = ( + planChanges: CustomerPlanChange[] | undefined, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + planChanges?.find( + (change) => + change.action === action && + (change.subscription?.plan_id ?? change.purchase?.plan_id) === planId, + ); + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["billing.updated"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +test(`${chalk.yellowBright("billing.updated: migration update_plan emits webhook")}`, async () => { + const suffix = Date.now(); + const customerId = `billing-updated-migration-${suffix}`; + const enterprise = products.base({ + id: `enterprise-migration-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, ctx: scenarioCtx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [enterprise] }), + ], + actions: [s.billing.attach({ productId: enterprise.id })], + }); + + let webhookResult: + | Awaited>> + | undefined; + + await runUpdatePlanMigration({ + ctx: scenarioCtx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: enterprise.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: enterprise.id }, + customize: { + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: true, + waitFor: async () => { + webhookResult = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "updated", + planId: enterprise.id, + }) !== undefined, + timeoutMs: 5_000, + logWebhook: false, + }); + expect(webhookResult).not.toBeNull(); + }, + timeoutMs: 20_000, + pollIntervalMs: 500, + }); + + expect(webhookResult).toBeDefined(); + const { data } = webhookResult!.payload; + const updated = findChange(data.plan_changes, { + action: "updated", + planId: enterprise.id, + }); + expect(updated).toBeDefined(); + expect(updated?.item_changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "created", + feature_id: TestFeature.Dashboard, + }), + ]), + ); +}); diff --git a/server/tests/integration/billing/create-schedule/annual-monthly/create-schedule-annual-monthly.test.ts b/server/tests/integration/billing/create-schedule/annual-monthly/create-schedule-annual-monthly.test.ts new file mode 100644 index 000000000..0650cbb05 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/annual-monthly/create-schedule-annual-monthly.test.ts @@ -0,0 +1,399 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiEntityV2, + type AttachPreviewResponse, + type CreateScheduleParamsV0Input, + type CreateScheduleResponse, + CusProductStatus, + customerProducts, + ms, +} from "@autumn/shared"; +import { + ANNUAL_MONTHLY_MESSAGES_PHASES, + annualMonthlyMessagesPlan, + annualMonthlyPhasePlan, + countMonthlyPeriods, + expectAnnualMonthlyPreviewCorrect, + expectAnnualMonthlyStripeInvoiceCorrect, + expectedAnnualMonthlyImmediateTotal, + monthlyPeriodsFrom, + nextMonthlyBoundary, + prepaidMessagesAmount, +} from "@tests/integration/billing/utils/annualMonthlyMessagesTestUtils"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { inArray } from "drizzle-orm"; +import { expectBackdatedStripeSubscriptionCorrect } from "../../utils/expectBackdatedStripeSubscriptionCorrect"; +import { + expectResetAnchoredTo, + getCustomerProduct, +} from "../../attach/params/start-date/utils"; + +const previewCreateSchedule = async ({ + autumnV1, + params, +}: { + autumnV1: Awaited>["autumnV1"]; + params: CreateScheduleParamsV0Input; +}): Promise => + await autumnV1.post("/billing.preview_create_schedule", params); + +const threePhaseParams = ({ + customerId, + entityId, + planId, + startsAt, +}: { + customerId: string; + entityId: string; + planId: string; + startsAt: number; +}): CreateScheduleParamsV0Input => { + const phases = ANNUAL_MONTHLY_MESSAGES_PHASES.map((phase, index) => ({ + starts_at: addMonths(startsAt, index * 4).getTime(), + plans: [ + annualMonthlyPhasePlan({ + planId, + annualAmount: phase.annualAmount, + prepaidQuantity: phase.prepaidQuantity, + }), + ], + })); + + return { + customer_id: customerId, + entity_id: entityId, + phases: phases as CreateScheduleParamsV0Input["phases"], + }; +}; + +const latestStripeInvoice = async ({ + ctx, + customer, +}: { + ctx: Awaited>["ctx"]; + customer: ApiCustomerV3; +}) => { + const stripeId = customer.invoices?.[0]?.stripe_id; + if (!stripeId) throw new Error("Expected latest invoice to have stripe_id"); + + return await ctx.stripeCli.invoices.retrieve(stripeId, { + expand: ["lines.data.price"], + }); +}; + +const expectThreePhaseBackdatedRowsCorrect = async ({ + ctx, + response, + planId, + entityId, + startsAt, + phase2StartsAt, + phase3StartsAt, + cycleCount, + expectedInvoiceTotal, +}: { + ctx: Awaited>["ctx"]; + response: CreateScheduleResponse; + planId: string; + entityId: string; + startsAt: number; + phase2StartsAt: number; + phase3StartsAt: number; + cycleCount: number; + expectedInvoiceTotal: number; +}) => { + const expectTimestamp = (actual: number | null | undefined, expected: number) => { + expect(actual).toBeDefined(); + expect(Math.abs(actual! - expected)).toBeLessThan(ms.seconds(2)); + }; + + expect(response.status).toBe("created"); + expect(response.invoice?.stripe_id).toBeDefined(); + expect(response.invoice?.total).toBe(expectedInvoiceTotal); + expect(response.phases).toHaveLength(3); + expect(response.phases.map((phase) => phase.starts_at)).toEqual([ + startsAt, + phase2StartsAt, + phase3StartsAt, + ]); + + const customerProductIds = response.phases.flatMap( + (phase) => phase.customer_product_ids, + ); + const rows = await ctx.db + .select() + .from(customerProducts) + .where(inArray(customerProducts.id, customerProductIds)); + const rowById = new Map(rows.map((row) => [row.id, row])); + const immediate = rowById.get(response.phases[0]!.customer_product_ids[0]!); + const phase2 = rowById.get(response.phases[1]!.customer_product_ids[0]!); + const phase3 = rowById.get(response.phases[2]!.customer_product_ids[0]!); + + expect(immediate).toMatchObject({ + product_id: planId, + entity_id: entityId, + status: CusProductStatus.Active, + starts_at: startsAt, + }); + expect(phase2).toMatchObject({ + product_id: planId, + entity_id: entityId, + status: CusProductStatus.Scheduled, + }); + expectTimestamp(phase2?.starts_at, phase2StartsAt); + expect(phase3).toMatchObject({ + product_id: planId, + entity_id: entityId, + status: CusProductStatus.Scheduled, + }); + expectTimestamp(phase3?.starts_at, phase3StartsAt); + + await expectBackdatedStripeSubscriptionCorrect({ + ctx, + stripeSubscriptionId: immediate!.subscription_ids![0]!, + startsAt, + stripeInvoiceId: response.invoice!.stripe_id, + minInvoiceTotal: expectedInvoiceTotal * 100 - 1, + minInvoiceLineCount: cycleCount + 1, + expandSchedule: true, + }); +}; + +test.concurrent( + `${chalk.yellowBright("create-schedule annual monthly: three-phase entity preview matches executed invoice")}`, + async () => { + const customerId = "create-schedule-annual-monthly-preview"; + const plan = annualMonthlyMessagesPlan(); + + const { autumnV1, ctx, entities, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const entityId = entities[0]!.id; + const startsAt = advancedTo; + const firstPhase = ANNUAL_MONTHLY_MESSAGES_PHASES[0]!; + const params = threePhaseParams({ + customerId, + entityId, + planId: plan.id, + startsAt, + }); + + const preview = await previewCreateSchedule({ autumnV1, params }); + expectAnnualMonthlyPreviewCorrect({ + preview, + annualAmount: firstPhase.annualAmount, + prepaidQuantity: firstPhase.prepaidQuantity, + startsAt, + currentEpochMs: advancedTo, + }); + + const response = await autumnV1.billing.createSchedule(params); + expect(response.status).toBe("created"); + expect(response.invoice?.total).toBe(preview.total); + + const customer = await autumnV1.customers.get(customerId); + const invoice = await latestStripeInvoice({ ctx, customer }); + const monthlyAmount = prepaidMessagesAmount({ + quantity: firstPhase.prepaidQuantity, + }); + + expectAnnualMonthlyStripeInvoiceCorrect({ + invoice, + annualAmount: firstPhase.annualAmount, + monthlyAmount, + monthlyPeriods: monthlyPeriodsFrom({ startsAt, count: 1 }), + expectedTotal: firstPhase.annualAmount + monthlyAmount, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + const cusProduct = await getCustomerProduct({ + ctx, + customerId, + productId: plan.id, + }); + expectResetAnchoredTo({ + cusProduct, + featureId: TestFeature.Messages, + startDate: startsAt, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule annual monthly backdate: preview aggregates monthly cycles and renewal is next chronological event")}`, + async () => { + const customerId = "create-schedule-annual-monthly-backdate"; + const plan = annualMonthlyMessagesPlan(); + + const { autumnV1, ctx, entities, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const entityId = entities[0]!.id; + const startsAt = advancedTo - ms.days(40); + const firstPhase = ANNUAL_MONTHLY_MESSAGES_PHASES[0]!; + const params = threePhaseParams({ + customerId, + entityId, + planId: plan.id, + startsAt, + }); + + const preview = await previewCreateSchedule({ autumnV1, params }); + expectAnnualMonthlyPreviewCorrect({ + preview, + annualAmount: firstPhase.annualAmount, + prepaidQuantity: firstPhase.prepaidQuantity, + startsAt, + currentEpochMs: advancedTo, + }); + + const response = await autumnV1.billing.createSchedule(params); + const cycleCount = countMonthlyPeriods({ + startsAt, + currentEpochMs: advancedTo, + }); + const monthlyAmount = prepaidMessagesAmount({ + quantity: firstPhase.prepaidQuantity, + }); + + await expectThreePhaseBackdatedRowsCorrect({ + ctx, + response, + planId: plan.id, + entityId, + startsAt, + phase2StartsAt: addMonths(startsAt, 4).getTime(), + phase3StartsAt: addMonths(startsAt, 8).getTime(), + cycleCount, + expectedInvoiceTotal: preview.total, + }); + expect(response.invoice?.total).toBe(preview.total); + + const customer = await autumnV1.customers.get(customerId); + const invoice = await latestStripeInvoice({ ctx, customer }); + expectAnnualMonthlyStripeInvoiceCorrect({ + invoice, + annualAmount: firstPhase.annualAmount, + monthlyAmount, + monthlyPeriods: monthlyPeriodsFrom({ startsAt, count: cycleCount }), + expectedTotal: expectedAnnualMonthlyImmediateTotal({ + annualAmount: firstPhase.annualAmount, + prepaidQuantity: firstPhase.prepaidQuantity, + startsAt, + currentEpochMs: advancedTo, + }), + }); + + const cusProduct = await getCustomerProduct({ + ctx, + customerId, + productId: plan.id, + }); + expectResetAnchoredTo({ + cusProduct, + featureId: TestFeature.Messages, + startDate: addMonths(startsAt, cycleCount - 1).getTime(), + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule annual monthly backdate: first renewal bills prepaid exactly")}`, + async () => { + const customerId = "create-schedule-annual-monthly-backdate-renewal"; + const plan = annualMonthlyMessagesPlan(); + + const { + autumnV1, + autumnV2_1, + ctx, + entities, + testClockId, + advancedTo, + } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const entityId = entities[0]!.id; + const startsAt = advancedTo - ms.days(40); + const firstPhase = ANNUAL_MONTHLY_MESSAGES_PHASES[0]!; + const params = threePhaseParams({ + customerId, + entityId, + planId: plan.id, + startsAt, + }); + + await autumnV1.billing.createSchedule(params); + + const nextCycleStart = nextMonthlyBoundary({ + startsAt, + currentEpochMs: advancedTo, + }); + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours(nextCycleStart, hoursToFinalizeInvoice).getTime(), + waitForSeconds: 30, + }); + + const customer = await autumnV1.customers.get(customerId); + const prepaidAmount = prepaidMessagesAmount({ + quantity: firstPhase.prepaidQuantity, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: prepaidAmount, + }); + + const invoice = await latestStripeInvoice({ ctx, customer }); + expectAnnualMonthlyStripeInvoiceCorrect({ + invoice, + monthlyAmount: prepaidAmount, + monthlyPeriods: monthlyPeriodsFrom({ startsAt: nextCycleStart, count: 1 }), + expectedTotal: prepaidAmount, + }); + + const entity = await autumnV2_1.entities.get( + customerId, + entityId, + ); + expectBalanceCorrect({ + customer: entity, + featureId: TestFeature.Messages, + remaining: firstPhase.prepaidQuantity, + usage: 0, + nextResetAt: addMonths(nextCycleStart, 1).getTime(), + }); + }, +); diff --git a/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate-preview.test.ts b/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate-preview.test.ts new file mode 100644 index 000000000..57fc81c95 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate-preview.test.ts @@ -0,0 +1,273 @@ +/** + * Preview accuracy for backdated first phases in create_schedule. + * + * Contract under test: + * - Immediate preview total = sum of first-phase base prices × elapsed cycles + * (Stripe's backdate_start_date invoices one period per elapsed cycle), and + * equals the executed createSchedule invoice total. + * - next_cycle is the NEXT chronological event: + * Case A (phase 2 lands after the renewal boundary): next_cycle is the + * renewal — anchored to starts_at, charging one full first-phase cycle. + * Case B (phase 2 lands before the renewal boundary): next_cycle is the + * prorated phase-2 replacement at phase 2 starts_at — incoming plan + * charged and outgoing plan credited over the remaining window. + * - Feature resets (next_reset_at) align to the backdated anchor. + */ + +import { expect, test } from "bun:test"; +import { + type AttachPreviewResponse, + BillingInterval, + type CreateScheduleParamsV0Input, + getCycleEnd, + ms, +} from "@autumn/shared"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { createAmountCoupon } from "../../utils/discounts/discountTestUtils"; +import { + expectResetAnchoredTo, + getCustomerProduct, +} from "../../attach/params/start-date/utils"; + +const previewCreateSchedule = async ({ + autumnV1, + params, +}: { + autumnV1: Awaited>["autumnV1"]; + params: CreateScheduleParamsV0Input; +}): Promise => + await autumnV1.post("/billing.preview_create_schedule", params); + +test.concurrent( + `${chalk.yellowBright("create-schedule backdate preview A: multi-plan first phase bills elapsed cycles, next_cycle is the renewal")}`, + async () => { + const customerId = "create-schedule-backdate-preview-renewal"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon, premium] }), + ], + actions: [], + }); + + // 40 days back spans two monthly cycles regardless of month length. + const phase1StartsAt = advancedTo - ms.days(40); + // 60 days out lands well after the renewal boundary (~20 days out). + const phase2StartsAt = advancedTo + ms.days(60); + + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + phases: [ + { + starts_at: phase1StartsAt, + plans: [{ plan_id: pro.id }, { plan_id: addon.id }], + }, + { + starts_at: phase2StartsAt, + plans: [{ plan_id: premium.id }], + }, + ], + }; + + const preview = await previewCreateSchedule({ autumnV1, params }); + + // Two elapsed cycles × (pro $20 + addon $20) = $80 now. + expect(preview.total).toBe(80); + expect(preview.subtotal).toBe(80); + expect( + preview.line_items.reduce((sum, lineItem) => sum + lineItem.total, 0), + ).toBe(preview.total); + + // Phase 2 is after the renewal, so next_cycle is the renewal: one full + // first-phase cycle (pro $20 + addon $20), anchored two months past start. + expectPreviewNextCycleCorrect({ + preview, + startsAt: addMonths(phase1StartsAt, 2).getTime(), + total: 40, + }); + + const response = await autumnV1.billing.createSchedule(params); + expect(response.status).toBe("created"); + // preview.total must equal the real backdated invoice total. + expect(response.invoice?.total).toBe(preview.total); + + // Reset aligns to the backdated anchor: next reset is two months past start. + const proCusProduct = await getCustomerProduct({ + ctx, + customerId, + productId: pro.id, + }); + expectResetAnchoredTo({ + cusProduct: proCusProduct, + featureId: TestFeature.Messages, + startDate: addMonths(phase1StartsAt, 1).getTime(), + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule backdate preview C: amount-off discount applies once to backdated immediate invoice")}`, + async () => { + const customerId = "create-schedule-backdate-preview-discount"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 500, + durationInMonths: 12, + }); + const phase1StartsAt = advancedTo - ms.days(40); + const phase2StartsAt = advancedTo + ms.days(60); + + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + discounts: [{ reward_id: coupon.id }], + phases: [ + { + starts_at: phase1StartsAt, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: phase2StartsAt, + plans: [{ plan_id: premium.id }], + }, + ], + }; + + const preview = await previewCreateSchedule({ autumnV1, params }); + + expect(preview.subtotal).toBe(40); + expect(preview.total).toBe(35); + expect(preview.line_items[0]?.period).toEqual({ + start: phase1StartsAt, + end: addMonths(phase1StartsAt, 2).getTime(), + }); + expectPreviewNextCycleCorrect({ + preview, + startsAt: addMonths(phase1StartsAt, 2).getTime(), + total: 15, + }); + + const response = await autumnV1.billing.createSchedule(params); + expect(response.status).toBe("created"); + expect(response.invoice?.total).toBe(preview.total); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule backdate preview B: phase 2 before renewal is a prorated upgrade in next_cycle")}`, + async () => { + const customerId = "create-schedule-backdate-preview-prorated"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + // 25 days back = one elapsed cycle; phase 2 one day out sits before the + // renewal boundary (>= 3 days out), so it is a mid-cycle scheduled change. + const phase1StartsAt = advancedTo - ms.days(25); + const phase2StartsAt = advancedTo + ms.days(1); + + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + phases: [ + { + starts_at: phase1StartsAt, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: phase2StartsAt, + plans: [{ plan_id: premium.id }], + }, + ], + }; + + const preview = await previewCreateSchedule({ autumnV1, params }); + + // One elapsed cycle of pro ($20) now. + expect(preview.total).toBe(20); + expect(preview.subtotal).toBe(20); + + // next_cycle is the phase-2 replacement, prorated over the remaining window. + const renewalMs = getCycleEnd({ + anchor: phase1StartsAt, + interval: BillingInterval.Month, + intervalCount: 1, + now: phase2StartsAt, + floor: phase1StartsAt, + }); + const ratio = (renewalMs - phase2StartsAt) / (renewalMs - phase1StartsAt); + // Incoming premium ($50) charged minus outgoing pro ($20) credited, prorated. + const expectedNextTotal = Math.round(ratio * (50 - 20) * 100) / 100; + + const nextCycle = expectPreviewNextCycleCorrect({ + preview, + startsAt: phase2StartsAt, + }); + + expect( + Math.abs((nextCycle?.total ?? 0) - expectedNextTotal) < 0.1, + `next_cycle.total ${nextCycle?.total} should be within 0.1 of prorated ${expectedNextTotal}`, + ).toBe(true); + + // The change shows an incoming premium charge and an outgoing pro credit. + const lineItems = nextCycle?.line_items ?? []; + expect(lineItems.some((lineItem) => lineItem.total > 0)).toBe(true); + expect(lineItems.some((lineItem) => lineItem.total < 0)).toBe(true); + + const response = await autumnV1.billing.createSchedule(params); + expect(response.status).toBe("created"); + expect(response.invoice?.total).toBe(preview.total); + }, +); diff --git a/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate.test.ts b/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate.test.ts new file mode 100644 index 000000000..7469f51bd --- /dev/null +++ b/server/tests/integration/billing/create-schedule/backdate/create-schedule-backdate.test.ts @@ -0,0 +1,228 @@ +/** + * TDD tests for backdated first phases in create_schedule. + * + * Contract under test: + * New types/fields: + * - Internal BillingContext.subscriptionBackdateStartMs?: epoch milliseconds + * New endpoints: + * - Existing billing.createSchedule accepts a first phase starts_at in the past for supported new-subscription creation + * New behaviors: + * - Backdated first phase creates one Stripe subscription with start_date backdated to phase starts_at + * - Future phases are still materialized as scheduled Autumn customer products and Stripe subscription schedule phases + * - Backdated first phases can include add-ons or be scoped to an entity + * Side effects: + * - Immediate-phase customer_products are active and store the past starts_at + * - First invoice is created by Stripe for the backdated subscription + */ + +import { test } from "bun:test"; +import { + type CreateScheduleParamsV0Input, + CusProductStatus, + ms, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { expectCreateScheduleBackdateCorrect } from "../utils/expectCreateScheduleBackdateCorrect"; + +test.concurrent( + `${chalk.yellowBright("create-schedule backdate: first phase creates backdated subscription")}`, + async () => { + const customerId = "create-schedule-backdate"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const startsAt = advancedTo - ms.days(35); + const futureStartsAt = advancedTo + ms.days(30); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + phases: [ + { + starts_at: startsAt, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: futureStartsAt, + plans: [{ plan_id: premium.id }], + }, + ], + }; + + const response = await autumnV1.billing.createSchedule(params); + + await expectCreateScheduleBackdateCorrect({ + ctx, + response, + immediate: { + productId: pro.id, + status: CusProductStatus.Active, + startsAt, + }, + scheduled: [ + { + productId: premium.id, + status: CusProductStatus.Scheduled, + startsAt: futureStartsAt, + }, + ], + minInvoiceTotal: 2000, + minInvoiceLineCount: 2, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule backdate add-ons: first phase bills main and add-on")}`, + async () => { + const customerId = "create-schedule-backdate-addons"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyUsers({ includedUsage: 5 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + const { autumnV1, ctx, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon, premium] }), + ], + actions: [], + }); + + const startsAt = advancedTo - ms.days(35); + const futureStartsAt = advancedTo + ms.days(30); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + phases: [ + { + starts_at: startsAt, + plans: [{ plan_id: pro.id }, { plan_id: addon.id }], + }, + { + starts_at: futureStartsAt, + plans: [{ plan_id: premium.id }], + }, + ], + }; + + const response = await autumnV1.billing.createSchedule(params); + + await expectCreateScheduleBackdateCorrect({ + ctx, + response, + immediate: [ + { + productId: pro.id, + status: CusProductStatus.Active, + startsAt, + }, + { + productId: addon.id, + status: CusProductStatus.Active, + startsAt, + }, + ], + scheduled: [ + { + productId: premium.id, + status: CusProductStatus.Scheduled, + startsAt: futureStartsAt, + }, + ], + minInvoiceTotal: 4000, + minInvoiceLineCount: 4, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule backdate entity: first phase creates entity-scoped backdated subscription")}`, + async () => { + const customerId = "create-schedule-backdate-entity"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + const { autumnV1, ctx, advancedTo, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const entityId = entities[0]!.id; + const startsAt = advancedTo - ms.days(35); + const futureStartsAt = advancedTo + ms.days(30); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + entity_id: entityId, + phases: [ + { + starts_at: startsAt, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: futureStartsAt, + plans: [{ plan_id: premium.id }], + }, + ], + }; + + const response = await autumnV1.billing.createSchedule(params); + + await expectCreateScheduleBackdateCorrect({ + ctx, + response, + immediate: { + productId: pro.id, + status: CusProductStatus.Active, + startsAt, + entityId, + }, + scheduled: [ + { + productId: premium.id, + status: CusProductStatus.Scheduled, + startsAt: futureStartsAt, + entityId, + }, + ], + minInvoiceTotal: 2000, + minInvoiceLineCount: 2, + }); + }, +); diff --git a/server/tests/integration/billing/create-schedule/create-schedule-annual-proration.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-annual-proration.test.ts index a8d55379a..ee4f75d59 100644 --- a/server/tests/integration/billing/create-schedule/create-schedule-annual-proration.test.ts +++ b/server/tests/integration/billing/create-schedule/create-schedule-annual-proration.test.ts @@ -79,6 +79,46 @@ const pendingStripeInvoiceItems = async ({ }); }; +const stripeInvoicesForCustomer = async ({ + ctx, + customer, +}: { + ctx: Awaited>["ctx"]; + customer: ApiCustomerV3; +}) => { + if (!customer.stripe_id) + throw new Error("Expected customer to have stripe_id"); + + const invoices = await ctx.stripeCli.invoices.list({ + customer: customer.stripe_id, + limit: 100, + }); + + return await Promise.all( + invoices.data.map((invoice) => + ctx.stripeCli.invoices.retrieve(invoice.id!, { + expand: ["lines.data.price"], + }), + ), + ); +}; + +const stripeSchedulesForCustomer = async ({ + ctx, + customer, +}: { + ctx: Awaited>["ctx"]; + customer: ApiCustomerV3; +}) => { + if (!customer.stripe_id) + throw new Error("Expected customer to have stripe_id"); + + return await ctx.stripeCli.subscriptionSchedules.list({ + customer: customer.stripe_id, + limit: 10, + }); +}; + const periodDuration = (period: { start: number; end: number }) => (period.end - period.start) * 1000; @@ -178,6 +218,70 @@ const expectedAnnualProrationDiff = ({ .toDecimalPlaces(2) .toNumber(); +const expectAmountCloseTo = ({ + actual, + expected, +}: { + actual: Decimal | number; + expected: Decimal | number; +}) => { + const diff = new Decimal(actual).minus(expected).abs(); + expect( + diff.lte(0.01), + `Expected $${new Decimal(actual).toFixed(2)} to be within $0.01 of $${new Decimal(expected).toFixed(2)}`, + ).toBe(true); +}; + +const expectAutumnInvoiceWithTotal = ({ + invoices, + total, +}: { + invoices: NonNullable; + total: Decimal | number; +}) => { + const invoice = invoices.find((candidate) => + new Decimal(candidate.total).minus(total).abs().lte(0.01), + ); + + expect( + invoice, + `Expected Autumn invoice total $${new Decimal(total).toFixed(2)}`, + ).toBeDefined(); + return invoice!; +}; + +const expectStripeInvoiceWithIntervalTotals = ({ + invoices, + yearTotal, + monthTotal, +}: { + invoices: Stripe.Invoice[]; + yearTotal: number; + monthTotal: number; +}) => { + const invoice = invoices.find((candidate) => { + const candidateYearTotal = intervalLineTotal({ + invoice: candidate, + interval: "year", + }); + const candidateMonthTotal = intervalLineTotal({ + invoice: candidate, + interval: "month", + }); + + return ( + candidateYearTotal.minus(yearTotal).abs().lte(0.01) && + candidateMonthTotal.minus(monthTotal).abs().lte(0.01) + ); + }); + + expect( + invoice, + `Expected Stripe invoice with yearly total $${yearTotal} and monthly total $${monthTotal}`, + ).toBeDefined(); + return invoice!; +}; + test.concurrent( `${chalk.yellowBright("create-schedule: customized annual prepaid proration ignores removed monthly prepaid")}`, async () => { @@ -261,6 +365,14 @@ test.concurrent( ctx, customer: initialCustomer, }); + const initialSchedules = await stripeSchedulesForCustomer({ + ctx, + customer: initialCustomer, + }); + expect(initialSchedules.data[0]?.phases[1]?.proration_behavior).toBe( + "always_invoice", + ); + expect(initialSchedules.data[0]?.billing_mode?.type).toBe("flexible"); const annualPeriod = annualPeriodFromInitialInvoice({ invoice: initialInvoice, }); @@ -273,11 +385,11 @@ test.concurrent( const customerAfterTransition = await autumnV1.customers.get(id); - const transitionInvoice = await latestStripeInvoice({ + const pendingItems = await pendingStripeInvoiceItems({ ctx, customer: customerAfterTransition, }); - const pendingItems = await pendingStripeInvoiceItems({ + const stripeInvoices = await stripeInvoicesForCustomer({ ctx, customer: customerAfterTransition, }); @@ -287,10 +399,37 @@ test.concurrent( transitionAt, billingPeriod: annualPeriod, }); - await expectCustomerInvoiceCorrect({ - customer: customerAfterTransition, - count: 2, - latestTotal: 10, + expect(customerAfterTransition.invoices).toHaveLength(3); + expectAutumnInvoiceWithTotal({ + invoices: customerAfterTransition.invoices!, + total: 10, + }); + expectAutumnInvoiceWithTotal({ + invoices: customerAfterTransition.invoices!, + total: new Decimal(expectedProration).minus(10), + }); + const prorationInvoice = expectStripeInvoiceWithIntervalTotals({ + invoices: stripeInvoices, + yearTotal: expectedProration, + monthTotal: -10, + }); + expectAmountCloseTo({ + actual: intervalLineTotal({ + invoice: prorationInvoice, + interval: "year", + }), + expected: expectedProration, + }); + expectAmountCloseTo({ + actual: intervalLineTotal({ + invoice: prorationInvoice, + interval: "month", + }), + expected: -10, + }); + expectAmountCloseTo({ + actual: new Decimal(prorationInvoice.total).div(100), + expected: new Decimal(expectedProration).minus(10), }); expect( pendingItemIntervalTotal({ @@ -299,42 +438,15 @@ test.concurrent( }) .toDecimalPlaces(2) .toNumber(), - ).toBe(expectedProration); + ).toBe(0); expect( - intervalLineTotal({ invoice: transitionInvoice, interval: "month" }) - .plus( - pendingItemIntervalTotal({ - items: pendingItems.data, - interval: "month", - }), - ) + pendingItemIntervalTotal({ + items: pendingItems.data, + interval: "month", + }) .toDecimalPlaces(2) .toNumber(), ).toBe(0); - expect( - new Decimal(transitionInvoice.total) - .div(100) - .plus( - pendingItemIntervalTotal({ - items: pendingItems.data, - interval: "month", - }), - ) - .plus( - pendingItemIntervalTotal({ - items: pendingItems.data, - interval: "year", - }), - ) - .toDecimalPlaces(2) - .toNumber(), - ).toBe(expectedProration); - expect( - pendingItems.data.some( - (item) => item.amount < 0 && item.amount !== -1000, - ), - ).toBe(true); - expect(pendingItems.data.some((item) => item.amount > 0)).toBe(true); }, ); @@ -415,6 +527,14 @@ test.concurrent( ctx, customer: initialCustomer, }); + const initialSchedules = await stripeSchedulesForCustomer({ + ctx, + customer: initialCustomer, + }); + expect(initialSchedules.data[0]?.phases[1]?.proration_behavior).toBe( + "always_invoice", + ); + expect(initialSchedules.data[0]?.billing_mode?.type).toBe("flexible"); const annualPeriod = annualPeriodFromInitialInvoice({ invoice: initialInvoice, }); @@ -427,11 +547,11 @@ test.concurrent( const customerAfterTransition = await autumnV1.customers.get(id); - const transitionInvoice = await latestStripeInvoice({ + const pendingItems = await pendingStripeInvoiceItems({ ctx, customer: customerAfterTransition, }); - const pendingItems = await pendingStripeInvoiceItems({ + const stripeInvoices = await stripeInvoicesForCustomer({ ctx, customer: customerAfterTransition, }); @@ -441,10 +561,37 @@ test.concurrent( transitionAt, billingPeriod: annualPeriod, }); - await expectCustomerInvoiceCorrect({ - customer: customerAfterTransition, - count: 2, - latestTotal: 10, + expect(customerAfterTransition.invoices).toHaveLength(3); + expectAutumnInvoiceWithTotal({ + invoices: customerAfterTransition.invoices!, + total: 10, + }); + expectAutumnInvoiceWithTotal({ + invoices: customerAfterTransition.invoices!, + total: new Decimal(expectedProration).minus(10), + }); + const prorationInvoice = expectStripeInvoiceWithIntervalTotals({ + invoices: stripeInvoices, + yearTotal: expectedProration, + monthTotal: -10, + }); + expectAmountCloseTo({ + actual: intervalLineTotal({ + invoice: prorationInvoice, + interval: "year", + }), + expected: expectedProration, + }); + expectAmountCloseTo({ + actual: intervalLineTotal({ + invoice: prorationInvoice, + interval: "month", + }), + expected: -10, + }); + expectAmountCloseTo({ + actual: new Decimal(prorationInvoice.total).div(100), + expected: new Decimal(expectedProration).minus(10), }); expect( pendingItemIntervalTotal({ @@ -453,41 +600,14 @@ test.concurrent( }) .toDecimalPlaces(2) .toNumber(), - ).toBe(expectedProration); + ).toBe(0); expect( - intervalLineTotal({ invoice: transitionInvoice, interval: "month" }) - .plus( - pendingItemIntervalTotal({ - items: pendingItems.data, - interval: "month", - }), - ) + pendingItemIntervalTotal({ + items: pendingItems.data, + interval: "month", + }) .toDecimalPlaces(2) .toNumber(), ).toBe(0); - expect( - new Decimal(transitionInvoice.total) - .div(100) - .plus( - pendingItemIntervalTotal({ - items: pendingItems.data, - interval: "month", - }), - ) - .plus( - pendingItemIntervalTotal({ - items: pendingItems.data, - interval: "year", - }), - ) - .toDecimalPlaces(2) - .toNumber(), - ).toBe(expectedProration); - expect( - pendingItems.data.some( - (item) => item.amount < 0 && item.amount !== -1000, - ), - ).toBe(true); - expect(pendingItems.data.some((item) => item.amount > 0)).toBe(true); }, ); diff --git a/server/tests/integration/billing/create-schedule/create-schedule-customize.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-customize.test.ts deleted file mode 100644 index 7e71486ef..000000000 --- a/server/tests/integration/billing/create-schedule/create-schedule-customize.test.ts +++ /dev/null @@ -1,575 +0,0 @@ -import { expect, test } from "bun:test"; -import { - CusProductStatus, - customerEntitlements, - customerProducts, - ms, - schedulePhases, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; -import { products } from "@tests/utils/fixtures/products"; -import { advanceTestClock } from "@tests/utils/stripeUtils"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; -import { eq } from "drizzle-orm"; -import { - getCustomerProductEntitlementBalances, - getCustomerProductPriceAmounts, - getRequiredScheduleId, -} from "./utils/createScheduleTestHelpers"; - -test.concurrent(`${chalk.yellowBright("create-schedule: preserves feature quantity options on created customer products")}`, async () => { - const prepaidMessages = products.base({ - id: "prepaid", - items: [items.prepaidMessages()], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-options", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [prepaidMessages] }), - ], - actions: [], - }); - - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [ - { - plan_id: prepaidMessages.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 400, - }, - ], - }, - ], - }, - ], - }); - - const insertedProducts = await ctx.db - .select({ - options: customerProducts.options, - }) - .from(customerProducts) - .where( - eq(customerProducts.id, response.phases[0]!.customer_product_ids[0]!), - ); - - expect(insertedProducts).toHaveLength(1); - expect(insertedProducts[0]!.options).toEqual([ - expect.objectContaining({ - feature_id: TestFeature.Messages, - quantity: 4, - }), - ]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on created customer products")}`, async () => { - const base = products.base({ - id: "custom-base", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-customize", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [ - { - plan_id: base.id, - customize: { - items: [itemsV2.monthlyWords({ included: 250 })], - }, - }, - ], - }, - ], - }); - - const insertedEntitlements = await ctx.db - .select({ - feature_id: customerEntitlements.feature_id, - balance: customerEntitlements.balance, - }) - .from(customerEntitlements) - .where( - eq( - customerEntitlements.customer_product_id, - response.phases[0]!.customer_product_ids[0]!, - ), - ); - - expect(insertedEntitlements).toEqual([ - { - feature_id: TestFeature.Words, - balance: 250, - }, - ]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on future scheduled customer products")}`, async () => { - const base = products.base({ - id: "custom-future-base", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-customize-future", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const now = Date.now(); - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(30), - plans: [ - { - plan_id: base.id, - customize: { - items: [itemsV2.monthlyWords({ included: 250 })], - }, - }, - ], - }, - ], - }); - - const futurePhase = response.phases[1]; - expect(futurePhase).toBeDefined(); - - const futureCustomerProductId = futurePhase?.customer_product_ids[0]; - expect(futureCustomerProductId).toBeTruthy(); - - if (!futureCustomerProductId) { - throw new Error( - "Expected a scheduled customer product for the future phase", - ); - } - - const insertedEntitlements = await ctx.db - .select({ - feature_id: customerEntitlements.feature_id, - balance: customerEntitlements.balance, - }) - .from(customerEntitlements) - .where( - eq(customerEntitlements.customer_product_id, futureCustomerProductId), - ); - - expect(insertedEntitlements).toEqual([ - { - feature_id: TestFeature.Words, - balance: 250, - }, - ]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: customized future phases keep custom prices and entitlements through activation")}`, async () => { - const base = products.base({ - id: "create-schedule-customize-rollover", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice(), - ], - }); - - const { customerId, autumnV1, ctx, testClockId, advancedTo } = - await initScenario({ - customerId: "create-schedule-customize-rollover", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const now = advancedTo; - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(15), - plans: [ - { - plan_id: base.id, - customize: { - price: itemsV2.monthlyPrice({ amount: 35 }), - items: [ - itemsV2.monthlyWords({ included: 250 }), - itemsV2.dashboard(), - ], - }, - }, - ], - }, - ], - }); - - const futureCustomerProductId = response.phases[1]!.customer_product_ids[0]!; - - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual([35]); - expect( - await getCustomerProductEntitlementBalances({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual( - expect.arrayContaining([ - { feature_id: TestFeature.Words, balance: 250 }, - { feature_id: TestFeature.Dashboard, balance: 0 }, - ]), - ); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - advanceTo: now + ms.days(16), - waitForSeconds: 30, - }); - - const activatedProduct = await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, futureCustomerProductId), - }); - - expect(activatedProduct?.status).toBe(CusProductStatus.Active); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual([35]); - expect( - await getCustomerProductEntitlementBalances({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual( - expect.arrayContaining([ - { feature_id: TestFeature.Words, balance: 250 }, - { feature_id: TestFeature.Dashboard, balance: 0 }, - ]), - ); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: updating a future customized phase replaces its custom prices and quantities before activation")}`, async () => { - const base = products.base({ - id: "create-schedule-custom-update", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice(), - ], - }); - - const { customerId, autumnV1, ctx, testClockId, advancedTo } = - await initScenario({ - customerId: "create-schedule-custom-update", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const now = advancedTo; - const initialResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(15), - plans: [ - { - plan_id: base.id, - customize: { - price: itemsV2.monthlyPrice({ amount: 35 }), - items: [ - itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }), - ], - }, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 200, - }, - ], - }, - ], - }, - ], - }); - - const initialFutureCustomerProductId = - initialResponse.phases[1]!.customer_product_ids[0]!; - - const updatedResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(15), - plans: [ - { - plan_id: base.id, - customize: { - price: itemsV2.monthlyPrice({ amount: 55 }), - items: [ - itemsV2.prepaidMessages({ amount: 20, billingUnits: 100 }), - ], - }, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 500, - }, - ], - }, - ], - }, - ], - }); - - const updatedFutureCustomerProductId = - updatedResponse.phases[1]!.customer_product_ids[0]!; - - expect(updatedFutureCustomerProductId).not.toBe( - initialFutureCustomerProductId, - ); - expect( - await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, initialFutureCustomerProductId), - }), - ).toBeUndefined(); - - const updatedFutureCustomerProduct = - await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, updatedFutureCustomerProductId), - }); - - expect(updatedFutureCustomerProduct?.status).toBe(CusProductStatus.Scheduled); - expect(updatedFutureCustomerProduct?.options).toEqual([ - expect.objectContaining({ - feature_id: TestFeature.Messages, - quantity: 5, - }), - ]); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: updatedFutureCustomerProductId, - }), - ).toEqual([55]); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - advanceTo: now + ms.days(16), - waitForSeconds: 30, - }); - - const activatedFutureCustomerProduct = - await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, updatedFutureCustomerProductId), - }); - - expect(activatedFutureCustomerProduct?.status).toBe(CusProductStatus.Active); - expect(activatedFutureCustomerProduct?.options).toEqual([ - expect.objectContaining({ - feature_id: TestFeature.Messages, - quantity: 5, - }), - ]); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: updatedFutureCustomerProductId, - }), - ).toEqual([55]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: updating a schedule with customized future phase persists both phases and custom items")}`, async () => { - const base = products.base({ - id: "base", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice(), - ], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-update-with-customize", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const now = Date.now(); - const initialResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - ], - }); - - expect(initialResponse.phases).toHaveLength(1); - - const updatedResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(30), - plans: [ - { - plan_id: base.id, - customize: { - price: itemsV2.monthlyPrice({ amount: 50 }), - items: [itemsV2.monthlyWords({ included: 200 })], - }, - }, - ], - }, - ], - }); - - expect(updatedResponse.phases).toHaveLength(2); - - const updatedDbPhases = await ctx.db - .select() - .from(schedulePhases) - .where( - eq( - schedulePhases.schedule_id, - getRequiredScheduleId(updatedResponse.schedule_id), - ), - ); - expect(updatedDbPhases).toHaveLength(2); - - const futureCustomerProductId = - updatedResponse.phases[1]!.customer_product_ids[0]!; - - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual([50]); - - expect( - await getCustomerProductEntitlementBalances({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual( - expect.arrayContaining([{ feature_id: TestFeature.Words, balance: 200 }]), - ); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: customize with boolean feature persists the boolean entitlement")}`, async () => { - const base = products.base({ - id: "bool-base", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice(), - ], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-customize-boolean", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [ - { - plan_id: base.id, - customize: { - items: [ - itemsV2.monthlyMessages({ included: 100 }), - itemsV2.dashboard(), - ], - }, - }, - ], - }, - ], - }); - - const customerProductId = response.phases[0]!.customer_product_ids[0]!; - - const entitlementBalances = await getCustomerProductEntitlementBalances({ - ctx, - customerProductId, - }); - - expect(entitlementBalances).toEqual( - expect.arrayContaining([ - { feature_id: TestFeature.Messages, balance: 100 }, - { feature_id: TestFeature.Dashboard, balance: 0 }, - ]), - ); - - const customerProduct = await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, customerProductId), - }); - expect(customerProduct?.is_custom).toBe(true); -}); diff --git a/server/tests/integration/billing/create-schedule/create-schedule-phases.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-phases.test.ts deleted file mode 100644 index a485e156e..000000000 --- a/server/tests/integration/billing/create-schedule/create-schedule-phases.test.ts +++ /dev/null @@ -1,1643 +0,0 @@ -import { expect, test } from "bun:test"; -import { - type ApiCustomerV3, - type CreateScheduleParamsV0Input, - CheckoutAction, - CusProductStatus, - customerProducts, - ms, - schedulePhases, - schedules, -} from "@autumn/shared"; -import { - confirmAutumnCheckout, - fetchAutumnCheckout, -} from "@tests/integration/billing/utils/checkout/autumnCheckoutUtils"; -import { isAutumnCheckoutUrl } from "@tests/integration/billing/utils/isAutumnCheckoutUrl"; -import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; -import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; -import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { advanceTestClock } from "@tests/utils/stripeUtils"; -import { timeout } from "@tests/utils/genUtils.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; -import { and, eq, inArray } from "drizzle-orm"; -import { CusService } from "@/internal/customers/CusService"; -import { - getFullCustomerSchedule, - hydrateCustomerWithSchedules, -} from "@/internal/customers/cusUtils/getFullCustomerSchedule"; -import { attachPaymentMethod } from "@/utils/scriptUtils/initCustomer"; -import { - getCheckoutId, - getCustomerProductRows, - getRequiredScheduleId, -} from "./utils/createScheduleTestHelpers"; - -test.concurrent(`${chalk.yellowBright("create-schedule: bills the first phase immediately and stores later phases as scheduled")}`, async () => { - const pro = products.pro({ - id: "pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const addon = products.recurringAddOn({ - id: "addon", - items: [items.monthlyWords({ includedUsage: 25 })], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-basic", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, addon] }), - ], - actions: [], - }); - - const now = Date.now(); - const params: CreateScheduleParamsV0Input = { - customer_id: customerId, - phases: [ - { - starts_at: now + ms.days(30), - plans: [{ plan_id: pro.id }], - }, - { - starts_at: now, - plans: [{ plan_id: pro.id }, { plan_id: addon.id }], - }, - ], - }; - - const response = await autumnV1.billing.createSchedule(params); - const scheduleId = getRequiredScheduleId(response.schedule_id); - - expect(response.customer_id).toBe(customerId); - expect(response.entity_id).toBeNull(); - expect(response.status).toBe("created"); - expect(response.payment_url).toBeNull(); - expect(response.invoice?.total).toBe(40); - expect(response.phases).toHaveLength(2); - expect(response.phases[0]!.starts_at).toBe(now); - expect(response.phases[0]!.customer_product_ids).toHaveLength(2); - expect(response.phases[1]!.starts_at).toBe(now + ms.days(30)); - expect(response.phases[1]!.customer_product_ids).toHaveLength(1); - - const dbSchedule = await ctx.db - .select() - .from(schedules) - .where(eq(schedules.id, scheduleId)); - expect(dbSchedule).toHaveLength(1); - - const dbPhases = await ctx.db - .select() - .from(schedulePhases) - .where(eq(schedulePhases.schedule_id, scheduleId)); - expect(dbPhases).toHaveLength(2); - - const immediatePhaseCustomerProducts = await ctx.db - .select() - .from(customerProducts) - .where( - inArray(customerProducts.id, response.phases[0]!.customer_product_ids), - ); - const phase1CustomerProducts = await ctx.db - .select() - .from(customerProducts) - .where( - inArray(customerProducts.id, response.phases[1]!.customer_product_ids), - ); - - expect(immediatePhaseCustomerProducts).toHaveLength(2); - expect( - immediatePhaseCustomerProducts.every( - (customerProduct) => customerProduct.status === CusProductStatus.Active, - ), - ).toBe(true); - expect(phase1CustomerProducts).toHaveLength(1); - expect( - phase1CustomerProducts.every( - (customerProduct) => - customerProduct.status === CusProductStatus.Scheduled, - ), - ).toBe(true); - expect( - immediatePhaseCustomerProducts.filter( - (customerProduct) => customerProduct.product_id === pro.id, - ), - ).toHaveLength(1); - expect( - immediatePhaseCustomerProducts.filter( - (customerProduct) => customerProduct.product_id === addon.id, - ), - ).toHaveLength(1); - expect(phase1CustomerProducts[0]!.product_id).toBe(pro.id); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: 40, - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: copies entity_id and replaces the prior schedule")}`, async () => { - const seats = products.base({ - id: "seats", - items: [items.prepaidUsers()], - }); - const backup = products.base({ - id: "backup", - items: [items.prepaidMessages()], - group: "backup", - }); - - const { customerId, autumnV1, ctx, entities } = await initScenario({ - customerId: "create-schedule-replace", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [seats, backup] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [], - }); - - const entityId = entities[0]!.id; - const now = Date.now(); - - const firstResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - entity_id: entityId, - phases: [ - { - starts_at: now, - plans: [ - { - plan_id: seats.id, - feature_quantities: [ - { - feature_id: TestFeature.Users, - quantity: 3, - }, - ], - }, - ], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: backup.id }], - }, - ], - }); - - const firstScheduledCustomerProductId = - firstResponse.phases[1]!.customer_product_ids[0]!; - - const secondResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - entity_id: entityId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: backup.id }], - }, - ], - }); - - const dbSchedules = await ctx.db - .select() - .from(schedules) - .where( - and( - eq(schedules.customer_id, customerId), - eq(schedules.entity_id, entityId), - ), - ); - expect(dbSchedules).toHaveLength(1); - expect(dbSchedules[0]!.id).toBe( - getRequiredScheduleId(secondResponse.schedule_id), - ); - - const removedScheduledProducts = await ctx.db - .select() - .from(customerProducts) - .where(eq(customerProducts.id, firstScheduledCustomerProductId)); - expect(removedScheduledProducts).toHaveLength(0); - - const removedSchedule = await ctx.db - .select() - .from(schedules) - .where(eq(schedules.id, getRequiredScheduleId(firstResponse.schedule_id))); - expect(removedSchedule).toHaveLength(0); - - const newCustomerProducts = await ctx.db - .select() - .from(customerProducts) - .where( - inArray( - customerProducts.id, - secondResponse.phases.flatMap( - (phase: { customer_product_ids: string[] }) => - phase.customer_product_ids, - ), - ), - ); - - expect(newCustomerProducts).toHaveLength(1); - expect(newCustomerProducts[0]!.entity_id).toBe(entityId); - expect(newCustomerProducts[0]!.status).toBe(CusProductStatus.Active); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: persists the new schedule and returns required_action when immediate billing is deferred")}`, async () => { - const pro = products.pro({ - id: "deferred-pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const premium = products.premium({ - id: "deferred-premium", - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-deferred", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [], - }); - - const now = Date.now(); - const initialResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: pro.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: premium.id }], - }, - ], - }); - - const persistedCustomer = await CusService.get({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - - const stripeCustomerId = persistedCustomer?.processor?.id; - if (!stripeCustomerId) { - throw new Error( - "Expected Stripe customer id before deferred create_schedule test", - ); - } - - await attachPaymentMethod({ - stripeCli: ctx.stripeCli, - stripeCusId: stripeCustomerId, - type: "authenticate", - }); - - const deferredResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [{ plan_id: premium.id }], - }, - ], - }); - - expect(deferredResponse.required_action).toBeDefined(); - expect(deferredResponse.required_action?.code).toBe("3ds_required"); - expect(deferredResponse.payment_url).toBeDefined(); - expect(deferredResponse.schedule_id).toBeNull(); - expect(deferredResponse.phases).toEqual([]); - expect(deferredResponse.status).toBe("pending_payment"); - - const schedulesAfterDeferredAttempt = await ctx.db - .select({ - id: schedules.id, - }) - .from(schedules) - .where(eq(schedules.customer_id, customerId)); - - expect(schedulesAfterDeferredAttempt).toHaveLength(1); - expect(schedulesAfterDeferredAttempt[0]!.id).toBe( - getRequiredScheduleId(initialResponse.schedule_id), - ); - - const phasesAfterDeferredAttempt = await ctx.db - .select({ - id: schedulePhases.id, - customer_product_ids: schedulePhases.customer_product_ids, - }) - .from(schedulePhases) - .where( - eq( - schedulePhases.schedule_id, - getRequiredScheduleId(initialResponse.schedule_id), - ), - ); - - expect(phasesAfterDeferredAttempt).toHaveLength(2); - expect(phasesAfterDeferredAttempt[0]!.customer_product_ids).toEqual( - initialResponse.phases[0]!.customer_product_ids, - ); - expect(phasesAfterDeferredAttempt[1]!.customer_product_ids).toEqual( - initialResponse.phases[1]!.customer_product_ids, - ); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: allows multiple group replacements when they only conflict with current plans")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const usersItem = items.monthlyUsers({ includedUsage: 5 }); - - const existingA = products.base({ - id: "create-schedule-existing-a", - items: [messagesItem, items.monthlyPrice({ price: 5 })], - }); - const existingB = products.base({ - id: "create-schedule-existing-b", - items: [usersItem, items.monthlyPrice({ price: 5 })], - group: "group-b", - }); - const replacementA = products.pro({ - id: "create-schedule-replacement-a", - items: [messagesItem], - }); - const replacementB = products.base({ - id: "create-schedule-replacement-b", - items: [usersItem, items.monthlyPrice({ price: 20 })], - group: "group-b", - }); - - const { customerId, autumnV1 } = await initScenario({ - customerId: "create-schedule-multi-replace", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ - list: [existingA, existingB, replacementA, replacementB], - }), - ], - actions: [ - s.billing.attach({ productId: existingA.id }), - s.billing.attach({ productId: existingB.id }), - ], - }); - - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [{ plan_id: replacementA.id }, { plan_id: replacementB.id }], - }, - ], - }); - - expect(response.customer_id).toBe(customerId); - expect(response.phases).toHaveLength(1); - expect(response.phases[0]!.customer_product_ids).toHaveLength(2); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: later-phase-only plans stay scheduled and never hit immediate billing")}`, async () => { - const nowBase = products.pro({ - id: "create-schedule-now-base", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const nowAddon = products.recurringAddOn({ - id: "create-schedule-now-addon", - items: [items.monthlyWords({ includedUsage: 50 })], - }); - const futureGroupB = products.base({ - id: "create-schedule-future-group-b", - items: [items.monthlyUsers({ includedUsage: 5 }), items.monthlyPrice()], - group: "group-b", - }); - const futureGroupC = products.base({ - id: "create-schedule-future-group-c", - items: [ - items.monthlyMessages({ includedUsage: 250 }), - items.monthlyPrice(), - ], - group: "group-c", - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-future-only-not-now", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ - list: [nowBase, nowAddon, futureGroupB, futureGroupC], - }), - ], - actions: [], - }); - - const now = Date.now(); - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: nowBase.id }, { plan_id: nowAddon.id }], - }, - { - starts_at: now + ms.days(15), - plans: [{ plan_id: futureGroupB.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: futureGroupC.id }], - }, - ], - }); - - const productRows = await getCustomerProductRows({ - ctx, - customerId, - productIds: [nowBase.id, nowAddon.id, futureGroupB.id, futureGroupC.id], - }); - const activeRows = productRows - .filter((productRow) => productRow.status === CusProductStatus.Active) - .sort((a, b) => a.productId!.localeCompare(b.productId!)); - const scheduledRows = productRows - .filter((productRow) => productRow.status === CusProductStatus.Scheduled) - .sort((a, b) => a.productId!.localeCompare(b.productId!)); - - expect(activeRows).toEqual( - [ - { productId: nowBase.id, status: CusProductStatus.Active }, - { productId: nowAddon.id, status: CusProductStatus.Active }, - ].sort((a, b) => a.productId.localeCompare(b.productId)), - ); - expect(scheduledRows).toEqual( - [ - { productId: futureGroupB.id, status: CusProductStatus.Scheduled }, - { productId: futureGroupC.id, status: CusProductStatus.Scheduled }, - ].sort((a, b) => a.productId.localeCompare(b.productId)), - ); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestInvoiceProductIds: [nowBase.id, nowAddon.id], - }); - expect(customer.invoices?.[0]?.product_ids).not.toContain(futureGroupB.id); - expect(customer.invoices?.[0]?.product_ids).not.toContain(futureGroupC.id); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: future replacements for an active group stay scheduled until their phase")}`, async () => { - const currentGroupB = products.base({ - id: "create-schedule-current-group-b", - items: [ - items.monthlyUsers({ includedUsage: 5 }), - items.monthlyPrice({ price: 5 }), - ], - group: "group-b", - }); - const nowBase = products.pro({ - id: "create-schedule-active-now-base", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const futureReplacementB = products.base({ - id: "create-schedule-future-replacement-b", - items: [ - items.monthlyMessages({ includedUsage: 200 }), - items.monthlyPrice({ price: 15 }), - ], - group: "group-b", - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-future-replacement-stays-scheduled", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ - list: [currentGroupB, nowBase, futureReplacementB], - }), - ], - actions: [s.billing.attach({ productId: currentGroupB.id })], - }); - - const now = Date.now(); - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: nowBase.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: futureReplacementB.id }], - }, - ], - }); - - const productRows = await getCustomerProductRows({ - ctx, - customerId, - productIds: [nowBase.id, futureReplacementB.id], - }); - - expect( - productRows.filter( - (productRow) => productRow.productId === futureReplacementB.id, - ), - ).toEqual([ - { - productId: futureReplacementB.id, - status: CusProductStatus.Scheduled, - }, - ]); - expect( - productRows.filter( - (productRow) => - productRow.productId === futureReplacementB.id && - productRow.status === CusProductStatus.Active, - ), - ).toHaveLength(0); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestInvoiceProductIds: [nowBase.id], - }); - expect(customer.invoices?.[0]?.product_ids).not.toContain( - futureReplacementB.id, - ); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: now phase stays the exact active set across groups and future phases")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const usersItem = items.monthlyUsers({ includedUsage: 5 }); - const wordsItem = items.monthlyWords({ includedUsage: 25 }); - - const currentA = products.base({ - id: "create-schedule-exact-current-a", - items: [messagesItem, items.monthlyPrice({ price: 5 })], - }); - const keepNowB = products.base({ - id: "create-schedule-exact-keep-b", - items: [usersItem, items.monthlyPrice({ price: 5 })], - group: "group-b", - }); - const currentAddon = products.recurringAddOn({ - id: "create-schedule-exact-current-addon", - items: [wordsItem], - }); - const nowReplacementA = products.pro({ - id: "create-schedule-exact-now-a", - items: [messagesItem], - }); - const futureReplacementB = products.base({ - id: "create-schedule-exact-future-b", - items: [usersItem, items.monthlyPrice({ price: 15 })], - group: "group-b", - }); - const futureAddon = products.recurringAddOn({ - id: "create-schedule-exact-future-addon", - items: [wordsItem], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-exact-now-set", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ - list: [ - currentA, - keepNowB, - currentAddon, - nowReplacementA, - futureReplacementB, - futureAddon, - ], - }), - ], - actions: [ - s.billing.attach({ productId: currentA.id }), - s.billing.attach({ productId: keepNowB.id }), - s.billing.attach({ productId: currentAddon.id }), - ], - }); - - const now = Date.now(); - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: nowReplacementA.id }, { plan_id: keepNowB.id }], - }, - { - starts_at: now + ms.days(15), - plans: [ - { plan_id: futureReplacementB.id }, - { plan_id: futureAddon.id }, - ], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: currentA.id }], - }, - ], - }); - - const productRows = await getCustomerProductRows({ - ctx, - customerId, - productIds: [ - currentA.id, - keepNowB.id, - currentAddon.id, - nowReplacementA.id, - futureReplacementB.id, - futureAddon.id, - ], - }); - const activeRows = productRows - .filter((productRow) => productRow.status === CusProductStatus.Active) - .sort((a, b) => a.productId!.localeCompare(b.productId!)); - const scheduledRows = productRows - .filter((productRow) => productRow.status === CusProductStatus.Scheduled) - .sort((a, b) => a.productId!.localeCompare(b.productId!)); - - expect(activeRows).toEqual( - [ - { productId: keepNowB.id, status: CusProductStatus.Active }, - { productId: nowReplacementA.id, status: CusProductStatus.Active }, - ].sort((a, b) => a.productId.localeCompare(b.productId)), - ); - expect(scheduledRows).toEqual( - [ - { productId: currentA.id, status: CusProductStatus.Scheduled }, - { productId: futureAddon.id, status: CusProductStatus.Scheduled }, - { - productId: futureReplacementB.id, - status: CusProductStatus.Scheduled, - }, - ].sort((a, b) => a.productId.localeCompare(b.productId)), - ); - - const customer = await autumnV1.customers.get(customerId); - expect(customer.invoices?.[0]?.product_ids).not.toContain( - futureReplacementB.id, - ); - expect(customer.invoices?.[0]?.product_ids).not.toContain(futureAddon.id); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: plans omitted from the next phase end at the phase boundary")}`, async () => { - const nowBase = products.pro({ - id: "create-schedule-phase-end-now-base", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const nowAddon = products.recurringAddOn({ - id: "create-schedule-phase-end-now-addon", - items: [items.monthlyWords({ includedUsage: 25 })], - }); - const nextBase = products.premium({ - id: "create-schedule-phase-end-next-base", - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - const nextAddon = products.recurringAddOn({ - id: "create-schedule-phase-end-next-addon", - items: [items.monthlyWords({ includedUsage: 75 })], - }); - - const { customerId, autumnV1, ctx, testClockId, advancedTo } = - await initScenario({ - customerId: "create-schedule-phase-end-boundary", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [nowBase, nowAddon, nextBase, nextAddon] }), - ], - actions: [], - }); - - const now = advancedTo; - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: nowBase.id }, { plan_id: nowAddon.id }], - }, - { - starts_at: now + ms.days(15), - plans: [{ plan_id: nextBase.id }, { plan_id: nextAddon.id }], - }, - ], - }); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - advanceTo: now + ms.days(16), - waitForSeconds: 30, - }); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerProducts({ - customer, - active: [nextAddon.id, nextBase.id], - notPresent: [nowAddon.id, nowBase.id], - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: rejects updating a schedule after earlier phases started when past phases are resubmitted")}`, async () => { - const originalPastBase = products.base({ - id: "create-schedule-update-history-past-base", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const currentBase = products.base({ - id: "create-schedule-update-history-current-base", - items: [items.monthlyMessages({ includedUsage: 300 })], - group: "current-base", - }); - const currentAddon = products.recurringAddOn({ - id: "create-schedule-update-history-current-addon", - items: [items.monthlyWords({ includedUsage: 25 })], - }); - const futureBase = products.base({ - id: "create-schedule-update-history-future-base", - items: [items.monthlyMessages({ includedUsage: 500 })], - group: "current-base", - }); - - const { customerId, autumnV1, ctx, testClockId, advancedTo } = - await initScenario({ - customerId: "create-schedule-update-history", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ - list: [originalPastBase, currentBase, currentAddon, futureBase], - }), - ], - actions: [], - }); - - const now = advancedTo; - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: originalPastBase.id }], - }, - { - starts_at: now + ms.days(15), - plans: [{ plan_id: currentBase.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: futureBase.id }], - }, - ], - }); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - advanceTo: now + ms.days(16), - waitForSeconds: 30, - }); - - await expectAutumnError({ - func: async () => - autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: originalPastBase.id }], - }, - { - starts_at: now + ms.days(15), - plans: [{ plan_id: currentBase.id }, { plan_id: currentAddon.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: futureBase.id }], - }, - ], - }), - errMessage: "The first phase must start immediately", - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: replacing a schedule removes old phases and leaves the correct replacement state in db")}`, async () => { - const currentA = products.base({ - id: "create-schedule-replace-state-current-a", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const currentB = products.base({ - id: "create-schedule-replace-state-current-b", - items: [items.monthlyUsers({ includedUsage: 5 })], - group: "group-b", - }); - const currentAddon = products.recurringAddOn({ - id: "create-schedule-replace-state-current-addon", - items: [items.monthlyWords({ includedUsage: 25 })], - }); - const firstFutureA = products.pro({ - id: "create-schedule-replace-state-first-future-a", - items: [items.monthlyMessages({ includedUsage: 300 })], - }); - const firstFutureAddon = products.recurringAddOn({ - id: "create-schedule-replace-state-first-future-addon", - items: [items.monthlyWords({ includedUsage: 75 })], - }); - const secondNowA = products.premium({ - id: "create-schedule-replace-state-second-now-a", - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - const secondFutureA = products.pro({ - id: "create-schedule-replace-state-second-future-a", - items: [items.monthlyMessages({ includedUsage: 300 })], - }); - const secondFutureB = products.pro({ - id: "create-schedule-replace-state-second-future-b", - items: [items.monthlyUsers({ includedUsage: 10 })], - group: "group-b", - }); - - const { customerId, autumnV1, ctx, advancedTo } = await initScenario({ - customerId: "create-schedule-replace-state", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ - list: [ - currentA, - currentB, - currentAddon, - firstFutureA, - firstFutureAddon, - secondNowA, - secondFutureA, - secondFutureB, - ], - }), - ], - actions: [ - s.billing.attach({ productId: currentA.id }), - s.billing.attach({ productId: currentB.id }), - s.billing.attach({ productId: currentAddon.id }), - ], - }); - - const now = advancedTo; - const firstResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [ - { plan_id: currentA.id }, - { plan_id: currentB.id }, - { plan_id: currentAddon.id }, - ], - }, - { - starts_at: now + ms.days(15), - plans: [{ plan_id: firstFutureA.id }, { plan_id: firstFutureAddon.id }], - }, - ], - }); - - const replacementNow = Date.now(); - const secondResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: replacementNow, - plans: [{ plan_id: secondNowA.id }, { plan_id: currentAddon.id }], - }, - { - starts_at: replacementNow + ms.days(15), - plans: [{ plan_id: secondFutureA.id }, { plan_id: secondFutureB.id }], - }, - ], - }); - - const dbSchedules = await ctx.db - .select() - .from(schedules) - .where(eq(schedules.customer_id, customerId)); - expect(dbSchedules).toHaveLength(1); - expect(dbSchedules[0]!.id).toBe( - getRequiredScheduleId(secondResponse.schedule_id), - ); - - const firstSchedule = await ctx.db - .select() - .from(schedules) - .where(eq(schedules.id, getRequiredScheduleId(firstResponse.schedule_id))); - expect(firstSchedule).toHaveLength(0); - - const secondSchedulePhases = await ctx.db - .select() - .from(schedulePhases) - .where( - eq( - schedulePhases.schedule_id, - getRequiredScheduleId(secondResponse.schedule_id), - ), - ); - expect(secondSchedulePhases).toHaveLength(2); - - const firstSchedulePhases = await ctx.db - .select() - .from(schedulePhases) - .where(eq(schedulePhases.schedule_id, getRequiredScheduleId(firstResponse.schedule_id))); - expect(firstSchedulePhases).toHaveLength(0); - - const productRowsAfterReplace = await getCustomerProductRows({ - ctx, - customerId, - productIds: [ - currentA.id, - currentB.id, - currentAddon.id, - firstFutureA.id, - firstFutureAddon.id, - secondNowA.id, - secondFutureA.id, - secondFutureB.id, - ], - }); - - expect( - productRowsAfterReplace - .filter((productRow) => productRow.status === CusProductStatus.Active) - .sort((a, b) => a.productId!.localeCompare(b.productId!)), - ).toEqual( - [ - { productId: currentAddon.id, status: CusProductStatus.Active }, - { productId: secondNowA.id, status: CusProductStatus.Active }, - ].sort((a, b) => a.productId.localeCompare(b.productId)), - ); - expect( - productRowsAfterReplace - .filter((productRow) => productRow.status === CusProductStatus.Scheduled) - .sort((a, b) => a.productId!.localeCompare(b.productId!)), - ).toEqual( - [ - { productId: secondFutureA.id, status: CusProductStatus.Scheduled }, - { productId: secondFutureB.id, status: CusProductStatus.Scheduled }, - ].sort((a, b) => a.productId.localeCompare(b.productId)), - ); - expect( - productRowsAfterReplace.filter( - (productRow) => - productRow.productId === firstFutureA.id || - productRow.productId === firstFutureAddon.id, - ), - ).toHaveLength(0); - - const customerAfterReplace = - await autumnV1.customers.get(customerId); - expect( - customerAfterReplace.products - ?.map((product) => ({ id: product.id, status: product.status })) - .sort((a, b) => a.id.localeCompare(b.id)), - ).toEqual( - [ - { id: currentAddon.id, status: "active" as const }, - { id: secondFutureA.id, status: "scheduled" as const }, - { id: secondFutureB.id, status: "scheduled" as const }, - { id: secondNowA.id, status: "active" as const }, - ].sort((a, b) => a.id.localeCompare(b.id)), - ); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: rejects invalid timing and entity input")}`, async () => { - const pro = products.pro({ - id: "pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const { customerId, autumnV1 } = await initScenario({ - customerId: "create-schedule-errors", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [], - }); - - await expectAutumnError({ - errMessage: "The first phase must start immediately", - func: async () => { - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now() - ms.days(1), - plans: [{ plan_id: pro.id }], - }, - ], - }); - }, - }); - - await expectAutumnError({ - errMessage: "The first phase must start immediately", - func: async () => { - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now() + ms.days(1), - plans: [{ plan_id: pro.id }], - }, - ], - }); - }, - }); - - await expectAutumnError({ - errMessage: "Phase starts_at values must be strictly increasing", - func: async () => { - const duplicateStartsAt = Date.now(); - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: duplicateStartsAt, - plans: [{ plan_id: pro.id }], - }, - { - starts_at: duplicateStartsAt, - plans: [{ plan_id: pro.id }], - }, - ], - }); - }, - }); - - await expectAutumnError({ - errMessage: "not found", - func: async () => { - await autumnV1.billing.createSchedule({ - customer_id: customerId, - entity_id: "missing-entity", - phases: [ - { - starts_at: Date.now(), - plans: [{ plan_id: pro.id }], - }, - ], - }); - }, - }); - - await expectAutumnError({ - errMessage: 'Unrecognized key: "free_trial"', - func: async () => { - await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [ - { - plan_id: pro.id, - customize: { - free_trial: { - duration_length: 7, - duration_type: "day", - card_required: false, - }, - }, - }, - ], - }, - ], - }); - }, - }); - -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: no payment method returns Stripe checkout and activates after completion")}`, async () => { - const pro = products.base({ - id: "create-schedule-checkout-pro", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice({ price: 20 }), - ], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-no-pm-checkout", - setup: [s.customer({}), s.products({ list: [pro] })], - actions: [], - }); - - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [{ plan_id: pro.id }], - }, - ], - }); - - expect(response.status).toBe("pending_payment"); - expect(response.payment_url).toBeDefined(); - expect(isAutumnCheckoutUrl(response.payment_url!)).toBe(false); - expect(response.schedule_id).toBeNull(); - expect(response.phases).toEqual([]); - - await completeStripeCheckoutForm({ url: response.payment_url! }); - await timeout(4000); - - const customer = await autumnV1.customers.get(customerId); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestStatus: "paid", - latestTotal: 20, - }); - - const dbSchedules = await ctx.db - .select({ id: schedules.id }) - .from(schedules) - .where(eq(schedules.customer_id, customerId)); - - expect(dbSchedules).toHaveLength(1); - - const phaseRows = await ctx.db - .select({ customer_product_ids: schedulePhases.customer_product_ids }) - .from(schedulePhases) - .where(eq(schedulePhases.schedule_id, dbSchedules[0]!.id)); - - expect(phaseRows).toHaveLength(1); - - const persistedProducts = await ctx.db - .select({ - productId: customerProducts.product_id, - status: customerProducts.status, - }) - .from(customerProducts) - .where(inArray(customerProducts.id, phaseRows[0]!.customer_product_ids)); - - expect(persistedProducts).toEqual([ - { - productId: pro.id, - status: CusProductStatus.Active, - }, - ]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: redirect_mode always returns Autumn checkout and confirms into a persisted schedule")}`, async () => { - const starter = products.base({ - id: "create-schedule-autumn-starter", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice({ price: 20 }), - ], - }); - const premium = products.base({ - id: "create-schedule-autumn-premium", - items: [ - items.monthlyMessages({ includedUsage: 500 }), - items.monthlyPrice({ price: 50 }), - ], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-autumn-checkout", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [starter, premium] }), - ], - actions: [s.billing.attach({ productId: starter.id })], - }); - - const now = Date.now(); - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - redirect_mode: "always", - phases: [ - { - starts_at: now, - plans: [{ plan_id: premium.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: starter.id }], - }, - ], - }); - - expect(response.status).toBe("pending_payment"); - expect(response.schedule_id).toBeNull(); - expect(response.phases).toEqual([]); - expect(isAutumnCheckoutUrl(response.payment_url!)).toBe(true); - - const checkoutId = getCheckoutId(response.payment_url); - const checkout = await fetchAutumnCheckout({ checkoutId }); - - expect(checkout.action).toBe(CheckoutAction.CreateSchedule); - expect(checkout.preview.total).toBe(30); - - await confirmAutumnCheckout({ - checkoutId, - customerId, - productId: premium.id, - }); - - const customer = await autumnV1.customers.get(customerId); - expect(customer.features?.[TestFeature.Messages]?.balance).toBe(500); - - const dbSchedules = await ctx.db - .select({ id: schedules.id }) - .from(schedules) - .where(eq(schedules.customer_id, customerId)); - - expect(dbSchedules).toHaveLength(1); - - const persistedPhases = await ctx.db - .select({ - starts_at: schedulePhases.starts_at, - customer_product_ids: schedulePhases.customer_product_ids, - }) - .from(schedulePhases) - .where(eq(schedulePhases.schedule_id, dbSchedules[0]!.id)); - - expect(persistedPhases).toHaveLength(2); - - const immediateProducts = await ctx.db - .select({ - productId: customerProducts.product_id, - status: customerProducts.status, - }) - .from(customerProducts) - .where( - inArray( - customerProducts.id, - persistedPhases[0]!.customer_product_ids, - ), - ); - const futureProducts = await ctx.db - .select({ - productId: customerProducts.product_id, - status: customerProducts.status, - }) - .from(customerProducts) - .where( - inArray( - customerProducts.id, - persistedPhases[1]!.customer_product_ids, - ), - ); - - expect(immediateProducts).toEqual([ - { - productId: premium.id, - status: CusProductStatus.Active, - }, - ]); - expect(futureProducts).toEqual([ - { - productId: starter.id, - status: CusProductStatus.Scheduled, - }, - ]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: invoice mode can collect payment without an attached payment method")}`, async () => { - const pro = products.base({ - id: "create-schedule-invoice-pro", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice({ price: 20 }), - ], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-invoice-no-pm", - setup: [s.customer({}), s.products({ list: [pro] })], - actions: [], - }); - - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - invoice_mode: { - enabled: true, - finalize: true, - enable_plan_immediately: false, - }, - phases: [ - { - starts_at: Date.now(), - plans: [{ plan_id: pro.id }], - }, - ], - }); - - expect(response.status).toBe("pending_payment"); - expect(response.invoice?.status).toBe("open"); - expect(response.payment_url).toBeDefined(); - expect(response.schedule_id).toBeNull(); - expect(response.phases).toEqual([]); - - const customerBefore = await autumnV1.customers.get(customerId); - expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); - - await expectCustomerInvoiceCorrect({ - customer: customerBefore, - count: 1, - latestStatus: "open", - latestTotal: 20, - }); - - await completeInvoiceCheckout({ url: response.payment_url! }); - await timeout(4000); - - const customerAfter = await autumnV1.customers.get(customerId); - - await expectCustomerInvoiceCorrect({ - customer: customerAfter, - count: 1, - latestStatus: "paid", - latestTotal: 20, - }); - - const dbSchedules = await ctx.db - .select({ id: schedules.id }) - .from(schedules) - .where(eq(schedules.customer_id, customerId)); - - expect(dbSchedules).toHaveLength(1); - - const phaseRows = await ctx.db - .select({ customer_product_ids: schedulePhases.customer_product_ids }) - .from(schedulePhases) - .where(eq(schedulePhases.schedule_id, dbSchedules[0]!.id)); - - expect(phaseRows).toHaveLength(1); - - const persistedProducts = await ctx.db - .select({ - productId: customerProducts.product_id, - status: customerProducts.status, - }) - .from(customerProducts) - .where(inArray(customerProducts.id, phaseRows[0]!.customer_product_ids)); - - expect(persistedProducts).toEqual([ - { - productId: pro.id, - status: CusProductStatus.Active, - }, - ]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: hydrates schedules on the full customer")}`, async () => { - const pro = products.pro({ - id: "pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const premium = products.premium({ - id: "premium", - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-hydrate-customer", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [], - }); - - const now = Date.now(); - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: pro.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: premium.id }], - }, - ], - }); - - const fullCustomer = await CusService.getFull({ - ctx, - idOrInternalId: customerId, - withEntities: true, - expand: [], - }); - const hydratedCustomer = await hydrateCustomerWithSchedules({ - ctx, - fullCustomer, - }); - - expect(hydratedCustomer.schedule?.id).toBe( - getRequiredScheduleId(response.schedule_id), - ); - expect(hydratedCustomer.schedule?.customer_id).toBe(customerId); - expect(hydratedCustomer.schedule?.phases).toHaveLength(2); - expect(hydratedCustomer.schedule?.phases[0]?.starts_at).toBe(now); - expect(hydratedCustomer.schedule?.phases[1]?.starts_at).toBe( - now + ms.days(30), - ); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: adding a future phase to an existing single-phase schedule persists both phases")}`, async () => { - const pro = products.pro({ - id: "pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const premium = products.premium({ - id: "premium", - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-add-future-phase", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [], - }); - - const now = Date.now(); - const initialResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: pro.id }], - }, - ], - }); - - expect(initialResponse.phases).toHaveLength(1); - expect(initialResponse.phases[0]!.customer_product_ids).toHaveLength(1); - - const initialDbPhases = await ctx.db - .select() - .from(schedulePhases) - .where( - eq( - schedulePhases.schedule_id, - getRequiredScheduleId(initialResponse.schedule_id), - ), - ); - expect(initialDbPhases).toHaveLength(1); - - const updatedResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: pro.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: premium.id }], - }, - ], - }); - - expect(updatedResponse.phases).toHaveLength(2); - expect(updatedResponse.phases[0]!.starts_at).toBe(now); - expect(updatedResponse.phases[0]!.customer_product_ids).toHaveLength(1); - expect(updatedResponse.phases[1]!.starts_at).toBe(now + ms.days(30)); - expect(updatedResponse.phases[1]!.customer_product_ids).toHaveLength(1); - - const updatedDbPhases = await ctx.db - .select() - .from(schedulePhases) - .where( - eq( - schedulePhases.schedule_id, - getRequiredScheduleId(updatedResponse.schedule_id), - ), - ); - expect(updatedDbPhases).toHaveLength(2); - - const immediateProducts = await ctx.db - .select() - .from(customerProducts) - .where( - inArray( - customerProducts.id, - updatedResponse.phases[0]!.customer_product_ids, - ), - ); - expect(immediateProducts).toHaveLength(1); - expect(immediateProducts[0]!.status).toBe(CusProductStatus.Active); - - const futureProducts = await ctx.db - .select() - .from(customerProducts) - .where( - inArray( - customerProducts.id, - updatedResponse.phases[1]!.customer_product_ids, - ), - ); - expect(futureProducts).toHaveLength(1); - expect(futureProducts[0]!.status).toBe(CusProductStatus.Scheduled); - expect(futureProducts[0]!.product_id).toBe(premium.id); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: customer-level and entity-level schedules coexist independently")}`, async () => { - const pro = products.pro({ - id: "pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const addon = products.recurringAddOn({ - id: "addon", - items: [items.monthlyWords({ includedUsage: 25 })], - }); - - const { customerId, autumnV1, ctx, entities } = await initScenario({ - customerId: "create-schedule-entity-coexist", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, addon] }), - s.entities({ count: 1, featureId: TestFeature.Users }), - ], - actions: [], - }); - - const entityId = entities[0]!.id; - const now = Date.now(); - - const customerSchedule = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: pro.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: pro.id }], - }, - ], - }); - - const entitySchedule = await autumnV1.billing.createSchedule({ - customer_id: customerId, - entity_id: entityId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: addon.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: addon.id }], - }, - ], - }); - - expect(customerSchedule.schedule_id).not.toBe(entitySchedule.schedule_id); - - const dbSchedules = await ctx.db - .select() - .from(schedules) - .where(eq(schedules.customer_id, customerId)); - expect(dbSchedules).toHaveLength(2); - - const customerLevelSchedule = dbSchedules.find((s) => !s.internal_entity_id); - const entityLevelSchedule = dbSchedules.find((s) => !!s.internal_entity_id); - expect(customerLevelSchedule).toBeDefined(); - expect(entityLevelSchedule).toBeDefined(); - expect(entityLevelSchedule!.entity_id).toBe(entityId); - - const customerScopedSchedule = await getFullCustomerSchedule({ - ctx, - internalCustomerId: dbSchedules[0]!.internal_customer_id, - }); - - expect(customerScopedSchedule?.id).toBe(customerLevelSchedule!.id); - expect(customerScopedSchedule?.internal_entity_id).toBeNull(); -}); diff --git a/server/tests/integration/billing/create-schedule/create-schedule-preview.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-preview.test.ts deleted file mode 100644 index e5df9c3f7..000000000 --- a/server/tests/integration/billing/create-schedule/create-schedule-preview.test.ts +++ /dev/null @@ -1,658 +0,0 @@ -import { expect, test } from "bun:test"; -import { - type AttachPreviewResponse, - BillingInterval, - BillingMethod, - type CreateScheduleParamsV0Input, - ms, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; -import { addMonths } from "date-fns"; - -const previewCreateSchedule = async ({ - autumnV1, - params, -}: { - autumnV1: Awaited>["autumnV1"]; - params: CreateScheduleParamsV0Input; -}): Promise => - await autumnV1.post("/billing.preview_create_schedule", params); - -const sortNumbers = (values: number[]) => [...values].sort((a, b) => a - b); -const sortStrings = (values: string[]) => [...values].sort((a, b) => a.localeCompare(b)); - -const expectPreviewToMatchCreateSchedule = async ({ - autumnV1, - params, - expectedTotal, - expectedLineItemTotals, - assertPreview, -}: { - autumnV1: Awaited>["autumnV1"]; - params: CreateScheduleParamsV0Input; - expectedTotal?: number; - expectedLineItemTotals?: number[]; - assertPreview?: (preview: AttachPreviewResponse) => void; -}) => { - const preview = await previewCreateSchedule({ autumnV1, params }); - - if (expectedTotal !== undefined) { - expect(preview.total).toBe(expectedTotal); - expect(preview.subtotal).toBe(expectedTotal); - } - if (expectedLineItemTotals) { - expect( - sortNumbers(preview.line_items.map((lineItem) => lineItem.total)), - ).toEqual(sortNumbers(expectedLineItemTotals)); - } - expect( - preview.line_items.reduce((sum, lineItem) => sum + lineItem.total, 0), - ).toBe(preview.total); - - assertPreview?.(preview); - - const response = await autumnV1.billing.createSchedule(params); - - expect(response.status).toBe("created"); - expect(response.invoice?.total ?? 0).toBe(preview.total); -}; - -test.concurrent(`${chalk.yellowBright("create-schedule preview 1: immediate recurring plans match preview total")}`, async () => { - const pro = products.pro({ - id: "preview-pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const addon = products.recurringAddOn({ - id: "preview-addon", - items: [items.monthlyWords({ includedUsage: 25 })], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-recurring", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, addon] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [{ plan_id: pro.id }, { plan_id: addon.id }], - }, - { - starts_at: advancedTo + ms.days(30), - plans: [{ plan_id: pro.id }], - }, - ], - }, - expectedTotal: 40, - expectedLineItemTotals: [20, 20], - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 2: prepaid feature quantities bill immediately")}`, async () => { - const prepaid = products.base({ - id: "preview-prepaid", - items: [items.prepaidMessages()], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-prepaid", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [prepaid] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [ - { - plan_id: prepaid.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 400, - }, - ], - }, - ], - }, - ], - }, - expectedTotal: 40, - expectedLineItemTotals: [40], - assertPreview: (preview) => { - expect(preview.line_items).toContainEqual( - expect.objectContaining({ - feature_id: TestFeature.Messages, - quantity: 400, - total: 40, - }), - ); - }, - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 3: customize.price overrides the template base price")}`, async () => { - const base = products.base({ - id: "preview-custom-price", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-custom-price", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [ - { - plan_id: base.id, - customize: { - price: itemsV2.monthlyPrice({ amount: 35 }), - }, - }, - ], - }, - ], - }, - expectedTotal: 35, - expectedLineItemTotals: [35], - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 4: graduated prepaid tiers use the correct total")}`, async () => { - const tiered = products.base({ - id: "preview-tiered-prepaid", - items: [items.tieredPrepaidMessages({ includedUsage: 0 })], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-tiered-prepaid", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [tiered] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [ - { - plan_id: tiered.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 700, - }, - ], - }, - ], - }, - ], - }, - expectedTotal: 60, - expectedLineItemTotals: [60], - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 5: volume prepaid tiers use the correct total")}`, async () => { - const volume = products.base({ - id: "preview-volume-prepaid", - items: [items.volumePrepaidMessages({ includedUsage: 0 })], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-volume-prepaid", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [volume] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [ - { - plan_id: volume.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 700, - }, - ], - }, - ], - }, - ], - }, - expectedTotal: 35, - expectedLineItemTotals: [35], - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 6: usage-based features stay out of the immediate total")}`, async () => { - const usagePlan = products.pro({ - id: "preview-usage-plan", - items: [items.consumableMessages({ includedUsage: 100, price: 0.5 })], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-usage-based", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [usagePlan] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [{ plan_id: usagePlan.id }], - }, - ], - }, - expectedTotal: 20, - expectedLineItemTotals: [20], - assertPreview: (preview) => { - expect( - preview.line_items.every((lineItem) => lineItem.feature_id === null), - ).toBe(true); - expect(preview.next_cycle).toBeUndefined(); - }, - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 7: active upgrade preview matches the immediate replacement invoice")}`, async () => { - const pro = products.pro({ - id: "preview-active-upgrade-pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const premium = products.premium({ - id: "preview-active-upgrade-premium", - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-active-upgrade", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [s.billing.attach({ productId: pro.id })], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [{ plan_id: premium.id }], - }, - ], - }, - assertPreview: (preview) => { - expect(preview.total).toBeGreaterThan(0); - expect(preview.total).toBeLessThan(50); - expect(preview.line_items.length).toBeGreaterThan(0); - }, - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 8: active downgrade preview matches the immediate replacement invoice")}`, async () => { - const pro = products.pro({ - id: "preview-active-downgrade-pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const premium = products.premium({ - id: "preview-active-downgrade-premium", - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-active-downgrade", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [s.billing.attach({ productId: premium.id })], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [{ plan_id: pro.id }], - }, - ], - }, - assertPreview: (preview) => { - expect(preview.total).toBeLessThan(20); - expect(preview.line_items.length).toBeGreaterThan(0); - }, - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 9: mixed immediate phase only charges recurring and prepaid items")}`, async () => { - const recurring = products.pro({ - id: "preview-mixed-recurring", - items: [items.monthlyMessages({ includedUsage: 100 })], - group: "preview-mixed-recurring", - }); - const prepaid = products.base({ - id: "preview-mixed-prepaid", - items: [items.prepaidUsers()], - group: "preview-mixed-prepaid", - }); - const usageBased = products.base({ - id: "preview-mixed-usage", - items: [items.consumableWords({ includedUsage: 100 })], - group: "preview-mixed-usage", - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-mixed", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [recurring, prepaid, usageBased] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [ - { plan_id: recurring.id }, - { - plan_id: prepaid.id, - feature_quantities: [ - { - feature_id: TestFeature.Users, - quantity: 4, - }, - ], - }, - { plan_id: usageBased.id }, - ], - }, - ], - }, - expectedTotal: 60, - expectedLineItemTotals: [20, 40], - assertPreview: (preview) => { - expect( - preview.line_items.some( - (lineItem) => lineItem.feature_id === TestFeature.Words, - ), - ).toBe(false); - }, - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 10: customize.items uses custom prepaid and one-off prices")}`, async () => { - const base = products.base({ - id: "preview-custom-items-chargeable", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-custom-items-chargeable", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [ - { - plan_id: base.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 300, - }, - { - feature_id: TestFeature.Words, - quantity: 200, - }, - ], - customize: { - items: [ - itemsV2.prepaidMessages({ - amount: 12, - billingUnits: 100, - }), - { - feature_id: TestFeature.Words, - included: 0, - price: { - amount: 15, - interval: BillingInterval.OneOff, - billing_method: BillingMethod.Prepaid, - billing_units: 100, - }, - }, - { - feature_id: TestFeature.Users, - included: 0, - price: { - amount: 7, - interval: BillingInterval.Month, - billing_method: BillingMethod.UsageBased, - billing_units: 1, - }, - }, - ], - }, - }, - ], - }, - ], - }, - expectedTotal: 66, - expectedLineItemTotals: [0, 30, 36], - assertPreview: (preview) => { - expect( - sortStrings( - preview.line_items.map((lineItem) => lineItem.feature_id ?? "base"), - ), - ).toEqual( - sortStrings([ - TestFeature.Messages, - TestFeature.Users, - TestFeature.Words, - ]), - ); - }, - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 11: one-off plan charges now and has no next cycle")}`, async () => { - const oneOff = products.base({ - id: "preview-one-off-base", - items: [items.oneOffPrice({ price: 50 }), items.monthlyMessages()], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-one-off", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [oneOff] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [{ plan_id: oneOff.id }], - }, - ], - }, - expectedTotal: 50, - expectedLineItemTotals: [50], - assertPreview: (preview) => { - expect(preview.next_cycle).toBeUndefined(); - }, - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 12: prepaid quantities only charge for units above included usage")}`, async () => { - const prepaid = products.base({ - id: "preview-prepaid-included-usage", - items: [items.prepaidMessages({ includedUsage: 200 })], - group: "preview-prepaid-included-usage", - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-prepaid-included-usage", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [prepaid] }), - ], - actions: [], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [ - { - plan_id: prepaid.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 200, - }, - ], - }, - ], - }, - ], - }, - expectedTotal: 0, - assertPreview: (preview) => { - expect( - preview.line_items.every((lineItem) => lineItem.total === 0), - ).toBe(true); - }, - }); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule preview 13: active schedules can defer a future replacement without charging now")}`, async () => { - const pro = products.pro({ - id: "preview-future-replacement-pro", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const premium = products.premium({ - id: "preview-future-replacement-premium", - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - - const { customerId, autumnV1, advancedTo } = await initScenario({ - customerId: "create-schedule-preview-future-replacement", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [s.billing.attach({ productId: pro.id })], - }); - - await expectPreviewToMatchCreateSchedule({ - autumnV1, - params: { - customer_id: customerId, - phases: [ - { - starts_at: advancedTo, - plans: [{ plan_id: pro.id }], - }, - { - starts_at: advancedTo + ms.days(15), - plans: [{ plan_id: premium.id }], - }, - ], - }, - expectedTotal: 0, - assertPreview: (preview) => { - expect(preview.line_items).toHaveLength(0); - expect(preview.next_cycle).toBeDefined(); - expect(preview.next_cycle?.total).toBe(50); - expect(preview.next_cycle?.starts_at).toBeCloseTo( - addMonths(advancedTo, 1).getTime(), - -ms.days(1), - ); - }, - }); -}); diff --git a/server/tests/integration/billing/create-schedule/one-off-prepaid-preserve/preserve-on-schedule.test.ts b/server/tests/integration/billing/create-schedule/one-off-prepaid-preserve/preserve-on-schedule.test.ts index 77b919b7c..cd6d45aa1 100644 --- a/server/tests/integration/billing/create-schedule/one-off-prepaid-preserve/preserve-on-schedule.test.ts +++ b/server/tests/integration/billing/create-schedule/one-off-prepaid-preserve/preserve-on-schedule.test.ts @@ -16,7 +16,11 @@ */ import { test } from "bun:test"; -import { ms, type ApiCustomerV3, type CreateScheduleParamsV0Input } from "@autumn/shared"; +import { + type ApiCustomerV3, + type CreateScheduleParamsV0Input, + ms, +} from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { TestFeature } from "@tests/setup/v2Features"; diff --git a/server/tests/integration/billing/create-schedule/params/create-schedule-customize.test.ts b/server/tests/integration/billing/create-schedule/params/create-schedule-customize.test.ts new file mode 100644 index 000000000..bbfd348b0 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/params/create-schedule-customize.test.ts @@ -0,0 +1,601 @@ +import { expect, test } from "bun:test"; +import { + CusProductStatus, + customerEntitlements, + customerProducts, + ms, + schedulePhases, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { + getCustomerProductEntitlementBalances, + getCustomerProductPriceAmounts, + getRequiredScheduleId, +} from "../utils/createScheduleTestHelpers"; + +test.concurrent( + `${chalk.yellowBright("create-schedule: preserves feature quantity options on created customer products")}`, + async () => { + const prepaidMessages = products.base({ + id: "prepaid", + items: [items.prepaidMessages()], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-options", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prepaidMessages] }), + ], + actions: [], + }); + + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [ + { + plan_id: prepaidMessages.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 400, + }, + ], + }, + ], + }, + ], + }); + + const insertedProducts = await ctx.db + .select({ + options: customerProducts.options, + }) + .from(customerProducts) + .where( + eq(customerProducts.id, response.phases[0]!.customer_product_ids[0]!), + ); + + expect(insertedProducts).toHaveLength(1); + expect(insertedProducts[0]!.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 4, + }), + ]); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: preserves customize.items on created customer products")}`, + async () => { + const base = products.base({ + id: "custom-base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-customize", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [ + { + plan_id: base.id, + customize: { + items: [itemsV2.monthlyWords({ included: 250 })], + }, + }, + ], + }, + ], + }); + + const insertedEntitlements = await ctx.db + .select({ + feature_id: customerEntitlements.feature_id, + balance: customerEntitlements.balance, + }) + .from(customerEntitlements) + .where( + eq( + customerEntitlements.customer_product_id, + response.phases[0]!.customer_product_ids[0]!, + ), + ); + + expect(insertedEntitlements).toEqual([ + { + feature_id: TestFeature.Words, + balance: 250, + }, + ]); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: preserves customize.items on future scheduled customer products")}`, + async () => { + const base = products.base({ + id: "custom-future-base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-customize-future", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = Date.now(); + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: base.id, + customize: { + items: [itemsV2.monthlyWords({ included: 250 })], + }, + }, + ], + }, + ], + }); + + const futurePhase = response.phases[1]; + expect(futurePhase).toBeDefined(); + + const futureCustomerProductId = futurePhase?.customer_product_ids[0]; + expect(futureCustomerProductId).toBeTruthy(); + + if (!futureCustomerProductId) { + throw new Error( + "Expected a scheduled customer product for the future phase", + ); + } + + const insertedEntitlements = await ctx.db + .select({ + feature_id: customerEntitlements.feature_id, + balance: customerEntitlements.balance, + }) + .from(customerEntitlements) + .where( + eq(customerEntitlements.customer_product_id, futureCustomerProductId), + ); + + expect(insertedEntitlements).toEqual([ + { + feature_id: TestFeature.Words, + balance: 250, + }, + ]); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: customized future phases keep custom prices and entitlements through activation")}`, + async () => { + const base = products.base({ + id: "create-schedule-customize-rollover", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice(), + ], + }); + + const { customerId, autumnV1, ctx, testClockId, advancedTo } = + await initScenario({ + customerId: "create-schedule-customize-rollover", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = advancedTo; + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(15), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 35 }), + items: [ + itemsV2.monthlyWords({ included: 250 }), + itemsV2.dashboard(), + ], + }, + }, + ], + }, + ], + }); + + const futureCustomerProductId = + response.phases[1]!.customer_product_ids[0]!; + + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual([35]); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Words, balance: 250 }, + { feature_id: TestFeature.Dashboard, balance: 0 }, + ]), + ); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: now + ms.days(16), + waitForSeconds: 30, + }); + + const activatedProduct = await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, futureCustomerProductId), + }); + + expect(activatedProduct?.status).toBe(CusProductStatus.Active); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual([35]); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Words, balance: 250 }, + { feature_id: TestFeature.Dashboard, balance: 0 }, + ]), + ); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: updating a future customized phase replaces its custom prices and quantities before activation")}`, + async () => { + const base = products.base({ + id: "create-schedule-custom-update", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice(), + ], + }); + + const { customerId, autumnV1, ctx, testClockId, advancedTo } = + await initScenario({ + customerId: "create-schedule-custom-update", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = advancedTo; + const initialResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(15), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 35 }), + items: [ + itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }), + ], + }, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 200, + }, + ], + }, + ], + }, + ], + }); + + const initialFutureCustomerProductId = + initialResponse.phases[1]!.customer_product_ids[0]!; + + const updatedResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(15), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 55 }), + items: [ + itemsV2.prepaidMessages({ amount: 20, billingUnits: 100 }), + ], + }, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 500, + }, + ], + }, + ], + }, + ], + }); + + const updatedFutureCustomerProductId = + updatedResponse.phases[1]!.customer_product_ids[0]!; + + expect(updatedFutureCustomerProductId).not.toBe( + initialFutureCustomerProductId, + ); + expect( + await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, initialFutureCustomerProductId), + }), + ).toBeUndefined(); + + const updatedFutureCustomerProduct = + await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, updatedFutureCustomerProductId), + }); + + expect(updatedFutureCustomerProduct?.status).toBe( + CusProductStatus.Scheduled, + ); + expect(updatedFutureCustomerProduct?.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 5, + }), + ]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: updatedFutureCustomerProductId, + }), + ).toEqual([55]); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: now + ms.days(16), + waitForSeconds: 30, + }); + + const activatedFutureCustomerProduct = + await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, updatedFutureCustomerProductId), + }); + + expect(activatedFutureCustomerProduct?.status).toBe( + CusProductStatus.Active, + ); + expect(activatedFutureCustomerProduct?.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 5, + }), + ]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: updatedFutureCustomerProductId, + }), + ).toEqual([55]); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: updating a schedule with customized future phase persists both phases and custom items")}`, + async () => { + const base = products.base({ + id: "base", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice(), + ], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-update-with-customize", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = Date.now(); + const initialResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + ], + }); + + expect(initialResponse.phases).toHaveLength(1); + + const updatedResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 50 }), + items: [itemsV2.monthlyWords({ included: 200 })], + }, + }, + ], + }, + ], + }); + + expect(updatedResponse.phases).toHaveLength(2); + + const updatedDbPhases = await ctx.db + .select() + .from(schedulePhases) + .where( + eq( + schedulePhases.schedule_id, + getRequiredScheduleId(updatedResponse.schedule_id), + ), + ); + expect(updatedDbPhases).toHaveLength(2); + + const futureCustomerProductId = + updatedResponse.phases[1]!.customer_product_ids[0]!; + + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual([50]); + + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual( + expect.arrayContaining([{ feature_id: TestFeature.Words, balance: 200 }]), + ); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: customize with boolean feature persists the boolean entitlement")}`, + async () => { + const base = products.base({ + id: "bool-base", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice(), + ], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-customize-boolean", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [ + { + plan_id: base.id, + customize: { + items: [ + itemsV2.monthlyMessages({ included: 100 }), + itemsV2.dashboard(), + ], + }, + }, + ], + }, + ], + }); + + const customerProductId = response.phases[0]!.customer_product_ids[0]!; + + const entitlementBalances = await getCustomerProductEntitlementBalances({ + ctx, + customerProductId, + }); + + expect(entitlementBalances).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Messages, balance: 100 }, + { feature_id: TestFeature.Dashboard, balance: 0 }, + ]), + ); + + const customerProduct = await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, customerProductId), + }); + expect(customerProduct?.is_custom).toBe(true); + }, +); diff --git a/server/tests/integration/billing/create-schedule/create-schedule-enable-plan-immediately.test.ts b/server/tests/integration/billing/create-schedule/params/create-schedule-enable-plan-immediately.test.ts similarity index 50% rename from server/tests/integration/billing/create-schedule/create-schedule-enable-plan-immediately.test.ts rename to server/tests/integration/billing/create-schedule/params/create-schedule-enable-plan-immediately.test.ts index 6ccbea992..41a9d9e47 100644 --- a/server/tests/integration/billing/create-schedule/create-schedule-enable-plan-immediately.test.ts +++ b/server/tests/integration/billing/create-schedule/params/create-schedule-enable-plan-immediately.test.ts @@ -24,8 +24,8 @@ import { schedulePhases, schedules, } from "@autumn/shared"; -import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; import { items } from "@tests/utils/fixtures/items"; @@ -44,139 +44,144 @@ const parseCheckoutSessionId = (url: string): string | null => { // TEST 1: Happy path // ═══════════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("create-schedule enable_plan_immediately: pre-inserts both phases, webhook persists schedule")}`, async () => { - const customerId = "create-schedule-eppi-happy"; +test.concurrent( + `${chalk.yellowBright("create-schedule enable_plan_immediately: pre-inserts both phases, webhook persists schedule")}`, + async () => { + const customerId = "create-schedule-eppi-happy"; - const pro = products.pro({ - id: "pro-eppi-cs", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - const growth = products.pro({ - id: "growth-eppi-cs", - items: [items.monthlyMessages({ includedUsage: 500 })], - }); + const pro = products.pro({ + id: "pro-eppi-cs", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const growth = products.pro({ + id: "growth-eppi-cs", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: true }), // No payment method → stripe_checkout - s.products({ list: [pro, growth] }), - ], - actions: [], - }); + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true }), // No payment method → stripe_checkout + s.products({ list: [pro, growth] }), + ], + actions: [], + }); - const dbCustomer = await ctx.db.query.customers.findFirst({ - where: eq(customers.id, customerId), - }); - expect(dbCustomer).toBeDefined(); - const internalCustomerId = dbCustomer!.internal_id; + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), + }); + expect(dbCustomer).toBeDefined(); + const internalCustomerId = dbCustomer!.internal_id; - const now = Date.now(); - const params: CreateScheduleParamsV0Input = { - customer_id: customerId, - enable_plan_immediately: true, - phases: [ - { - starts_at: now, - plans: [{ plan_id: pro.id }], - }, - { - starts_at: now + ms.days(30), - plans: [{ plan_id: growth.id }], - }, - ], - }; + const now = Date.now(); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + enable_plan_immediately: true, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: growth.id }], + }, + ], + }; - const response = await autumnV1.billing.createSchedule(params); + const response = await autumnV1.billing.createSchedule(params); - expect(response.status).toBe("pending_payment"); - expect(response.schedule_id).toBeNull(); - expect(response.payment_url).toBeDefined(); - expect(response.payment_url).toContain("checkout.stripe.com"); + expect(response.status).toBe("pending_payment"); + expect(response.schedule_id).toBeNull(); + expect(response.payment_url).toBeDefined(); + expect(response.payment_url).toContain("checkout.stripe.com"); - const checkoutSessionId = parseCheckoutSessionId(response.payment_url!); - expect(checkoutSessionId).toBeTruthy(); + const checkoutSessionId = parseCheckoutSessionId(response.payment_url!); + expect(checkoutSessionId).toBeTruthy(); - // Pre-completion: both cusProducts exist, linked to the same checkout session. - const cusProductsBefore = await CusProductService.list({ - db: ctx.db, - internalCustomerId, - inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled], - }); + // Pre-completion: both cusProducts exist, linked to the same checkout session. + const cusProductsBefore = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled], + }); - const proBefore = cusProductsBefore.find((cp) => cp.product.id === pro.id); - const growthBefore = cusProductsBefore.find( - (cp) => cp.product.id === growth.id, - ); - expect(proBefore).toBeDefined(); - expect(growthBefore).toBeDefined(); + const proBefore = cusProductsBefore.find((cp) => cp.product.id === pro.id); + const growthBefore = cusProductsBefore.find( + (cp) => cp.product.id === growth.id, + ); + expect(proBefore).toBeDefined(); + expect(growthBefore).toBeDefined(); - expect(proBefore!.status).toBe(CusProductStatus.Active); - expect(growthBefore!.status).toBe(CusProductStatus.Scheduled); + const customerBefore = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerBefore, + active: [pro.id], + scheduled: [growth.id], + }); - expect(proBefore!.stripe_checkout_session_id).toBe(checkoutSessionId); - expect(growthBefore!.stripe_checkout_session_id).toBe(checkoutSessionId); + expect(proBefore!.stripe_checkout_session_id).toBe(checkoutSessionId); + expect(growthBefore!.stripe_checkout_session_id).toBe(checkoutSessionId); - expect(proBefore!.subscription_ids ?? []).toHaveLength(0); - expect(growthBefore!.subscription_ids ?? []).toHaveLength(0); + expect(proBefore!.subscription_ids ?? []).toHaveLength(0); + expect(growthBefore!.subscription_ids ?? []).toHaveLength(0); - // Pre-completion: no schedule rows yet. - const schedulesBefore = await ctx.db - .select() - .from(schedules) - .where(eq(schedules.internal_customer_id, internalCustomerId)); - expect(schedulesBefore).toHaveLength(0); + // Pre-completion: no schedule rows yet. + const schedulesBefore = await ctx.db + .select() + .from(schedules) + .where(eq(schedules.internal_customer_id, internalCustomerId)); + expect(schedulesBefore).toHaveLength(0); - // API view: pro is already active immediately. - const customerBefore = - await autumnV1.customers.get(customerId); - await expectProductActive({ customer: customerBefore, productId: pro.id }); + // Customer completes checkout. + await completeStripeCheckoutForm({ url: response.payment_url! }); - // Customer completes checkout. - await completeStripeCheckoutForm({ url: response.payment_url! }); + // Post-completion: subscription_ids patched on the immediate row, + // schedule + phases rows now exist. + const cusProductsAfter = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled], + }); + const proAfter = cusProductsAfter.find((cp) => cp.product.id === pro.id); + const growthAfter = cusProductsAfter.find( + (cp) => cp.product.id === growth.id, + ); + expect(proAfter!.id).toBe(proBefore!.id); + expect(proAfter!.subscription_ids ?? []).toHaveLength(1); - // Post-completion: subscription_ids patched on the immediate row, - // schedule + phases rows now exist. - const cusProductsAfter = await CusProductService.list({ - db: ctx.db, - internalCustomerId, - inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled], - }); - const proAfter = cusProductsAfter.find((cp) => cp.product.id === pro.id); - const growthAfter = cusProductsAfter.find( - (cp) => cp.product.id === growth.id, - ); - expect(proAfter!.id).toBe(proBefore!.id); - expect(proAfter!.subscription_ids ?? []).toHaveLength(1); - expect(growthAfter!.status).toBe(CusProductStatus.Scheduled); + const customerAfter = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfter, + active: [pro.id], + scheduled: [growth.id], + }); - const schedulesAfter = await ctx.db - .select() - .from(schedules) - .where(eq(schedules.internal_customer_id, internalCustomerId)); - expect(schedulesAfter).toHaveLength(1); + const schedulesAfter = await ctx.db + .select() + .from(schedules) + .where(eq(schedules.internal_customer_id, internalCustomerId)); + expect(schedulesAfter).toHaveLength(1); - const phasesAfter = await ctx.db - .select() - .from(schedulePhases) - .where(eq(schedulePhases.schedule_id, schedulesAfter[0]!.id)); - expect(phasesAfter).toHaveLength(2); + const phasesAfter = await ctx.db + .select() + .from(schedulePhases) + .where(eq(schedulePhases.schedule_id, schedulesAfter[0]!.id)); + expect(phasesAfter).toHaveLength(2); - // scheduled_ids should be populated on paid+recurring rows once the Stripe - // subscription_schedule is created in the webhook. - expect(proAfter!.scheduled_ids ?? []).toHaveLength(1); - expect(growthAfter!.scheduled_ids ?? []).toHaveLength(1); - expect(proAfter!.scheduled_ids![0]).toBe(growthAfter!.scheduled_ids![0]); + // scheduled_ids should be populated on paid+recurring rows once the Stripe + // subscription_schedule is created in the webhook. + expect(proAfter!.scheduled_ids ?? []).toHaveLength(1); + expect(growthAfter!.scheduled_ids ?? []).toHaveLength(1); + expect(proAfter!.scheduled_ids![0]).toBe(growthAfter!.scheduled_ids![0]); - // Cross-checks the Stripe subscription_schedule phases against the Autumn - // cusProduct timeline. - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); + // Cross-checks the Stripe subscription_schedule phases against the Autumn + // cusProduct timeline. + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 2: Abandoned session — both phases cleaned up; no schedule rows ever exist diff --git a/server/tests/integration/billing/create-schedule/create-schedule-subscription-id.test.ts b/server/tests/integration/billing/create-schedule/params/create-schedule-subscription-id.test.ts similarity index 100% rename from server/tests/integration/billing/create-schedule/create-schedule-subscription-id.test.ts rename to server/tests/integration/billing/create-schedule/params/create-schedule-subscription-id.test.ts diff --git a/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-checkout.test.ts b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-checkout.test.ts new file mode 100644 index 000000000..14e134543 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-checkout.test.ts @@ -0,0 +1,453 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + CheckoutAction, + CusProductStatus, + customerProducts, + ms, + schedulePhases, + schedules, +} from "@autumn/shared"; +import { + confirmAutumnCheckout, + fetchAutumnCheckout, +} from "@tests/integration/billing/utils/checkout/autumnCheckoutUtils"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { isAutumnCheckoutUrl } from "@tests/integration/billing/utils/isAutumnCheckoutUrl"; +import { TestFeature } from "@tests/setup/v2Features"; +import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; +import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq, inArray } from "drizzle-orm"; +import { CusService } from "@/internal/customers/CusService"; +import { attachPaymentMethod } from "@/utils/scriptUtils/initCustomer"; +import { + getCheckoutId, + getRequiredScheduleId, +} from "../utils/createScheduleTestHelpers"; + +test.concurrent( + `${chalk.yellowBright("create-schedule: persists the new schedule and returns required_action when immediate billing is deferred")}`, + async () => { + const pro = products.pro({ + id: "deferred-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "deferred-premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-deferred", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const now = Date.now(); + const initialResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: premium.id }], + }, + ], + }); + + const persistedCustomer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const stripeCustomerId = persistedCustomer?.processor?.id; + if (!stripeCustomerId) { + throw new Error( + "Expected Stripe customer id before deferred create_schedule test", + ); + } + + await attachPaymentMethod({ + stripeCli: ctx.stripeCli, + stripeCusId: stripeCustomerId, + type: "authenticate", + }); + + const deferredResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [{ plan_id: premium.id }], + }, + ], + }); + + expect(deferredResponse.required_action).toBeDefined(); + expect(deferredResponse.required_action?.code).toBe("3ds_required"); + expect(deferredResponse.payment_url).toBeDefined(); + expect(deferredResponse.schedule_id).toBeNull(); + expect(deferredResponse.phases).toEqual([]); + expect(deferredResponse.status).toBe("pending_payment"); + + const schedulesAfterDeferredAttempt = await ctx.db + .select({ + id: schedules.id, + }) + .from(schedules) + .where(eq(schedules.customer_id, customerId)); + + expect(schedulesAfterDeferredAttempt).toHaveLength(1); + expect(schedulesAfterDeferredAttempt[0]!.id).toBe( + getRequiredScheduleId(initialResponse.schedule_id), + ); + + const phasesAfterDeferredAttempt = await ctx.db + .select({ + id: schedulePhases.id, + customer_product_ids: schedulePhases.customer_product_ids, + }) + .from(schedulePhases) + .where( + eq( + schedulePhases.schedule_id, + getRequiredScheduleId(initialResponse.schedule_id), + ), + ); + + expect(phasesAfterDeferredAttempt).toHaveLength(2); + expect(phasesAfterDeferredAttempt[0]!.customer_product_ids).toEqual( + initialResponse.phases[0]!.customer_product_ids, + ); + expect(phasesAfterDeferredAttempt[1]!.customer_product_ids).toEqual( + initialResponse.phases[1]!.customer_product_ids, + ); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: no payment method returns Stripe checkout and creates Stripe schedule")}`, + async () => { + const pro = products.base({ + id: "create-schedule-checkout-pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + const premium = products.base({ + id: "create-schedule-checkout-premium", + items: [ + items.monthlyMessages({ includedUsage: 500 }), + items.monthlyPrice({ price: 50 }), + ], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-no-pm-checkout", + setup: [s.customer({}), s.products({ list: [pro, premium] })], + actions: [], + }); + + const now = Date.now(); + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: premium.id }], + }, + ], + }); + + expect(response.status).toBe("pending_payment"); + expect(response.payment_url).toBeDefined(); + expect(isAutumnCheckoutUrl(response.payment_url!)).toBe(false); + expect(response.schedule_id).toBeNull(); + expect(response.phases).toEqual([]); + + await completeStripeCheckoutForm({ url: response.payment_url! }); + await timeout(4000); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestStatus: "paid", + latestTotal: 20, + }); + await expectCustomerProducts({ + customer, + active: [pro.id], + scheduled: [premium.id], + }); + + const dbSchedules = await ctx.db + .select({ id: schedules.id }) + .from(schedules) + .where(eq(schedules.customer_id, customerId)); + + expect(dbSchedules).toHaveLength(1); + + const phaseRows = await ctx.db + .select({ customer_product_ids: schedulePhases.customer_product_ids }) + .from(schedulePhases) + .where(eq(schedulePhases.schedule_id, dbSchedules[0]!.id)); + + expect(phaseRows).toHaveLength(2); + + const persistedProducts = await ctx.db + .select({ + productId: customerProducts.product_id, + status: customerProducts.status, + }) + .from(customerProducts) + .where( + inArray( + customerProducts.id, + phaseRows.flatMap((phase) => phase.customer_product_ids), + ), + ); + + expect(persistedProducts).toEqual( + expect.arrayContaining([ + { + productId: pro.id, + status: CusProductStatus.Active, + }, + { + productId: premium.id, + status: CusProductStatus.Scheduled, + }, + ]), + ); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: redirect_mode always returns Autumn checkout and confirms into a persisted schedule")}`, + async () => { + const starter = products.base({ + id: "create-schedule-autumn-starter", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + const premium = products.base({ + id: "create-schedule-autumn-premium", + items: [ + items.monthlyMessages({ includedUsage: 500 }), + items.monthlyPrice({ price: 50 }), + ], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-autumn-checkout", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [starter, premium] }), + ], + actions: [s.billing.attach({ productId: starter.id })], + }); + + const now = Date.now(); + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + redirect_mode: "always", + phases: [ + { + starts_at: now, + plans: [{ plan_id: premium.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: starter.id }], + }, + ], + }); + + expect(response.status).toBe("pending_payment"); + expect(response.schedule_id).toBeNull(); + expect(response.phases).toEqual([]); + expect(isAutumnCheckoutUrl(response.payment_url!)).toBe(true); + + const checkoutId = getCheckoutId(response.payment_url); + const checkout = await fetchAutumnCheckout({ checkoutId }); + + expect(checkout.action).toBe(CheckoutAction.CreateSchedule); + expect(checkout.preview.total).toBe(30); + + await confirmAutumnCheckout({ + checkoutId, + customerId, + productId: premium.id, + }); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.features?.[TestFeature.Messages]?.balance).toBe(500); + + const dbSchedules = await ctx.db + .select({ id: schedules.id }) + .from(schedules) + .where(eq(schedules.customer_id, customerId)); + + expect(dbSchedules).toHaveLength(1); + + const persistedPhases = await ctx.db + .select({ + starts_at: schedulePhases.starts_at, + customer_product_ids: schedulePhases.customer_product_ids, + }) + .from(schedulePhases) + .where(eq(schedulePhases.schedule_id, dbSchedules[0]!.id)); + + expect(persistedPhases).toHaveLength(2); + + const immediateProducts = await ctx.db + .select({ + productId: customerProducts.product_id, + status: customerProducts.status, + }) + .from(customerProducts) + .where( + inArray(customerProducts.id, persistedPhases[0]!.customer_product_ids), + ); + const futureProducts = await ctx.db + .select({ + productId: customerProducts.product_id, + status: customerProducts.status, + }) + .from(customerProducts) + .where( + inArray(customerProducts.id, persistedPhases[1]!.customer_product_ids), + ); + + expect(immediateProducts).toEqual([ + { + productId: premium.id, + status: CusProductStatus.Active, + }, + ]); + expect(futureProducts).toEqual([ + { + productId: starter.id, + status: CusProductStatus.Scheduled, + }, + ]); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: invoice mode can collect payment without an attached payment method")}`, + async () => { + const pro = products.base({ + id: "create-schedule-invoice-pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-invoice-no-pm", + setup: [s.customer({}), s.products({ list: [pro] })], + actions: [], + }); + + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + invoice_mode: { + enabled: true, + finalize: true, + enable_plan_immediately: false, + }, + phases: [ + { + starts_at: Date.now(), + plans: [{ plan_id: pro.id }], + }, + ], + }); + + expect(response.status).toBe("pending_payment"); + expect(response.invoice?.status).toBe("open"); + expect(response.payment_url).toBeDefined(); + expect(response.schedule_id).toBeNull(); + expect(response.phases).toEqual([]); + + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined(); + + await expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 1, + latestStatus: "open", + latestTotal: 20, + }); + + await completeInvoiceCheckout({ url: response.payment_url! }); + await timeout(4000); + + const customerAfter = + await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 1, + latestStatus: "paid", + latestTotal: 20, + }); + + const dbSchedules = await ctx.db + .select({ id: schedules.id }) + .from(schedules) + .where(eq(schedules.customer_id, customerId)); + + expect(dbSchedules).toHaveLength(1); + + const phaseRows = await ctx.db + .select({ customer_product_ids: schedulePhases.customer_product_ids }) + .from(schedulePhases) + .where(eq(schedulePhases.schedule_id, dbSchedules[0]!.id)); + + expect(phaseRows).toHaveLength(1); + + const persistedProducts = await ctx.db + .select({ + productId: customerProducts.product_id, + status: customerProducts.status, + }) + .from(customerProducts) + .where(inArray(customerProducts.id, phaseRows[0]!.customer_product_ids)); + + expect(persistedProducts).toEqual([ + { + productId: pro.id, + status: CusProductStatus.Active, + }, + ]); + }, +); diff --git a/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-replacements.test.ts b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-replacements.test.ts new file mode 100644 index 000000000..a18ca0af8 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-replacements.test.ts @@ -0,0 +1,473 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + CusProductStatus, + customerProducts, + ms, + schedulePhases, + schedules, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { and, eq, inArray } from "drizzle-orm"; +import { + getCustomerProductRows, + getRequiredScheduleId, +} from "../utils/createScheduleTestHelpers"; + +test.concurrent( + `${chalk.yellowBright("create-schedule: copies entity_id and replaces the prior schedule")}`, + async () => { + const seats = products.base({ + id: "seats", + items: [items.prepaidUsers()], + }); + const backup = products.base({ + id: "backup", + items: [items.prepaidMessages()], + group: "backup", + }); + + const { customerId, autumnV1, ctx, entities } = await initScenario({ + customerId: "create-schedule-replace", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [seats, backup] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const entityId = entities[0]!.id; + const now = Date.now(); + + const firstResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + entity_id: entityId, + phases: [ + { + starts_at: now, + plans: [ + { + plan_id: seats.id, + feature_quantities: [ + { + feature_id: TestFeature.Users, + quantity: 3, + }, + ], + }, + ], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: backup.id }], + }, + ], + }); + + const firstScheduledCustomerProductId = + firstResponse.phases[1]!.customer_product_ids[0]!; + + const secondResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + entity_id: entityId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: backup.id }], + }, + ], + }); + + const dbSchedules = await ctx.db + .select() + .from(schedules) + .where( + and( + eq(schedules.customer_id, customerId), + eq(schedules.entity_id, entityId), + ), + ); + expect(dbSchedules).toHaveLength(1); + expect(dbSchedules[0]!.id).toBe( + getRequiredScheduleId(secondResponse.schedule_id), + ); + + const removedScheduledProducts = await ctx.db + .select() + .from(customerProducts) + .where(eq(customerProducts.id, firstScheduledCustomerProductId)); + expect(removedScheduledProducts).toHaveLength(0); + + const removedSchedule = await ctx.db + .select() + .from(schedules) + .where( + eq(schedules.id, getRequiredScheduleId(firstResponse.schedule_id)), + ); + expect(removedSchedule).toHaveLength(0); + + const newCustomerProducts = await ctx.db + .select() + .from(customerProducts) + .where( + inArray( + customerProducts.id, + secondResponse.phases.flatMap( + (phase: { customer_product_ids: string[] }) => + phase.customer_product_ids, + ), + ), + ); + + expect(newCustomerProducts).toHaveLength(1); + expect(newCustomerProducts[0]!.entity_id).toBe(entityId); + expect(newCustomerProducts[0]!.status).toBe(CusProductStatus.Active); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: allows multiple group replacements when they only conflict with current plans")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const usersItem = items.monthlyUsers({ includedUsage: 5 }); + + const existingA = products.base({ + id: "create-schedule-existing-a", + items: [messagesItem, items.monthlyPrice({ price: 5 })], + }); + const existingB = products.base({ + id: "create-schedule-existing-b", + items: [usersItem, items.monthlyPrice({ price: 5 })], + group: "group-b", + }); + const replacementA = products.pro({ + id: "create-schedule-replacement-a", + items: [messagesItem], + }); + const replacementB = products.base({ + id: "create-schedule-replacement-b", + items: [usersItem, items.monthlyPrice({ price: 20 })], + group: "group-b", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "create-schedule-multi-replace", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ + list: [existingA, existingB, replacementA, replacementB], + }), + ], + actions: [ + s.billing.attach({ productId: existingA.id }), + s.billing.attach({ productId: existingB.id }), + ], + }); + + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [{ plan_id: replacementA.id }, { plan_id: replacementB.id }], + }, + ], + }); + + expect(response.customer_id).toBe(customerId); + expect(response.phases).toHaveLength(1); + expect(response.phases[0]!.customer_product_ids).toHaveLength(2); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: future replacements for an active group stay scheduled until their phase")}`, + async () => { + const currentGroupB = products.base({ + id: "create-schedule-current-group-b", + items: [ + items.monthlyUsers({ includedUsage: 5 }), + items.monthlyPrice({ price: 5 }), + ], + group: "group-b", + }); + const nowBase = products.pro({ + id: "create-schedule-active-now-base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const futureReplacementB = products.base({ + id: "create-schedule-future-replacement-b", + items: [ + items.monthlyMessages({ includedUsage: 200 }), + items.monthlyPrice({ price: 15 }), + ], + group: "group-b", + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-future-replacement-stays-scheduled", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ + list: [currentGroupB, nowBase, futureReplacementB], + }), + ], + actions: [s.billing.attach({ productId: currentGroupB.id })], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: nowBase.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: futureReplacementB.id }], + }, + ], + }); + + const productRows = await getCustomerProductRows({ + ctx, + customerId, + productIds: [nowBase.id, futureReplacementB.id], + }); + + expect( + productRows.filter( + (productRow) => productRow.productId === futureReplacementB.id, + ), + ).toEqual([ + { + productId: futureReplacementB.id, + status: CusProductStatus.Scheduled, + }, + ]); + expect( + productRows.filter( + (productRow) => + productRow.productId === futureReplacementB.id && + productRow.status === CusProductStatus.Active, + ), + ).toHaveLength(0); + + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestInvoiceProductIds: [nowBase.id], + }); + expect(customer.invoices?.[0]?.product_ids).not.toContain( + futureReplacementB.id, + ); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: replacing a schedule removes old phases and leaves the correct replacement state in db")}`, + async () => { + const currentA = products.base({ + id: "create-schedule-replace-state-current-a", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const currentB = products.base({ + id: "create-schedule-replace-state-current-b", + items: [items.monthlyUsers({ includedUsage: 5 })], + group: "group-b", + }); + const currentAddon = products.recurringAddOn({ + id: "create-schedule-replace-state-current-addon", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + const firstFutureA = products.pro({ + id: "create-schedule-replace-state-first-future-a", + items: [items.monthlyMessages({ includedUsage: 300 })], + }); + const firstFutureAddon = products.recurringAddOn({ + id: "create-schedule-replace-state-first-future-addon", + items: [items.monthlyWords({ includedUsage: 75 })], + }); + const secondNowA = products.premium({ + id: "create-schedule-replace-state-second-now-a", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const secondFutureA = products.pro({ + id: "create-schedule-replace-state-second-future-a", + items: [items.monthlyMessages({ includedUsage: 300 })], + }); + const secondFutureB = products.pro({ + id: "create-schedule-replace-state-second-future-b", + items: [items.monthlyUsers({ includedUsage: 10 })], + group: "group-b", + }); + + const { customerId, autumnV1, ctx, advancedTo } = await initScenario({ + customerId: "create-schedule-replace-state", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ + list: [ + currentA, + currentB, + currentAddon, + firstFutureA, + firstFutureAddon, + secondNowA, + secondFutureA, + secondFutureB, + ], + }), + ], + actions: [ + s.billing.attach({ productId: currentA.id }), + s.billing.attach({ productId: currentB.id }), + s.billing.attach({ productId: currentAddon.id }), + ], + }); + + const now = advancedTo; + const firstResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [ + { plan_id: currentA.id }, + { plan_id: currentB.id }, + { plan_id: currentAddon.id }, + ], + }, + { + starts_at: now + ms.days(15), + plans: [ + { plan_id: firstFutureA.id }, + { plan_id: firstFutureAddon.id }, + ], + }, + ], + }); + + const replacementNow = Date.now(); + const secondResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: replacementNow, + plans: [{ plan_id: secondNowA.id }, { plan_id: currentAddon.id }], + }, + { + starts_at: replacementNow + ms.days(15), + plans: [{ plan_id: secondFutureA.id }, { plan_id: secondFutureB.id }], + }, + ], + }); + + const dbSchedules = await ctx.db + .select() + .from(schedules) + .where(eq(schedules.customer_id, customerId)); + expect(dbSchedules).toHaveLength(1); + expect(dbSchedules[0]!.id).toBe( + getRequiredScheduleId(secondResponse.schedule_id), + ); + + const firstSchedule = await ctx.db + .select() + .from(schedules) + .where( + eq(schedules.id, getRequiredScheduleId(firstResponse.schedule_id)), + ); + expect(firstSchedule).toHaveLength(0); + + const secondSchedulePhases = await ctx.db + .select() + .from(schedulePhases) + .where( + eq( + schedulePhases.schedule_id, + getRequiredScheduleId(secondResponse.schedule_id), + ), + ); + expect(secondSchedulePhases).toHaveLength(2); + + const firstSchedulePhases = await ctx.db + .select() + .from(schedulePhases) + .where( + eq( + schedulePhases.schedule_id, + getRequiredScheduleId(firstResponse.schedule_id), + ), + ); + expect(firstSchedulePhases).toHaveLength(0); + + const productRowsAfterReplace = await getCustomerProductRows({ + ctx, + customerId, + productIds: [ + currentA.id, + currentB.id, + currentAddon.id, + firstFutureA.id, + firstFutureAddon.id, + secondNowA.id, + secondFutureA.id, + secondFutureB.id, + ], + }); + + expect( + productRowsAfterReplace + .filter((productRow) => productRow.status === CusProductStatus.Active) + .sort((a, b) => a.productId!.localeCompare(b.productId!)), + ).toEqual( + [ + { productId: currentAddon.id, status: CusProductStatus.Active }, + { productId: secondNowA.id, status: CusProductStatus.Active }, + ].sort((a, b) => a.productId.localeCompare(b.productId)), + ); + expect( + productRowsAfterReplace + .filter( + (productRow) => productRow.status === CusProductStatus.Scheduled, + ) + .sort((a, b) => a.productId!.localeCompare(b.productId!)), + ).toEqual( + [ + { productId: secondFutureA.id, status: CusProductStatus.Scheduled }, + { productId: secondFutureB.id, status: CusProductStatus.Scheduled }, + ].sort((a, b) => a.productId.localeCompare(b.productId)), + ); + expect( + productRowsAfterReplace.filter( + (productRow) => + productRow.productId === firstFutureA.id || + productRow.productId === firstFutureAddon.id, + ), + ).toHaveLength(0); + + const customerAfterReplace = + await autumnV1.customers.get(customerId); + expect( + customerAfterReplace.products + ?.map((product) => ({ id: product.id, status: product.status })) + .sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual( + [ + { id: currentAddon.id, status: "active" as const }, + { id: secondFutureA.id, status: "scheduled" as const }, + { id: secondFutureB.id, status: "scheduled" as const }, + { id: secondNowA.id, status: "active" as const }, + ].sort((a, b) => a.id.localeCompare(b.id)), + ); + }, +); diff --git a/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-schedules.test.ts b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-schedules.test.ts new file mode 100644 index 000000000..76ca28dc3 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-schedules.test.ts @@ -0,0 +1,263 @@ +import { expect, test } from "bun:test"; +import { + CusProductStatus, + customerProducts, + ms, + schedulePhases, + schedules, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq, inArray } from "drizzle-orm"; +import { CusService } from "@/internal/customers/CusService"; +import { + getFullCustomerSchedule, + hydrateCustomerWithSchedules, +} from "@/internal/customers/cusUtils/getFullCustomerSchedule"; +import { getRequiredScheduleId } from "../utils/createScheduleTestHelpers"; + +test.concurrent( + `${chalk.yellowBright("create-schedule: hydrates schedules on the full customer")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-hydrate-customer", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const now = Date.now(); + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: premium.id }], + }, + ], + }); + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + expand: [], + }); + const hydratedCustomer = await hydrateCustomerWithSchedules({ + ctx, + fullCustomer, + }); + + expect(hydratedCustomer.schedule?.id).toBe( + getRequiredScheduleId(response.schedule_id), + ); + expect(hydratedCustomer.schedule?.customer_id).toBe(customerId); + expect(hydratedCustomer.schedule?.phases).toHaveLength(2); + expect(hydratedCustomer.schedule?.phases[0]?.starts_at).toBe(now); + expect(hydratedCustomer.schedule?.phases[1]?.starts_at).toBe( + now + ms.days(30), + ); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: adding a future phase to an existing single-phase schedule persists both phases")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-add-future-phase", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const now = Date.now(); + const initialResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + ], + }); + + expect(initialResponse.phases).toHaveLength(1); + expect(initialResponse.phases[0]!.customer_product_ids).toHaveLength(1); + + const initialDbPhases = await ctx.db + .select() + .from(schedulePhases) + .where( + eq( + schedulePhases.schedule_id, + getRequiredScheduleId(initialResponse.schedule_id), + ), + ); + expect(initialDbPhases).toHaveLength(1); + + const updatedResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: premium.id }], + }, + ], + }); + + expect(updatedResponse.phases).toHaveLength(2); + expect(updatedResponse.phases[0]!.starts_at).toBe(now); + expect(updatedResponse.phases[0]!.customer_product_ids).toHaveLength(1); + expect(updatedResponse.phases[1]!.starts_at).toBe(now + ms.days(30)); + expect(updatedResponse.phases[1]!.customer_product_ids).toHaveLength(1); + + const updatedDbPhases = await ctx.db + .select() + .from(schedulePhases) + .where( + eq( + schedulePhases.schedule_id, + getRequiredScheduleId(updatedResponse.schedule_id), + ), + ); + expect(updatedDbPhases).toHaveLength(2); + + const immediateProducts = await ctx.db + .select() + .from(customerProducts) + .where( + inArray( + customerProducts.id, + updatedResponse.phases[0]!.customer_product_ids, + ), + ); + expect(immediateProducts).toHaveLength(1); + expect(immediateProducts[0]!.status).toBe(CusProductStatus.Active); + + const futureProducts = await ctx.db + .select() + .from(customerProducts) + .where( + inArray( + customerProducts.id, + updatedResponse.phases[1]!.customer_product_ids, + ), + ); + expect(futureProducts).toHaveLength(1); + expect(futureProducts[0]!.status).toBe(CusProductStatus.Scheduled); + expect(futureProducts[0]!.product_id).toBe(premium.id); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: customer-level and entity-level schedules coexist independently")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + + const { customerId, autumnV1, ctx, entities } = await initScenario({ + customerId: "create-schedule-entity-coexist", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const entityId = entities[0]!.id; + const now = Date.now(); + + const customerSchedule = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: pro.id }], + }, + ], + }); + + const entitySchedule = await autumnV1.billing.createSchedule({ + customer_id: customerId, + entity_id: entityId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: addon.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: addon.id }], + }, + ], + }); + + expect(customerSchedule.schedule_id).not.toBe(entitySchedule.schedule_id); + + const dbSchedules = await ctx.db + .select() + .from(schedules) + .where(eq(schedules.customer_id, customerId)); + expect(dbSchedules).toHaveLength(2); + + const customerLevelSchedule = dbSchedules.find( + (s) => !s.internal_entity_id, + ); + const entityLevelSchedule = dbSchedules.find((s) => !!s.internal_entity_id); + expect(customerLevelSchedule).toBeDefined(); + expect(entityLevelSchedule).toBeDefined(); + expect(entityLevelSchedule!.entity_id).toBe(entityId); + + const customerScopedSchedule = await getFullCustomerSchedule({ + ctx, + internalCustomerId: dbSchedules[0]!.internal_customer_id, + }); + + expect(customerScopedSchedule?.id).toBe(customerLevelSchedule!.id); + expect(customerScopedSchedule?.internal_entity_id).toBeNull(); + }, +); diff --git a/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-validation.test.ts b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-validation.test.ts new file mode 100644 index 000000000..dcb954162 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases-validation.test.ts @@ -0,0 +1,212 @@ +import { test } from "bun:test"; +import { ms } from "@autumn/shared"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +test.concurrent( + `${chalk.yellowBright("create-schedule: rejects updating a schedule after earlier phases started when past phases are resubmitted")}`, + async () => { + const originalPastBase = products.base({ + id: "create-schedule-update-history-past-base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const currentBase = products.base({ + id: "create-schedule-update-history-current-base", + items: [items.monthlyMessages({ includedUsage: 300 })], + group: "current-base", + }); + const currentAddon = products.recurringAddOn({ + id: "create-schedule-update-history-current-addon", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + const futureBase = products.base({ + id: "create-schedule-update-history-future-base", + items: [items.monthlyMessages({ includedUsage: 500 })], + group: "current-base", + }); + + const { customerId, autumnV1, ctx, testClockId, advancedTo } = + await initScenario({ + customerId: "create-schedule-update-history", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ + list: [originalPastBase, currentBase, currentAddon, futureBase], + }), + ], + actions: [], + }); + + const now = advancedTo; + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: originalPastBase.id }], + }, + { + starts_at: now + ms.days(15), + plans: [{ plan_id: currentBase.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: futureBase.id }], + }, + ], + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: now + ms.days(16), + waitForSeconds: 30, + }); + + await expectAutumnError({ + func: async () => + autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: originalPastBase.id }], + }, + { + starts_at: now + ms.days(15), + plans: [ + { plan_id: currentBase.id }, + { plan_id: currentAddon.id }, + ], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: futureBase.id }], + }, + ], + }), + errMessage: + "Past first phase starts_at is only supported for paid recurring plans.", + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: rejects invalid timing and entity input")}`, + async () => { + const free = products.base({ + id: "free", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "create-schedule-errors", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free, pro] }), + ], + actions: [], + }); + + await expectAutumnError({ + errMessage: + "Past first phase starts_at is only supported for paid recurring plans.", + func: async () => { + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now() - ms.days(1), + plans: [{ plan_id: free.id }], + }, + ], + }); + }, + }); + + await expectAutumnError({ + errMessage: "The first phase must start immediately", + func: async () => { + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now() + ms.days(1), + plans: [{ plan_id: pro.id }], + }, + ], + }); + }, + }); + + await expectAutumnError({ + errMessage: "Phase starts_at values must be strictly increasing", + func: async () => { + const duplicateStartsAt = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: duplicateStartsAt, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: duplicateStartsAt, + plans: [{ plan_id: pro.id }], + }, + ], + }); + }, + }); + + await expectAutumnError({ + errMessage: "not found", + func: async () => { + await autumnV1.billing.createSchedule({ + customer_id: customerId, + entity_id: "missing-entity", + phases: [ + { + starts_at: Date.now(), + plans: [{ plan_id: pro.id }], + }, + ], + }); + }, + }); + + await expectAutumnError({ + errMessage: 'Unrecognized key: "free_trial"', + func: async () => { + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [ + { + plan_id: pro.id, + customize: { + free_trial: { + duration_length: 7, + duration_type: "day", + card_required: false, + }, + }, + }, + ], + }, + ], + }); + }, + }); + }, +); diff --git a/server/tests/integration/billing/create-schedule/phases/create-schedule-phases.test.ts b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases.test.ts new file mode 100644 index 000000000..d4423e972 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases.test.ts @@ -0,0 +1,644 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + applyProration, + type CreateScheduleParamsV0Input, + CusProductStatus, + customerProducts, + ms, + schedulePhases, + schedules, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; +import { eq, inArray } from "drizzle-orm"; +import type Stripe from "stripe"; +import { + getCustomerProductRows, + getRequiredScheduleId, +} from "../utils/createScheduleTestHelpers"; + +const latestStripeInvoice = async ({ + ctx, + customer, +}: { + ctx: Awaited>["ctx"]; + customer: ApiCustomerV3; +}) => { + const stripeId = customer.invoices?.[0]?.stripe_id; + if (!stripeId) throw new Error("Expected latest invoice to have stripe_id"); + + return await ctx.stripeCli.invoices.retrieve(stripeId, { + expand: ["lines.data.price"], + }); +}; + +const pendingStripeInvoiceItems = async ({ + ctx, + customer, +}: { + ctx: Awaited>["ctx"]; + customer: ApiCustomerV3; +}) => { + if (!customer.stripe_id) + throw new Error("Expected customer to have stripe_id"); + + return await ctx.stripeCli.invoiceItems.list({ + customer: customer.stripe_id, + pending: true, + limit: 100, + }); +}; + +const stripeInvoicesForCustomer = async ({ + ctx, + customer, +}: { + ctx: Awaited>["ctx"]; + customer: ApiCustomerV3; +}) => { + if (!customer.stripe_id) + throw new Error("Expected customer to have stripe_id"); + + const invoices = await ctx.stripeCli.invoices.list({ + customer: customer.stripe_id, + limit: 100, + }); + + return await Promise.all( + invoices.data.map((invoice) => + ctx.stripeCli.invoices.retrieve(invoice.id!, { + expand: ["lines.data.price"], + }), + ), + ); +}; + +const lineAmountDollars = (line: Stripe.InvoiceLineItem) => + new Decimal(line.amount).div(100); + +const invoiceLineTotal = (invoice: Stripe.Invoice) => + invoice.lines.data.reduce( + (total, line) => total.plus(lineAmountDollars(line)), + new Decimal(0), + ); + +const initialMonthlyPeriod = (invoice: Stripe.Invoice) => { + const monthlyLine = invoice.lines.data.find((line) => line.amount > 0); + if (!monthlyLine) throw new Error("Expected a positive monthly invoice line"); + + return { + start: monthlyLine.period.start * 1000, + end: monthlyLine.period.end * 1000, + }; +}; + +const expectedMonthlyProrationDiff = ({ + oldAmount, + newAmount, + transitionAt, + billingPeriod, +}: { + oldAmount: number; + newAmount: number; + transitionAt: number; + billingPeriod: { start: number; end: number }; +}) => + new Decimal( + applyProration({ + now: transitionAt, + billingPeriod, + amount: newAmount, + }), + ) + .minus( + applyProration({ + now: transitionAt, + billingPeriod, + amount: oldAmount, + }), + ) + .toDecimalPlaces(2) + .toNumber(); + +const expectStripeInvoiceWithTotal = ({ + invoices, + total, +}: { + invoices: Stripe.Invoice[]; + total: number; +}) => { + const invoice = invoices.find((candidate) => { + const candidateTotal = new Decimal(candidate.total).div(100); + return candidateTotal.minus(total).abs().lte(0.01); + }); + + expect(invoice, `Expected Stripe invoice total $${total}`).toBeDefined(); + return invoice!; +}; + +test.concurrent( + `${chalk.yellowBright("create-schedule: bills the first phase immediately and stores later phases as scheduled")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-basic", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [], + }); + + const now = Date.now(); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + phases: [ + { + starts_at: now + ms.days(30), + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now, + plans: [{ plan_id: pro.id }, { plan_id: addon.id }], + }, + ], + }; + + const response = await autumnV1.billing.createSchedule(params); + const scheduleId = getRequiredScheduleId(response.schedule_id); + + expect(response.customer_id).toBe(customerId); + expect(response.entity_id).toBeNull(); + expect(response.status).toBe("created"); + expect(response.payment_url).toBeNull(); + expect(response.invoice?.total).toBe(40); + expect(response.phases).toHaveLength(2); + expect(response.phases[0]!.starts_at).toBe(now); + expect(response.phases[0]!.customer_product_ids).toHaveLength(2); + expect(response.phases[1]!.starts_at).toBe(now + ms.days(30)); + expect(response.phases[1]!.customer_product_ids).toHaveLength(1); + + const dbSchedule = await ctx.db + .select() + .from(schedules) + .where(eq(schedules.id, scheduleId)); + expect(dbSchedule).toHaveLength(1); + + const dbPhases = await ctx.db + .select() + .from(schedulePhases) + .where(eq(schedulePhases.schedule_id, scheduleId)); + expect(dbPhases).toHaveLength(2); + + const immediatePhaseCustomerProducts = await ctx.db + .select() + .from(customerProducts) + .where( + inArray(customerProducts.id, response.phases[0]!.customer_product_ids), + ); + const phase1CustomerProducts = await ctx.db + .select() + .from(customerProducts) + .where( + inArray(customerProducts.id, response.phases[1]!.customer_product_ids), + ); + + expect(immediatePhaseCustomerProducts).toHaveLength(2); + expect( + immediatePhaseCustomerProducts.every( + (customerProduct) => customerProduct.status === CusProductStatus.Active, + ), + ).toBe(true); + expect(phase1CustomerProducts).toHaveLength(1); + expect( + phase1CustomerProducts.every( + (customerProduct) => + customerProduct.status === CusProductStatus.Scheduled, + ), + ).toBe(true); + expect( + immediatePhaseCustomerProducts.filter( + (customerProduct) => customerProduct.product_id === pro.id, + ), + ).toHaveLength(1); + expect( + immediatePhaseCustomerProducts.filter( + (customerProduct) => customerProduct.product_id === addon.id, + ), + ).toHaveLength(1); + expect(phase1CustomerProducts[0]!.product_id).toBe(pro.id); + + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 40, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: later-phase-only plans stay scheduled and never hit immediate billing")}`, + async () => { + const nowBase = products.pro({ + id: "create-schedule-now-base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const nowAddon = products.recurringAddOn({ + id: "create-schedule-now-addon", + items: [items.monthlyWords({ includedUsage: 50 })], + }); + const futureGroupB = products.base({ + id: "create-schedule-future-group-b", + items: [items.monthlyUsers({ includedUsage: 5 }), items.monthlyPrice()], + group: "group-b", + }); + const futureGroupC = products.base({ + id: "create-schedule-future-group-c", + items: [ + items.monthlyMessages({ includedUsage: 250 }), + items.monthlyPrice(), + ], + group: "group-c", + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-future-only-not-now", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ + list: [nowBase, nowAddon, futureGroupB, futureGroupC], + }), + ], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: nowBase.id }, { plan_id: nowAddon.id }], + }, + { + starts_at: now + ms.days(15), + plans: [{ plan_id: futureGroupB.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: futureGroupC.id }], + }, + ], + }); + + const productRows = await getCustomerProductRows({ + ctx, + customerId, + productIds: [nowBase.id, nowAddon.id, futureGroupB.id, futureGroupC.id], + }); + const activeRows = productRows + .filter((productRow) => productRow.status === CusProductStatus.Active) + .sort((a, b) => a.productId!.localeCompare(b.productId!)); + const scheduledRows = productRows + .filter((productRow) => productRow.status === CusProductStatus.Scheduled) + .sort((a, b) => a.productId!.localeCompare(b.productId!)); + + expect(activeRows).toEqual( + [ + { productId: nowBase.id, status: CusProductStatus.Active }, + { productId: nowAddon.id, status: CusProductStatus.Active }, + ].sort((a, b) => a.productId.localeCompare(b.productId)), + ); + expect(scheduledRows).toEqual( + [ + { productId: futureGroupB.id, status: CusProductStatus.Scheduled }, + { productId: futureGroupC.id, status: CusProductStatus.Scheduled }, + ].sort((a, b) => a.productId.localeCompare(b.productId)), + ); + + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestInvoiceProductIds: [nowBase.id, nowAddon.id], + }); + expect(customer.invoices?.[0]?.product_ids).not.toContain(futureGroupB.id); + expect(customer.invoices?.[0]?.product_ids).not.toContain(futureGroupC.id); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: now phase stays the exact active set across groups and future phases")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const usersItem = items.monthlyUsers({ includedUsage: 5 }); + const wordsItem = items.monthlyWords({ includedUsage: 25 }); + + const currentA = products.base({ + id: "create-schedule-exact-current-a", + items: [messagesItem, items.monthlyPrice({ price: 5 })], + }); + const keepNowB = products.base({ + id: "create-schedule-exact-keep-b", + items: [usersItem, items.monthlyPrice({ price: 5 })], + group: "group-b", + }); + const currentAddon = products.recurringAddOn({ + id: "create-schedule-exact-current-addon", + items: [wordsItem], + }); + const nowReplacementA = products.pro({ + id: "create-schedule-exact-now-a", + items: [messagesItem], + }); + const futureReplacementB = products.base({ + id: "create-schedule-exact-future-b", + items: [usersItem, items.monthlyPrice({ price: 15 })], + group: "group-b", + }); + const futureAddon = products.recurringAddOn({ + id: "create-schedule-exact-future-addon", + items: [wordsItem], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-exact-now-set", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ + list: [ + currentA, + keepNowB, + currentAddon, + nowReplacementA, + futureReplacementB, + futureAddon, + ], + }), + ], + actions: [ + s.billing.attach({ productId: currentA.id }), + s.billing.attach({ productId: keepNowB.id }), + s.billing.attach({ productId: currentAddon.id }), + ], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: nowReplacementA.id }, { plan_id: keepNowB.id }], + }, + { + starts_at: now + ms.days(15), + plans: [ + { plan_id: futureReplacementB.id }, + { plan_id: futureAddon.id }, + ], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: currentA.id }], + }, + ], + }); + + const productRows = await getCustomerProductRows({ + ctx, + customerId, + productIds: [ + currentA.id, + keepNowB.id, + currentAddon.id, + nowReplacementA.id, + futureReplacementB.id, + futureAddon.id, + ], + }); + const activeRows = productRows + .filter((productRow) => productRow.status === CusProductStatus.Active) + .sort((a, b) => a.productId!.localeCompare(b.productId!)); + const scheduledRows = productRows + .filter((productRow) => productRow.status === CusProductStatus.Scheduled) + .sort((a, b) => a.productId!.localeCompare(b.productId!)); + + expect(activeRows).toEqual( + [ + { productId: keepNowB.id, status: CusProductStatus.Active }, + { productId: nowReplacementA.id, status: CusProductStatus.Active }, + ].sort((a, b) => a.productId.localeCompare(b.productId)), + ); + expect(scheduledRows).toEqual( + [ + { productId: currentA.id, status: CusProductStatus.Scheduled }, + { productId: futureAddon.id, status: CusProductStatus.Scheduled }, + { + productId: futureReplacementB.id, + status: CusProductStatus.Scheduled, + }, + ].sort((a, b) => a.productId.localeCompare(b.productId)), + ); + + const customer = await autumnV1.customers.get(customerId); + expect(customer.invoices?.[0]?.product_ids).not.toContain( + futureReplacementB.id, + ); + expect(customer.invoices?.[0]?.product_ids).not.toContain(futureAddon.id); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: plans omitted from the next phase end at the phase boundary")}`, + async () => { + const nowBase = products.pro({ + id: "create-schedule-phase-end-now-base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const nowAddon = products.recurringAddOn({ + id: "create-schedule-phase-end-now-addon", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + const nextBase = products.premium({ + id: "create-schedule-phase-end-next-base", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const nextAddon = products.recurringAddOn({ + id: "create-schedule-phase-end-next-addon", + items: [items.monthlyWords({ includedUsage: 75 })], + }); + + const { customerId, autumnV1, ctx, testClockId, advancedTo } = + await initScenario({ + customerId: "create-schedule-phase-end-boundary", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [nowBase, nowAddon, nextBase, nextAddon] }), + ], + actions: [], + }); + + const now = advancedTo; + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: nowBase.id }, { plan_id: nowAddon.id }], + }, + { + starts_at: now + ms.days(15), + plans: [{ plan_id: nextBase.id }, { plan_id: nextAddon.id }], + }, + ], + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: now + ms.days(16), + waitForSeconds: 30, + }); + + const customer = await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer, + active: [nextAddon.id, nextBase.id], + notPresent: [nowAddon.id, nowBase.id], + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: phase transition invoices monthly upgrade proration immediately")}`, + async () => { + const pro = products.pro({ + id: "create-schedule-transition-invoice-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "create-schedule-transition-invoice-premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { customerId, autumnV1, ctx, testClockId, advancedTo } = + await initScenario({ + customerId: "create-schedule-transition-invoice", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const now = advancedTo; + const transitionAt = now + ms.days(15); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: transitionAt, + plans: [{ plan_id: premium.id }], + }, + ], + }); + + const initialCustomer = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: initialCustomer, + count: 1, + latestTotal: 20, + }); + + const initialInvoice = await latestStripeInvoice({ + ctx, + customer: initialCustomer, + }); + const billingPeriod = initialMonthlyPeriod(initialInvoice); + const expectedProration = expectedMonthlyProrationDiff({ + oldAmount: 20, + newAmount: 50, + transitionAt, + billingPeriod, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: transitionAt, + waitForSeconds: 30, + }); + + const customerAfterTransition = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterTransition, + active: [premium.id], + notPresent: [pro.id], + }); + await expectCustomerInvoiceCorrect({ + customer: customerAfterTransition, + count: 2, + latestTotal: expectedProration, + }); + const stripeInvoices = await stripeInvoicesForCustomer({ + ctx, + customer: customerAfterTransition, + }); + const transitionInvoice = expectStripeInvoiceWithTotal({ + invoices: stripeInvoices, + total: expectedProration, + }); + expect( + invoiceLineTotal(transitionInvoice).toDecimalPlaces(2).toNumber(), + ).toBe(expectedProration); + + const pendingItems = await pendingStripeInvoiceItems({ + ctx, + customer: customerAfterTransition, + }); + expect(pendingItems.data).toHaveLength(0); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: billingPeriod.end, + waitForSeconds: 30, + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterRenewal, + active: [premium.id], + notPresent: [pro.id], + }); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 3, + latestTotal: 50, + }); + }, +); diff --git a/server/tests/integration/billing/create-schedule/preview/create-schedule-preview.test.ts b/server/tests/integration/billing/create-schedule/preview/create-schedule-preview.test.ts new file mode 100644 index 000000000..38f69b45c --- /dev/null +++ b/server/tests/integration/billing/create-schedule/preview/create-schedule-preview.test.ts @@ -0,0 +1,957 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type AttachPreviewResponse, + applyProration, + BillingInterval, + BillingMethod, + type CreateScheduleParamsV0Input, + ms, + truncateMsToSecondPrecision, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { Decimal } from "decimal.js"; + +const previewCreateSchedule = async ({ + autumnV1, + params, +}: { + autumnV1: Awaited>["autumnV1"]; + params: CreateScheduleParamsV0Input; +}): Promise => + await autumnV1.post("/billing.preview_create_schedule", params); + +const sortNumbers = (values: number[]) => [...values].sort((a, b) => a - b); +const sortStrings = (values: string[]) => + [...values].sort((a, b) => a.localeCompare(b)); +const expectCloseToCents = ({ + actual, + expected, +}: { + actual: number; + expected: number; +}) => + expect( + Math.abs(actual - expected) < 0.01, + `expected ${actual} to be within 0.01 of ${expected}`, + ).toBe(true); + +const annualPrepaidWords = ({ amount }: { amount: number }) => { + const item = itemsV2.prepaidWords({ + amount, + billingUnits: 100, + included: 0, + }); + + return { + ...item, + price: { + ...item.price, + interval: BillingInterval.Year, + billing_method: BillingMethod.Prepaid, + }, + }; +}; + +const monthlyPrepaidMessages = ({ amount }: { amount: number }) => + itemsV2.prepaidMessages({ + amount, + billingUnits: 100, + included: 0, + }); + +const proratedDelta = ({ + oldAmount, + newAmount, + start, + end, + transitionAt, +}: { + oldAmount: number; + newAmount: number; + start: number; + end: number; + transitionAt: number; +}) => + new Decimal( + applyProration({ + now: transitionAt, + billingPeriod: { start, end }, + amount: newAmount, + }), + ) + .minus( + applyProration({ + now: transitionAt, + billingPeriod: { start, end }, + amount: oldAmount, + }), + ) + .toDecimalPlaces(2) + .toNumber(); + +const expectPreviewToMatchCreateSchedule = async ({ + autumnV1, + params, + expectedTotal, + expectedLineItemTotals, + assertPreview, +}: { + autumnV1: Awaited>["autumnV1"]; + params: CreateScheduleParamsV0Input; + expectedTotal?: number; + expectedLineItemTotals?: number[]; + assertPreview?: (preview: AttachPreviewResponse) => void; +}) => { + const preview = await previewCreateSchedule({ autumnV1, params }); + + if (expectedTotal !== undefined) { + expect(preview.total).toBe(expectedTotal); + expect(preview.subtotal).toBe(expectedTotal); + } + if (expectedLineItemTotals) { + expect( + sortNumbers(preview.line_items.map((lineItem) => lineItem.total)), + ).toEqual(sortNumbers(expectedLineItemTotals)); + } + expect( + preview.line_items.reduce((sum, lineItem) => sum + lineItem.total, 0), + ).toBe(preview.total); + + assertPreview?.(preview); + + const response = await autumnV1.billing.createSchedule(params); + + expect(response.status).toBe("created"); + expect(response.invoice?.total ?? 0).toBe(preview.total); +}; + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 1: immediate recurring plans match preview total")}`, + async () => { + const pro = products.pro({ + id: "preview-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const addon = products.recurringAddOn({ + id: "preview-addon", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-recurring", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: pro.id }, { plan_id: addon.id }], + }, + { + starts_at: advancedTo + ms.days(30), + plans: [{ plan_id: pro.id }], + }, + ], + }, + expectedTotal: 40, + expectedLineItemTotals: [20, 20], + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 2: prepaid feature quantities bill immediately")}`, + async () => { + const prepaid = products.base({ + id: "preview-prepaid", + items: [items.prepaidMessages()], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-prepaid", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prepaid] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [ + { + plan_id: prepaid.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 400, + }, + ], + }, + ], + }, + ], + }, + expectedTotal: 40, + expectedLineItemTotals: [40], + assertPreview: (preview) => { + expect(preview.line_items).toContainEqual( + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 400, + total: 40, + }), + ); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 3: customize.price overrides the template base price")}`, + async () => { + const base = products.base({ + id: "preview-custom-price", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-custom-price", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 35 }), + }, + }, + ], + }, + ], + }, + expectedTotal: 35, + expectedLineItemTotals: [35], + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 4: graduated prepaid tiers use the correct total")}`, + async () => { + const tiered = products.base({ + id: "preview-tiered-prepaid", + items: [items.tieredPrepaidMessages({ includedUsage: 0 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-tiered-prepaid", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [tiered] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [ + { + plan_id: tiered.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 700, + }, + ], + }, + ], + }, + ], + }, + expectedTotal: 60, + expectedLineItemTotals: [60], + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 5: volume prepaid tiers use the correct total")}`, + async () => { + const volume = products.base({ + id: "preview-volume-prepaid", + items: [items.volumePrepaidMessages({ includedUsage: 0 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-volume-prepaid", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [volume] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [ + { + plan_id: volume.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 700, + }, + ], + }, + ], + }, + ], + }, + expectedTotal: 35, + expectedLineItemTotals: [35], + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 6: usage-based features stay out of the immediate total")}`, + async () => { + const usagePlan = products.pro({ + id: "preview-usage-plan", + items: [items.consumableMessages({ includedUsage: 100, price: 0.5 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-usage-based", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [usagePlan] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: usagePlan.id }], + }, + ], + }, + expectedTotal: 20, + expectedLineItemTotals: [20], + assertPreview: (preview) => { + expect( + preview.line_items.every((lineItem) => lineItem.feature_id === null), + ).toBe(true); + expect(preview.next_cycle).toBeUndefined(); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 7: active upgrade preview matches the immediate replacement invoice")}`, + async () => { + const pro = products.pro({ + id: "preview-active-upgrade-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "preview-active-upgrade-premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-active-upgrade", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: premium.id }], + }, + ], + }, + assertPreview: (preview) => { + expect(preview.total).toBeGreaterThan(0); + expect(preview.total).toBeLessThan(50); + expect(preview.line_items.length).toBeGreaterThan(0); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 8: active downgrade preview matches the immediate replacement invoice")}`, + async () => { + const pro = products.pro({ + id: "preview-active-downgrade-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "preview-active-downgrade-premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-active-downgrade", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: premium.id })], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: pro.id }], + }, + ], + }, + assertPreview: (preview) => { + expect(preview.total).toBeLessThan(20); + expect(preview.line_items.length).toBeGreaterThan(0); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 9: mixed immediate phase only charges recurring and prepaid items")}`, + async () => { + const recurring = products.pro({ + id: "preview-mixed-recurring", + items: [items.monthlyMessages({ includedUsage: 100 })], + group: "preview-mixed-recurring", + }); + const prepaid = products.base({ + id: "preview-mixed-prepaid", + items: [items.prepaidUsers()], + group: "preview-mixed-prepaid", + }); + const usageBased = products.base({ + id: "preview-mixed-usage", + items: [items.consumableWords({ includedUsage: 100 })], + group: "preview-mixed-usage", + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-mixed", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [recurring, prepaid, usageBased] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [ + { plan_id: recurring.id }, + { + plan_id: prepaid.id, + feature_quantities: [ + { + feature_id: TestFeature.Users, + quantity: 4, + }, + ], + }, + { plan_id: usageBased.id }, + ], + }, + ], + }, + expectedTotal: 60, + expectedLineItemTotals: [20, 40], + assertPreview: (preview) => { + expect( + preview.line_items.some( + (lineItem) => lineItem.feature_id === TestFeature.Words, + ), + ).toBe(false); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 10: customize.items uses custom prepaid and one-off prices")}`, + async () => { + const base = products.base({ + id: "preview-custom-items-chargeable", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-custom-items-chargeable", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [ + { + plan_id: base.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 300, + }, + { + feature_id: TestFeature.Words, + quantity: 200, + }, + ], + customize: { + items: [ + itemsV2.prepaidMessages({ + amount: 12, + billingUnits: 100, + }), + { + feature_id: TestFeature.Words, + included: 0, + price: { + amount: 15, + interval: BillingInterval.OneOff, + billing_method: BillingMethod.Prepaid, + billing_units: 100, + }, + }, + { + feature_id: TestFeature.Users, + included: 0, + price: { + amount: 7, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + }, + ], + }, + }, + ], + }, + ], + }, + expectedTotal: 66, + expectedLineItemTotals: [0, 30, 36], + assertPreview: (preview) => { + expect( + sortStrings( + preview.line_items.map((lineItem) => lineItem.feature_id ?? "base"), + ), + ).toEqual( + sortStrings([ + TestFeature.Messages, + TestFeature.Users, + TestFeature.Words, + ]), + ); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 11: one-off plan charges now and has no next cycle")}`, + async () => { + const oneOff = products.base({ + id: "preview-one-off-base", + items: [items.oneOffPrice({ price: 50 }), items.monthlyMessages()], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-one-off", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOff] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: oneOff.id }], + }, + ], + }, + expectedTotal: 50, + expectedLineItemTotals: [50], + assertPreview: (preview) => { + expect(preview.next_cycle).toBeUndefined(); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 12: prepaid quantities only charge for units above included usage")}`, + async () => { + const prepaid = products.base({ + id: "preview-prepaid-included-usage", + items: [items.prepaidMessages({ includedUsage: 200 })], + group: "preview-prepaid-included-usage", + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-prepaid-included-usage", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prepaid] }), + ], + actions: [], + }); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [ + { + plan_id: prepaid.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 200, + }, + ], + }, + ], + }, + ], + }, + expectedTotal: 0, + assertPreview: (preview) => { + expect( + preview.line_items.every((lineItem) => lineItem.total === 0), + ).toBe(true); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 13: active schedules can defer a future replacement without charging now")}`, + async () => { + const pro = products.pro({ + id: "preview-future-replacement-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "preview-future-replacement-premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-future-replacement", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + const transitionAt = truncateMsToSecondPrecision(advancedTo + ms.days(15)); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: transitionAt, + plans: [{ plan_id: premium.id }], + }, + ], + }, + expectedTotal: 0, + assertPreview: (preview) => { + expect(preview.line_items).toHaveLength(0); + expect(preview.next_cycle).toBeDefined(); + expect(preview.next_cycle?.starts_at).toBe(transitionAt); + + const renewalAt = addMonths(advancedTo, 1).getTime(); + const expectedTotal = proratedDelta({ + oldAmount: 20, + newAmount: 50, + start: advancedTo, + end: renewalAt, + transitionAt, + }); + expectCloseToCents({ + actual: preview.next_cycle?.total ?? 0, + expected: expectedTotal, + }); + expect( + preview.next_cycle?.line_items.some((line) => line.total > 0), + ).toBe(true); + expect( + preview.next_cycle?.line_items.some((line) => line.total < 0), + ).toBe(true); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 14: phase boundary prorates annual and monthly items independently")}`, + async () => { + const group = "preview-mixed-interval-phase"; + const phase1 = products.base({ + id: "preview-mixed-interval-phase-1", + group, + items: [items.monthlyMessages({ includedUsage: 1 })], + }); + const phase2 = products.base({ + id: "preview-mixed-interval-phase-2", + group, + items: [items.monthlyWords({ includedUsage: 1 })], + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-mixed-interval-phase", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [phase1, phase2] }), + ], + actions: [], + }); + + const transitionAt = truncateMsToSecondPrecision(advancedTo + ms.days(15)); + const annualEnd = addMonths(advancedTo, 12).getTime(); + const monthlyEnd = addMonths(advancedTo, 1).getTime(); + const expectedNextCycleTotal = new Decimal( + proratedDelta({ + oldAmount: 120, + newAmount: 240, + start: advancedTo, + end: annualEnd, + transitionAt, + }), + ) + .plus( + proratedDelta({ + oldAmount: 10, + newAmount: 20, + start: advancedTo, + end: monthlyEnd, + transitionAt, + }), + ) + .toDecimalPlaces(2) + .toNumber(); + + await expectPreviewToMatchCreateSchedule({ + autumnV1, + params: { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [ + { + plan_id: phase1.id, + customize: { + items: [ + annualPrepaidWords({ amount: 120 }), + monthlyPrepaidMessages({ amount: 10 }), + ], + }, + feature_quantities: [ + { feature_id: TestFeature.Words, quantity: 100 }, + { feature_id: TestFeature.Messages, quantity: 100 }, + ], + }, + ], + }, + { + starts_at: transitionAt, + plans: [ + { + plan_id: phase2.id, + customize: { + items: [ + annualPrepaidWords({ amount: 240 }), + monthlyPrepaidMessages({ amount: 20 }), + ], + }, + feature_quantities: [ + { feature_id: TestFeature.Words, quantity: 100 }, + { feature_id: TestFeature.Messages, quantity: 100 }, + ], + }, + ], + }, + ], + }, + expectedTotal: 130, + expectedLineItemTotals: [10, 120], + assertPreview: (preview) => { + expect(preview.next_cycle?.starts_at).toBe(transitionAt); + expectCloseToCents({ + actual: preview.next_cycle?.total ?? 0, + expected: expectedNextCycleTotal, + }); + expect(preview.next_cycle?.line_items).toHaveLength(4); + }, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule preview 15: invoice excludes unrelated pending Stripe invoice items")}`, + async () => { + const pro = products.pro({ + id: "preview-pending-items-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "preview-pending-items-premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { customerId, autumnV1, ctx, advancedTo } = await initScenario({ + customerId: "create-schedule-preview-pending-items", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const customer = await autumnV1.customers.get(customerId); + const stripeSubscriptions = await ctx.stripeCli.subscriptions.list({ + customer: customer.stripe_id!, + limit: 1, + }); + const stripeSubscription = stripeSubscriptions.data[0]; + expect(stripeSubscription).toBeDefined(); + + await ctx.stripeCli.invoiceItems.create({ + customer: customer.stripe_id!, + subscription: stripeSubscription.id, + amount: 12345, + currency: "usd", + description: "Unrelated pending Stripe invoice item", + }); + + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + invoice_mode: { + enabled: true, + finalize: false, + enable_plan_immediately: true, + }, + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: premium.id }], + }, + ], + }; + const preview = await previewCreateSchedule({ autumnV1, params }); + const response = await autumnV1.billing.createSchedule(params); + + expect(response.status).toBe("created"); + expect(response.invoice?.total).toBe(preview.total); + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + response.invoice!.stripe_id!, + { expand: ["lines"] }, + ); + expect( + stripeInvoice.lines.data.some( + (line) => line.description === "Unrelated pending Stripe invoice item", + ), + ).toBe(false); + }, +); diff --git a/server/tests/integration/billing/create-schedule/utils/expectCreateScheduleBackdateCorrect.ts b/server/tests/integration/billing/create-schedule/utils/expectCreateScheduleBackdateCorrect.ts new file mode 100644 index 000000000..c1d07281f --- /dev/null +++ b/server/tests/integration/billing/create-schedule/utils/expectCreateScheduleBackdateCorrect.ts @@ -0,0 +1,118 @@ +import { expect } from "bun:test"; +import { + type CreateScheduleResponse, + type CusProductStatus, + customerProducts, + ms, +} from "@autumn/shared"; +import { expectBackdatedStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectBackdatedStripeSubscriptionCorrect"; +import type { initScenario } from "@tests/utils/testInitUtils/initScenario"; +import { inArray } from "drizzle-orm"; + +type Ctx = Awaited>["ctx"]; + +type ExpectedPhaseProduct = { + productId: string; + status: CusProductStatus; + startsAt: number; + entityId?: string | null; +}; + +export const expectCreateScheduleBackdateCorrect = async ({ + ctx, + response, + immediate, + scheduled = [], + minInvoiceTotal = 2000, + minInvoiceLineCount, +}: { + ctx: Ctx; + response: CreateScheduleResponse; + immediate: ExpectedPhaseProduct | ExpectedPhaseProduct[]; + scheduled?: ExpectedPhaseProduct[]; + minInvoiceTotal?: number; + minInvoiceLineCount?: number; +}) => { + const immediateProducts = Array.isArray(immediate) ? immediate : [immediate]; + const primaryImmediateProduct = immediateProducts[0]!; + + expect(response.status).toBe("created"); + expect(response.invoice?.stripe_id).toBeDefined(); + expect(response.invoice?.total).toBeGreaterThan(minInvoiceTotal / 100); + expect(response.phases).toHaveLength(1 + scheduled.length); + expect(response.phases[0]!.starts_at).toBe(primaryImmediateProduct.startsAt); + + for (let i = 0; i < scheduled.length; i++) { + expect(response.phases[i + 1]!.starts_at).toBe(scheduled[i]!.startsAt); + } + + const customerProductIds = response.phases.flatMap( + (phase) => phase.customer_product_ids, + ); + const rows = await ctx.db + .select() + .from(customerProducts) + .where(inArray(customerProducts.id, customerProductIds)); + + const immediateRows = immediateProducts.map((expectedImmediateProduct) => { + const row = rows.find( + (row) => + row.product_id === expectedImmediateProduct.productId && + (expectedImmediateProduct.entityId === undefined || + row.entity_id === expectedImmediateProduct.entityId), + ); + + expect(row).toMatchObject({ + status: expectedImmediateProduct.status, + starts_at: expectedImmediateProduct.startsAt, + }); + expect(row?.subscription_ids).toHaveLength(1); + + return row!; + }); + + for (const expectedScheduledProduct of scheduled) { + const scheduledRow = rows.find( + (row) => + row.product_id === expectedScheduledProduct.productId && + (expectedScheduledProduct.entityId === undefined || + row.entity_id === expectedScheduledProduct.entityId), + ); + + expect(scheduledRow).toMatchObject({ + status: expectedScheduledProduct.status, + }); + expect( + Math.abs( + (scheduledRow?.starts_at ?? 0) - expectedScheduledProduct.startsAt, + ), + ).toBeLessThan(ms.seconds(2)); + expect(scheduledRow?.scheduled_ids ?? []).toHaveLength(1); + } + + const immediateRow = immediateRows[0]!; + const stripeSubscriptionId = immediateRow.subscription_ids![0]!; + for (const row of immediateRows) { + expect(row.subscription_ids?.[0]).toBe(stripeSubscriptionId); + } + + const { stripeSchedule } = await expectBackdatedStripeSubscriptionCorrect({ + ctx, + stripeSubscriptionId, + startsAt: primaryImmediateProduct.startsAt, + stripeInvoiceId: response.invoice!.stripe_id, + minInvoiceTotal, + minInvoiceLineCount, + expandSchedule: scheduled.length > 0, + }); + + if (scheduled.length > 0) { + expect(immediateRow?.scheduled_ids ?? []).toHaveLength(1); + const autumnScheduleId = immediateRow?.scheduled_ids?.[0]; + expect(autumnScheduleId).toBeDefined(); + expect(stripeSchedule?.id).toBe(autumnScheduleId!); + expect(stripeSchedule?.phases.length ?? 0).toBeGreaterThan(1); + } + + return { rows, immediateRow, immediateRows, stripeSchedule }; +}; diff --git a/server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts b/server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts new file mode 100644 index 000000000..54c7b08a2 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts @@ -0,0 +1,290 @@ +/** + * Invoice-Matched Proration Credits — Additional Coverage + * + * 1. amount-off coupon cancel: refund based on discounted invoice + * 2. cancel after partial refund (upgrade then cancel): second refund nets the first + * 3. create-schedule with discount: immediate phase credit from stored charge + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, AttachPreviewResponse } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductNotPresent, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { createAmountCoupon } from "../utils/discounts/discountTestUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Amount-off coupon cancel — refund based on discounted invoice ($15) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits additional 1: amount-off coupon cancel — refund based on discounted invoice")}`, + async () => { + const customerId = "imc-add-amtoff-cancel"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, testClockId, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 500, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 15, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 15, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 15, + }); + + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBeLessThan(0); + expect(Math.abs(preview.total)).toBeLessThan(10); + expect(Math.abs(preview.total)).toBeGreaterThan(5); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 3, + latestTotal: preview.total, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Cancel after partial refund (upgrade then cancel) — second refund nets the first +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits additional 2: cancel after upgrade — second refund nets the first")}`, + async () => { + const customerId = "imc-add-upg-then-cancel"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ toNextInvoice: true }), + s.advanceTestClock({ days: 10 }), + ], + }); + + const upgradeResult = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(upgradeResult.invoice).toBeDefined(); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterUpgrade = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterUpgrade, + productId: `premium_${customerId}`, + }); + + const cancelParams = { + customer_id: customerId, + product_id: `premium_${customerId}`, + cancel_action: "cancel_immediately" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBeLessThan(0); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: `premium_${customerId}`, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Scheduled upgrade with discount — immediate phase credit from stored charge +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits additional 3: scheduled upgrade with discount — credit reflects discounted charge")}`, + async () => { + const customerId = "imc-add-sched-disc"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 400, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 16, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 16, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-16); + + for (const creditLine of creditLines) { + const discounts = creditLine.discounts ?? []; + expect(discounts.length).toBe(0); + } + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/cancel.test.ts b/server/tests/integration/billing/invoice-matched-credits/cancel.test.ts new file mode 100644 index 000000000..8741c18c9 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/cancel.test.ts @@ -0,0 +1,279 @@ +/** + * Invoice-Matched Proration Credits — Cancel Tests + * + * Verifies that cancellation credits and refunds source amounts from stored + * invoice line items rather than catalog prices. + * + * - cancel_immediately with discount: credit reflects discounted charge ($16) + * - cancel_end_of_cycle: no immediate credit line items + * - refund_last_payment prorated with discount: refund based on discounted invoice + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductNotPresent, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Cancel immediately prorated with discount — credit from stored charge +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits cancel 1: cancel immediately with discount — credit reflects stored charge")}`, + async () => { + const customerId = "imc-cancel-imm-disc"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 16, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 16, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 15, + }); + + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBeLessThan(0); + expect(preview.total).toBeGreaterThan(-16); + expect(preview.total).toBeLessThanOrEqual(-7); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 3, + latestTotal: preview.total, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Cancel end_of_cycle — no immediate credit lines +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits cancel 2: cancel end_of_cycle — no immediate credit line items")}`, + async () => { + const customerId = "imc-cancel-eoc"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ days: 10 }), + ], + }); + + const customerBeforeCancel = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerBeforeCancel, + productId: pro.id, + }); + + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_end_of_cycle" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBe(0); + + const creditLines = preview.line_items.filter((li: { total: number }) => li.total < 0); + expect(creditLines.length).toBe(0); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 1, + latestTotal: 20, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Cancel discounted plan refund_last_payment prorated — refund based on discounted invoice +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits cancel 3: cancel with refund_last_payment prorated — refund reflects discounted invoice")}`, + async () => { + const customerId = "imc-cancel-refund-disc"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 16, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 15, + }); + + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately" as const, + refund_last_payment: "prorated" as const, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + + expect(preview.total).toBe(0); + expect(preview.refund).toBeDefined(); + + const refundAmount = preview.refund!.amount; + expect(refundAmount).toBeGreaterThan(0); + expect(refundAmount).toBeLessThanOrEqual(16); + expect(refundAmount).toBeGreaterThanOrEqual(7); + + expect(preview.refund!.invoice.total).toBe(16); + + await autumnV1.subscriptions.update(cancelParams); + + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfterCancel, + productId: pro.id, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 2, + }); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts b/server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts new file mode 100644 index 000000000..271e77ceb --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts @@ -0,0 +1,194 @@ +import { expect, test } from "bun:test"; +import type { AttachPreviewResponse } from "@autumn/shared"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits create-schedule: immediate phase pro + addon — credits from stored discounted charges")}`, + async () => { + const customerId = "imc-sched-disc-addon"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, addon, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + const scheduleResponse = await autumnV1.billing.createSchedule( + { + customer_id: customerId, + discounts: [{ reward_id: coupon.id }], + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: pro.id }, { plan_id: addon.id }], + }, + ], + }, + { timeout: 8000 }, + ); + + expect(scheduleResponse.status).toBe("created"); + expect(scheduleResponse.phases[0]!.customer_product_ids).toHaveLength(2); + expect(scheduleResponse.invoice?.total).toBeLessThan(40); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: premium.id, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(Math.abs(creditTotal)).toBeLessThan(40); + + for (const creditLine of creditLines) { + expect(creditLine.discounts ?? []).toHaveLength(0); + } + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits create-schedule: multi-phase setup then mid-cycle upgrade — preview matches invoice")}`, + async () => { + const customerId = "imc-sched-multiphase"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, addon, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + const phase2StartsAt = addMonths(advancedTo, 2).getTime(); + + await autumnV1.billing.createSchedule( + { + customer_id: customerId, + discounts: [{ reward_id: coupon.id }], + phases: [ + { + starts_at: advancedTo, + plans: [{ plan_id: pro.id }, { plan_id: addon.id }], + }, + { + starts_at: phase2StartsAt, + plans: [{ plan_id: pro.id }], + }, + ], + }, + { timeout: 8000 }, + ); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: premium.id, + })) as AttachPreviewResponse; + + expect(preview.total).toBeDefined(); + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts b/server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts new file mode 100644 index 000000000..0cc9b1352 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts @@ -0,0 +1,282 @@ +import { expect, test } from "bun:test"; +import type { AttachPreviewResponse } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits discount 1: catalog fallback when no stored row exists")}`, + async () => { + const customerId = "imc-disc-fallback"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + expect(preview.line_items.length).toBeGreaterThan(0); + expect(preview.total).toBeDefined(); + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThanOrEqual(-20); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits discount 2: discounted quantity decrease — refund based on stored discounted charge")}`, + async () => { + const customerId = "imc-disc-qty-dec"; + + const billingUnits = 100; + const pricePerPack = 10; + + const prepaidMessages = items.prepaidMessages({ + includedUsage: 0, + billingUnits, + price: pricePerPack, + }); + + const product = products.pro({ + id: "prepaid-disc", + items: [prepaidMessages], + }); + + const initialQuantity = 500; + const decreasedQuantity = 200; + + const { autumnV1, testClockId, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `prepaid-disc_${customerId}`, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: `prepaid-disc_${customerId}`, + options: [ + { + feature_id: TestFeature.Messages, + quantity: decreasedQuantity, + }, + ], + }); + + expect(preview.total).toBeLessThan(0); + + const fullPriceRefundBound = + -((initialQuantity - decreasedQuantity) / billingUnits) * pricePerPack; + expect(preview.total).toBeGreaterThan(fullPriceRefundBound); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits discount 3: trial sibling with discount — credit reflects discounted charge")}`, + async () => { + const customerId = "imc-disc-trial-sib"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [items.monthlyMessages({ includedUsage: 1000 })], + trialDays: 14, + cardRequired: false, + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premiumTrial] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium-trial_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-20); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `premium-trial_${customerId}`, + }); + + await new Promise((resolve) => setTimeout(resolve, 4000)); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits discount 4: discounted credit magnitude bounded by stored charge")}`, + async () => { + const customerId = "imc-disc-no-double"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-16.01); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts b/server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts new file mode 100644 index 000000000..0f22785ef --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts @@ -0,0 +1,171 @@ +/** + * Invoice-Matched Proration Credits — Downgrade Tests + * + * Verifies that scheduled downgrade previews source outgoing credits from + * stored invoice line items (actual charged amounts) rather than catalog prices. + * + * - With discount: outgoing credit reflects the discounted charge ($40, not $50) + * - Without discount: outgoing credit reflects the full catalog charge ($50) + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Scheduled downgrade with discount — outgoing credit reflects discounted price +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits downgrade 1: scheduled downgrade with discount — next_cycle outgoing credit reflects discounted price")}`, + async () => { + const customerId = "imc-down-disc"; + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 40, + }); + + const renewedAt = await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + currentEpochMs: advancedTo, + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 40, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewedAt), + numberOfDays: 5, + }); + + const preview = await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `pro_${customerId}`, + }); + + expect(preview.total).toBe(0); + + const nextCycle = expectPreviewNextCycleCorrect({ + preview, + expectDefined: true, + })!; + + expect(nextCycle.total).toBeLessThan(50); + expect(nextCycle.total).toBeGreaterThan(0); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Scheduled downgrade without discount — outgoing credit reflects full price +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits downgrade 2: scheduled downgrade without discount — next_cycle outgoing credit reflects full price")}`, + async () => { + const customerId = "imc-down-full"; + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + ], + actions: [ + s.billing.attach({ productId: premium.id }), + s.advanceToNextInvoice(), + s.advanceTestClock({ days: 5 }), + ], + }); + + const customerAfterRenewal = + await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfterRenewal, + count: 2, + latestTotal: 50, + }); + + const preview = await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `pro_${customerId}`, + }); + + expect(preview.total).toBe(0); + + const nextCycle = expectPreviewNextCycleCorrect({ + preview, + expectDefined: true, + })!; + + expect(nextCycle.total).toBeLessThan(50); + expect(nextCycle.total).toBeGreaterThan(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts b/server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts new file mode 100644 index 000000000..5c2f18af3 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts @@ -0,0 +1,281 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Catalog fallback when no stored row exists +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched edge 1: catalog fallback when no stored row exists")}`, + async () => { + const customerId = "inv-match-edge-fallback"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const upgradeResult = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + expect(upgradeResult).toBeDefined(); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Multi-attach with outgoing credit +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched edge 2: multi-attach with outgoing credit from stored charge")}`, + async () => { + const customerId = "inv-match-edge-multi"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium, addon] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ months: 1 }), + s.advanceTestClock({ days: 15 }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + const invoiceCountBefore = customerBefore.invoices?.length ?? 0; + + const preview = await autumnV1.billing.previewMultiAttach({ + customer_id: customerId, + plans: [{ plan_id: premium.id }, { plan_id: addon.id }], + }); + + expect(preview.total).toBeDefined(); + expect(preview.outgoing.length).toBeGreaterThanOrEqual(1); + + const outgoingPro = preview.outgoing.find((c: { plan_id: string }) => c.plan_id === pro.id); + expect(outgoingPro).toBeDefined(); + + await autumnV1.billing.multiAttach({ + customer_id: customerId, + plans: [{ plan_id: premium.id }, { plan_id: addon.id }], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id, addon.id], + notPresent: [pro.id], + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: 200, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: invoiceCountBefore + 1, + }); + + const latestInvoice = customer.invoices?.[0]; + expect(latestInvoice).toBeDefined(); + expect(latestInvoice!.total).toBeDefined(); + + expect(latestInvoice!.total).toBeLessThan(70); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: No-op re-attach — filterUnchangedPrices cancels +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched edge 3: no-op re-attach — filterUnchangedPrices cancels")}`, + async () => { + const customerId = "inv-match-edge-noop"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ months: 1 }), + ], + }); + + await expectProductActive({ + customer: await autumnV1.customers.get(customerId), + productId: pro.id, + }); + + let threw = false; + try { + await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: pro.id, + }); + } catch (err: any) { + threw = true; + expect(err.code).toBe("plan_already_attached"); + } + expect(threw).toBe(true); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Preview/execute rounding parity on upgrade +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched edge 4: preview/execute rounding parity on mid-cycle upgrade")}`, + async () => { + const customerId = "inv-match-edge-rounding"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ months: 1 }), + s.advanceTestClock({ days: 15 }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + const invoiceCountBefore = customerBefore.invoices?.length ?? 0; + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + + expect(preview.total).toBeDefined(); + expect(preview.total).toBeGreaterThan(0); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [pro.id], + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: invoiceCountBefore + 1, + }); + + const latestInvoice = customer.invoices?.[0]; + expect(latestInvoice).toBeDefined(); + expect(latestInvoice!.total).toBeCloseTo(preview.total, 0); + + const diff = Math.abs(latestInvoice!.total - preview.total); + expect(diff).toBeLessThanOrEqual(0.01); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/entities.test.ts b/server/tests/integration/billing/invoice-matched-credits/entities.test.ts new file mode 100644 index 000000000..7f8d7a501 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/entities.test.ts @@ -0,0 +1,203 @@ +import { expect, test } from "bun:test"; +import type { AttachPreviewResponse } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits entities 1: single entity upgrade — credit from stored charge")}`, + async () => { + const customerId = "imc-ent-single-upg"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, entities, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + ], + }); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + entity_id: entities[0].id, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-20); + + expect(preview.total).toBeDefined(); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits entities 2: entity with discount — credit reflects discounted amount")}`, + async () => { + const customerId = "imc-ent-disc-upg"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, entities, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + entity_id: entities[0].id, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + entity_id: entities[0].id, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-16.01); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits entities 3: entity add mid-cycle — no credit for new entity")}`, + async () => { + const customerId = "imc-ent-add-midcycle"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, entities, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + ], + }); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const result = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + entity_id: entities[1].id, + }); + + const creditLines = (result.invoice?.line_items ?? []).filter( + (li: { total: number }) => li.total < 0, + ); + expect(creditLines.length).toBe(0); + + expect(result.invoice?.total).toBeGreaterThanOrEqual(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts b/server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts new file mode 100644 index 000000000..56c6c09e3 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits multi-attach 1: discounted outgoing — credit reflects stored charge")}`, + async () => { + const customerId = "imc-multi-disc-out"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1, testClockId, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium, addon] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = await autumnV1.billing.previewMultiAttach({ + customer_id: customerId, + plans: [{ plan_id: `premium_${customerId}` }, { plan_id: `addon_${customerId}` }], + }); + + expect(preview.total).toBeDefined(); + expect(preview.outgoing.length).toBeGreaterThanOrEqual(1); + + const outgoingPro = preview.outgoing.find( + (c: { plan_id: string }) => c.plan_id === `pro_${customerId}`, + ); + expect(outgoingPro).toBeDefined(); + + const catalogTotal = 50 + 20; + expect(preview.total).toBeLessThan(catalogTotal); + + await autumnV1.billing.multiAttach({ + customer_id: customerId, + plans: [{ plan_id: `premium_${customerId}` }, { plan_id: `addon_${customerId}` }], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer, + active: [`premium_${customerId}`, `addon_${customerId}`], + notPresent: [`pro_${customerId}`], + }); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits multi-attach 2: add-only — no credit lines")}`, + async () => { + const customerId = "imc-multi-add-only"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `addon_${customerId}`, + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer, + productId: `pro_${customerId}`, + }); + + await expectProductActive({ + customer, + productId: `addon_${customerId}`, + }); + + const latestInvoice = customer.invoices?.[0]; + expect(latestInvoice).toBeDefined(); + expect(latestInvoice!.total).toBeGreaterThanOrEqual(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/quantity.test.ts b/server/tests/integration/billing/invoice-matched-credits/quantity.test.ts new file mode 100644 index 000000000..305dcd757 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/quantity.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +const BILLING_UNITS = 12; +const PRICE_PER_UNIT = 8; + +test.concurrent( + `${chalk.yellowBright("invoice-matched qty 1: prepaid quantity decrease — credit from stored charge")}`, + async () => { + const customerId = "inv-match-qty-decrease"; + + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }), + ], + }); + + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 20 * BILLING_UNITS }, + ], + }), + ], + }); + + // Advance a full cycle (clean renewal charge stored) then mid-cycle. + let advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId as string, + numberOfMonths: 1, + waitForSeconds: 30, + }); + advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId as string, + startingFrom: new Date(advancedTo), + numberOfDays: 15, + waitForSeconds: 20, + }); + + const customerBefore = await autumnV1.customers.get( + customerId, + ); + const invoiceCountBefore = customerBefore.invoices?.length ?? 0; + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: 5 * BILLING_UNITS }], + }); + + // Decreasing units mid-cycle yields a prorated credit. + expect(preview.total).toBeLessThan(0); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: 5 * BILLING_UNITS }], + }); + + const customer = await autumnV1.customers.get(customerId); + + expect(customer.features?.[TestFeature.Messages]?.balance).toBe( + 5 * BILLING_UNITS, + ); + + // Preview must match what was actually invoiced (credit sourced from the + // stored renewal charge, not catalog re-synthesis). + expect(customer.invoices?.length ?? 0).toBe(invoiceCountBefore + 1); + const latestInvoice = customer.invoices?.[0]; + expect(latestInvoice).toBeDefined(); + expect(Math.abs(latestInvoice!.total - preview.total)).toBeLessThanOrEqual( + 0.01, + ); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched qty 2: prepaid decrease after mid-cycle increase — nets across stored charges")}`, + async () => { + const customerId = "inv-match-qty-netting"; + + const product = products.base({ + id: "prepaid", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }), + ], + }); + + const { autumnV1, testClockId, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * BILLING_UNITS }, + ], + }), + ], + }); + + // Renew so there is a full-period stored charge for the current cycle. + let advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId as string, + numberOfMonths: 1, + waitForSeconds: 30, + }); + + // Mid-cycle increase: creates a SECOND stored charge row for this price. + advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId as string, + startingFrom: new Date(advancedTo), + numberOfDays: 10, + waitForSeconds: 20, + }); + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 20 * BILLING_UNITS }, + ], + }); + + advancedTo = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId as string, + startingFrom: new Date(advancedTo), + numberOfDays: 10, + waitForSeconds: 20, + }); + + const customerBefore = await autumnV1.customers.get( + customerId, + ); + const invoiceCountBefore = customerBefore.invoices?.length ?? 0; + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: 5 * BILLING_UNITS }], + }); + + expect(preview.total).toBeLessThan(0); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: 5 * BILLING_UNITS }], + }); + + const customer = await autumnV1.customers.get(customerId); + + expect(customer.features?.[TestFeature.Messages]?.balance).toBe( + 5 * BILLING_UNITS, + ); + + // The credit must net both stored charge rows (renewal + mid-cycle increase); + // with single-row crediting this preview/execute parity would break. + expect(customer.invoices?.length ?? 0).toBe(invoiceCountBefore + 1); + const latestInvoice = customer.invoices?.[0]; + expect(latestInvoice).toBeDefined(); + expect(Math.abs(latestInvoice!.total - preview.total)).toBeLessThanOrEqual( + 0.01, + ); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/trials.test.ts b/server/tests/integration/billing/invoice-matched-credits/trials.test.ts new file mode 100644 index 000000000..dfcf81f32 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/trials.test.ts @@ -0,0 +1,252 @@ +/** + * Invoice-Matched Proration Credits — Trial Tests + * + * Verifies correct credit behavior when trials interact with the + * invoice-matched credit system: + * + * - Upgrade during trial: no credit (no stored charge for a $0 trial) + * - Paid product switched to trial sibling: paid product credited from stored charge + * - End trial: no refund-direction line items emitted + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Upgrade during trial — no credit (trial product has no stored charge) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits trial 1: upgrade during trial — no credit for outgoing trial product")}`, + async () => { + const customerId = "imc-trial-upgrade"; + + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [items.monthlyMessages({ includedUsage: 500 })], + trialDays: 14, + cardRequired: false, + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, premium] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + const customerTrialing = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerTrialing, + active: [proTrial.id], + }); + + await expectCustomerInvoiceCorrect({ + customer: customerTrialing, + count: 1, + latestTotal: 0, + }); + + const preview = await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(preview.total).toBe(50); + + const creditLines = preview.line_items.filter((li: { total: number }) => li.total < 0); + expect(creditLines.length).toBe(0); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + const customerAfterUpgrade = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterUpgrade, + active: [premium.id], + notPresent: [proTrial.id], + }); + + await expectProductNotTrialing({ + customer: customerAfterUpgrade, + productId: premium.id, + nowMs: advancedTo, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterUpgrade, + count: 2, + latestTotal: 50, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Paid product switched to trial sibling — sibling credited from stored charge +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits trial 2: paid product switched to trial — credit from stored charge")}`, + async () => { + const customerId = "imc-trial-sibling"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premiumTrial = products.premiumWithTrial({ + id: "premium-trial", + items: [items.monthlyMessages({ includedUsage: 1000 })], + trialDays: 14, + cardRequired: true, + }); + + const { autumnV1, autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premiumTrial] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 20, + }); + + const preview = await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium-trial_${customerId}`, + }); + + expect(preview.total).toBe(-20); + + const creditLines = preview.line_items.filter((li: { total: number }) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum: number, li: { total: number }) => sum + li.total, 0); + expect(creditTotal).toBe(-20); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + }); + + await new Promise((resolve) => setTimeout(resolve, 4000)); + + const customerAfterSwitch = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterSwitch, + active: [premiumTrial.id], + notPresent: [pro.id], + }); + + await expectProductTrialing({ + customer: customerAfterSwitch, + productId: premiumTrial.id, + trialEndsAt: advancedTo + 14 * 24 * 60 * 60 * 1000, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: End trial — no refund lines emitted +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits trial 3: end trial — no refund-direction line items")}`, + async () => { + const customerId = "imc-trial-end"; + + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [items.monthlyMessages({ includedUsage: 500 })], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + const customerTrialing = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerTrialing, + productId: proTrial.id, + trialEndsAt: advancedTo + 7 * 24 * 60 * 60 * 1000, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerTrialing, + count: 1, + latestTotal: 0, + }); + + const previewBeforeTrialEnd = await autumnV2_2.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: proTrial.id, + recalculate_balances: { enabled: true }, + }); + + const refundLines = previewBeforeTrialEnd.line_items.filter( + (li: { total: number }) => li.total < 0, + ); + expect(refundLines.length).toBe(0); + + const nextCyclePreview = expectPreviewNextCycleCorrect({ + preview: previewBeforeTrialEnd, + expectDefined: true, + })!; + + const nextCycleRefundLines = nextCyclePreview.line_items.filter( + (li) => li.total < 0, + ); + expect(nextCycleRefundLines.length).toBe(0); + + expect(nextCyclePreview.total).toBeGreaterThanOrEqual(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts b/server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts new file mode 100644 index 000000000..e7f645049 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts @@ -0,0 +1,355 @@ +import { expect, test } from "bun:test"; +import type { AttachPreviewResponse } from "@autumn/shared"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { + createAmountCoupon, + createPercentCoupon, +} from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 1: percent-off forever discount — credit reflects discounted price")}`, + async () => { + const customerId = "inv-cred-upg-pct"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeCloseTo(-8, 0); + + for (const creditLine of creditLines) { + const discounts = creditLine.discounts ?? []; + expect(discounts.length).toBe(0); + } + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 2: no discount — credit reflects full price")}`, + async () => { + const customerId = "inv-cred-upg-full"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ toNextInvoice: true }), + s.advanceTestClock({ days: 15 }), + ], + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-20); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 3: amount-off coupon — credit reflects discounted price")}`, + async () => { + const customerId = "inv-cred-upg-amt"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createAmountCoupon({ + stripeCli, + amountOffCents: 500, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const renewalTime = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + startingFrom: new Date(renewalTime), + numberOfDays: 15, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-15); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 4: at cycle start — full credit equals full charged amount")}`, + async () => { + const customerId = "inv-cred-upg-start"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: `pro_${customerId}`, + discounts: [{ reward_id: coupon.id }], + }); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(advancedTo), 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 30, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = preview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0); + expect(creditTotal).toBeLessThan(0); + expect(creditTotal).toBeGreaterThan(-20.01); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(Math.abs((result.invoice?.total ?? 0) - preview.total)).toBeLessThan( + 2, + ); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 5: upgrade twice in one period — second upgrade nets prior refund")}`, + async () => { + const customerId = "inv-cred-upg-twice"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const growth = products.growth({ + id: "growth", + items: [items.monthlyMessages({ includedUsage: 2000 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium, growth] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ toNextInvoice: true }), + s.advanceTestClock({ days: 10 }), + ], + }); + + const firstUpgradeResult = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(firstUpgradeResult.invoice).toBeDefined(); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const secondPreview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `growth_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = secondPreview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const positiveLines = secondPreview.line_items.filter((li) => li.total > 0); + expect(positiveLines.length).toBeGreaterThan(0); + + const secondResult = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `growth_${customerId}`, + }); + + expect(secondResult.invoice?.total).toBeCloseTo(secondPreview.total, 0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/migrations-v2/controls/cancel/migration-cancel-batch.test.ts b/server/tests/integration/billing/migrations-v2/controls/cancel/migration-cancel-batch.test.ts new file mode 100644 index 000000000..59ea8e3a8 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/controls/cancel/migration-cancel-batch.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test"; +import { MigrationRunStatus } from "@autumn/shared"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { + migrationItemRunRepo, + migrationRunRepo, +} from "@/internal/migrations/v2/repos/index.js"; +import { waitForMigrationResult } from "../../utils/runUpdatePlanMigration.js"; + +const CUSTOMER_COUNT = 10; + +test.concurrent( + `${chalk.yellowBright("migration cancel (batch): in-flight item finishes, remaining items skipped, run canceled")}`, + async () => { + /** + * Contract under test: + * New behaviors: + * - Cancelling a running batch migration lets the in-flight item + * finish (>=1 succeeded) but skips the rest (no claim, no row, none + * cut off mid-migration), so total processed < CUSTOMER_COUNT. + * - The run settles to `canceled` (not `succeeded`). + * Side effects: + * - No migration_item_runs row ends up `failed`. + */ + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const customerIds = Array.from( + { length: CUSTOMER_COUNT }, + (_, i) => `cancel-batch-${i}-${suffix}`, + ); + const plan = products.base({ + id: `cancel-batch-plan-${suffix}`, + items: [], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId: customerIds[0], + setup: [ + s.customer({ testClock: false }), + s.otherCustomers(customerIds.slice(1).map((id) => ({ id }))), + s.products({ list: [plan] }), + ], + actions: [ + s.parallel( + ...customerIds.map((id) => + id === customerIds[0] + ? s.billing.attach({ productId: plan.id }) + : s.billing.attach({ customerId: id, productId: plan.id }), + ), + ), + ], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `cancel-batch-mig-${suffix}`, + filter: { customer: { plan: { plan_id: plan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + no_billing_changes: true, + }); + + const runResponse = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: false, + concurrency: 1, + }); + + // Wait until the batch has started (>=1 claimed item), then cancel ASAP so + // the remaining items hit the gate before they are claimed. + await waitForMigrationResult({ + timeoutMs: 30_000, + pollIntervalMs: 150, + waitFor: async () => { + const counts = await migrationItemRunRepo.getCounts({ + ctx, + migrationInternalId: migration.internal_id, + dryRun: false, + migrationRunId: runResponse.run_id, + }); + expect(counts.total).toBeGreaterThanOrEqual(1); + }, + }); + + const cancel = await autumnV2_2.migrationsV2.cancelRun({ id: migration.id }); + expect(cancel.canceled).toBe(true); + + await waitForMigrationResult({ + timeoutMs: 60_000, + pollIntervalMs: 500, + waitFor: async () => { + const [run] = await migrationRunRepo.list({ + ctx, + internalId: runResponse.run_id, + }); + if (!run) throw new Error("Run not found"); + if (run.status !== MigrationRunStatus.Canceled) + throw new Error(`Run still ${run.status}`); + }, + }); + + const [run] = await migrationRunRepo.list({ + ctx, + internalId: runResponse.run_id, + }); + expect(run.status).toBe(MigrationRunStatus.Canceled); + expect(run.error_message).toBe("Canceled by user"); + + const counts = await migrationItemRunRepo.getCounts({ + ctx, + migrationInternalId: migration.internal_id, + dryRun: false, + migrationRunId: runResponse.run_id, + }); + + // In-flight item(s) finished, the rest were skipped before claiming. + expect(counts.succeeded).toBeGreaterThanOrEqual(1); + expect(counts.total).toBeLessThan(CUSTOMER_COUNT); + // Nothing cut off mid-migration. + expect(counts.failed).toBe(0); + }, +); diff --git a/server/tests/integration/billing/migrations-v2/controls/cancel/migration-cancel-lazy.test.ts b/server/tests/integration/billing/migrations-v2/controls/cancel/migration-cancel-lazy.test.ts new file mode 100644 index 000000000..ca42feb04 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/controls/cancel/migration-cancel-lazy.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV5, MigrationRunStatus } from "@autumn/shared"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { migrationRunRepo } from "@/internal/migrations/v2/repos/index.js"; +import { + countCustomerItemRunRows, + getCustomerAndAwaitMigration, + getInternalCustomerId, + startLazyMigration, +} from "../../lazy/utils/lazyMigrationTestUtils.js"; + +const timeout = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const uniqueSuffix = () => + `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +test.concurrent( + `${chalk.yellowBright("migration cancel (lazy): no further per-customer migrations run after cancel")}`, + async () => { + /** + * Contract under test: + * New endpoint: + * - POST /migrations.cancel_run sets a cancel token and (for lazy + * runs) marks the run `canceled` + clears the org cache. + * New behaviors: + * - Before cancel, fetching a matching customer lazily migrates them + * (positive control). + * - After cancel, fetching another matching customer does NOT migrate + * them and creates NO migration_item_runs row (enqueue + task gates, + * and the dropped `pendingMigrations` entry). + * Side effects: + * - The migration_runs row settles to `canceled`. + */ + const suffix = uniqueSuffix(); + const customerA = `cancel-lazy-a-${suffix}`; + const customerB = `cancel-lazy-b-${suffix}`; + const plan = products.base({ + id: `cancel-lazy-plan-${suffix}`, + items: [], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId: customerA, + setup: [ + s.customer(), + s.otherCustomers([{ id: customerB }]), + s.products({ list: [plan] }), + ], + actions: [ + s.parallel( + s.billing.attach({ productId: plan.id }), + s.billing.attach({ customerId: customerB, productId: plan.id }), + ), + ], + }); + + const { migration, run_id } = await startLazyMigration({ + autumnV2_2, + ctx, + id: `cancel-lazy-mig-${suffix}`, + planId: plan.id, + }); + + // Positive control: fetching A lazily migrates it. + const custA = await getCustomerAndAwaitMigration({ + autumnV2_2, + customerId: customerA, + }); + expectFlagCorrect({ + customer: custA, + featureId: TestFeature.Dashboard, + present: true, + }); + + const cancel = await autumnV2_2.migrationsV2.cancelRun({ id: migration.id }); + expect(cancel.canceled).toBe(true); + expect(cancel.run_id).toBe(run_id); + + const [run] = await migrationRunRepo.list({ ctx, internalId: run_id }); + expect(run).toBeDefined(); + expect(run.status).toBe(MigrationRunStatus.Canceled); + + // After cancel, repeatedly fetch B — each fetch is a chance for the lazy + // path to (incorrectly) enqueue a migration. It must not. + for (let i = 0; i < 4; i++) { + await autumnV2_2.customers.get(customerB); + await timeout(1_000); + } + + const custB = await autumnV2_2.customers.get(customerB); + expectFlagCorrect({ + customer: custB, + featureId: TestFeature.Dashboard, + present: false, + }); + + const internalB = await getInternalCustomerId({ + customerId: customerB, + ctx, + }); + const rowsB = await countCustomerItemRunRows({ + ctx, + migration, + internalCustomerId: internalB, + }); + expect(rowsB).toBe(0); + }, +); diff --git a/server/tests/integration/billing/migrations-v2/idempotency/migration-idempotency.test.ts b/server/tests/integration/billing/migrations-v2/controls/idempotency/migration-idempotency.test.ts similarity index 95% rename from server/tests/integration/billing/migrations-v2/idempotency/migration-idempotency.test.ts rename to server/tests/integration/billing/migrations-v2/controls/idempotency/migration-idempotency.test.ts index 42aed5e48..f0bc855d7 100644 --- a/server/tests/integration/billing/migrations-v2/idempotency/migration-idempotency.test.ts +++ b/server/tests/integration/billing/migrations-v2/controls/idempotency/migration-idempotency.test.ts @@ -17,7 +17,7 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { CusService } from "@/internal/customers/CusService.js"; import { migrationItemRunRepo } from "@/internal/migrations/v2/repos/index.js"; -import { waitForMigrationResult } from "../utils/runUpdatePlanMigration.js"; +import { waitForMigrationResult } from "../../utils/runUpdatePlanMigration.js"; const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -105,10 +105,12 @@ const waitForMigrationRunAccepted = async ({ autumnV2_2, id, dryRun = false, + retryItemStatuses, }: { autumnV2_2: Awaited>["autumnV2_2"]; id: string; dryRun?: boolean; + retryItemStatuses?: ("failed" | "skipped")[]; }) => waitForMigrationResult({ timeoutMs: 60_000, @@ -117,6 +119,7 @@ const waitForMigrationRunAccepted = async ({ autumnV2_2.migrationsV2.run({ id, dry_run: dryRun, + retry_item_statuses: retryItemStatuses, }), }); @@ -255,7 +258,7 @@ test(`${chalk.yellowBright("migrations idempotency: run API skips running and fa }); }); -test(`${chalk.yellowBright("migrations idempotency: retry_failed and dry_run are honored through run API")}`, async () => { +test(`${chalk.yellowBright("migrations idempotency: retry_item_statuses and dry_run are honored through run API")}`, async () => { const retryCustomerId = "migration-idem-retry"; const dryRunCustomerId = "migration-idem-dry-run"; const retryPlan = products.pro({ id: "retry-pro", items: [] }); @@ -290,34 +293,34 @@ test(`${chalk.yellowBright("migrations idempotency: retry_failed and dry_run are planId: retryPlan.id, }), ); - const retryableMigration = await autumnV2_2.migrationsV2.update({ - id: retryMigration.id, - updates: { retry_failed: true }, - }); await migrationItemRunRepo.claim({ ctx, - migrationInternalId: retryableMigration.internal_id, + migrationInternalId: retryMigration.internal_id, itemKind: MigrationItemKind.Customer, itemId: retryInternalCustomerId, claimBehavior: "claim_new", }); await migrationItemRunRepo.markFailed({ ctx, - migrationInternalId: retryableMigration.internal_id, + migrationInternalId: retryMigration.internal_id, itemKind: MigrationItemKind.Customer, itemId: retryInternalCustomerId, }); - await waitForMigrationRunAccepted({ autumnV2_2, id: retryableMigration.id }); + await waitForMigrationRunAccepted({ + autumnV2_2, + id: retryMigration.id, + retryItemStatuses: [MigrationItemRunStatus.Failed], + }); await waitForCustomerItemRunStatus({ ctx, - migration: retryableMigration, + migration: retryMigration, internalCustomerId: retryInternalCustomerId, status: MigrationItemRunStatus.Succeeded, }); expect( await getCustomerItemRun({ ctx, - migration: retryableMigration, + migration: retryMigration, internalCustomerId: retryInternalCustomerId, }), ).toMatchObject({ status: MigrationItemRunStatus.Succeeded }); diff --git a/server/tests/integration/billing/migrations-v2/controls/run-scoping/migration-run-scoping.test.ts b/server/tests/integration/billing/migrations-v2/controls/run-scoping/migration-run-scoping.test.ts new file mode 100644 index 000000000..bdbdebb7d --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/controls/run-scoping/migration-run-scoping.test.ts @@ -0,0 +1,482 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + MigrationItemKind, + MigrationItemRunStatus, +} from "@autumn/shared"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; +import { + migrationItemRunRepo, + migrationRunRepo, +} from "@/internal/migrations/v2/repos/index.js"; +import { waitForMigrationResult } from "../../utils/runUpdatePlanMigration.js"; + +const timeout = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const uniqueSuffix = () => + `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const getInternalCustomerId = async ({ + customerId, + ctx, +}: { + customerId: string; + ctx: Awaited>["ctx"]; +}) => { + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + if (!customer) throw new Error(`Expected customer ${customerId}`); + return customer.internal_id; +}; + +const waitForRunCompleted = async ({ + ctx, + runId, +}: { + ctx: Awaited>["ctx"]; + runId: string; +}) => + waitForMigrationResult({ + timeoutMs: 60_000, + pollIntervalMs: 1_000, + waitFor: async () => { + const [run] = await migrationRunRepo.list({ + ctx, + internalId: runId, + }); + if (!run) throw new Error("Run not found"); + if (run.status !== "succeeded" && run.status !== "failed") + throw new Error(`Run still ${run.status}`); + }, + }); + +test.concurrent( + `${chalk.yellowBright("migration run scoping: only persists target_customer_ids on run record")}`, + async () => { + const suffix = uniqueSuffix(); + const firstId = `run-scope-only-first-${suffix}`; + const secondId = `run-scope-only-second-${suffix}`; + const plan = products.base({ + id: `run-scope-only-plan-${suffix}`, + items: [], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId: firstId, + setup: [ + s.customer(), + s.otherCustomers([{ id: secondId }]), + s.products({ list: [plan] }), + ], + actions: [ + s.parallel( + s.billing.attach({ productId: plan.id }), + s.billing.attach({ customerId: secondId, productId: plan.id }), + ), + ], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `run-scope-only-mig-${suffix}`, + filter: { customer: { plan: { plan_id: plan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + }); + + const runResponse = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: true, + only: [firstId], + }); + + await waitForRunCompleted({ ctx, runId: runResponse.run_id }); + + const [run] = await migrationRunRepo.list({ + ctx, + internalId: runResponse.run_id, + }); + expect(run).toBeDefined(); + expect(run.only_ids).toEqual([firstId]); + expect(run.target_limit).toBeNull(); + expect(run.dry_run).toBe(true); + + const firstInternalId = await getInternalCustomerId({ + customerId: firstId, + ctx, + }); + const secondInternalId = await getInternalCustomerId({ + customerId: secondId, + ctx, + }); + + const firstItemRun = await migrationItemRunRepo.getCustomer({ + ctx, + migrationInternalId: migration.internal_id, + internalCustomerId: firstInternalId, + dryRun: true, + migrationRunId: runResponse.run_id, + }); + expect(firstItemRun).toMatchObject({ + status: MigrationItemRunStatus.Succeeded, + }); + + const secondItemRun = await migrationItemRunRepo.getCustomer({ + ctx, + migrationInternalId: migration.internal_id, + internalCustomerId: secondInternalId, + dryRun: true, + migrationRunId: runResponse.run_id, + }); + expect(secondItemRun).toBeNull(); + }, +); + +test.concurrent( + `${chalk.yellowBright("migration run scoping: retry_item_statuses reruns failed customer rows")}`, + async () => { + const suffix = uniqueSuffix(); + const customerId = `run-scope-retry-only-${suffix}`; + const plan = products.base({ + id: `run-scope-retry-only-plan-${suffix}`, + items: [], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [plan] })], + actions: [s.billing.attach({ productId: plan.id })], + }); + const internalCustomerId = await getInternalCustomerId({ customerId, ctx }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `run-scope-retry-only-mig-${suffix}`, + filter: { customer: { plan: { plan_id: plan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + }); + + await migrationItemRunRepo.claim({ + ctx, + migrationInternalId: migration.internal_id, + itemKind: MigrationItemKind.Customer, + itemId: internalCustomerId, + claimBehavior: "claim_new", + }); + await migrationItemRunRepo.markFailed({ + ctx, + migrationInternalId: migration.internal_id, + itemKind: MigrationItemKind.Customer, + itemId: internalCustomerId, + }); + + const runResponse = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: false, + only: [customerId], + retry_item_statuses: [MigrationItemRunStatus.Failed], + }); + + await waitForRunCompleted({ ctx, runId: runResponse.run_id }); + const itemRun = await migrationItemRunRepo.getCustomer({ + ctx, + migrationInternalId: migration.internal_id, + internalCustomerId, + }); + expect(itemRun).toMatchObject({ status: MigrationItemRunStatus.Succeeded }); + }, +); + +test.concurrent( + `${chalk.yellowBright("migration run scoping: retry_item_statuses reruns skipped customer rows")}`, + async () => { + /** + * Contract under test: + * New request field: + * - retry_item_statuses?: ("failed" | "skipped")[] on migrations.run. + * New behaviors: + * - A normal rerun continues to checkpoint-exclude skipped item rows. + * - retry_item_statuses: ["skipped"] reselects and reclaims skipped rows. + * Side effects: + * - The reclaimed migration_item_runs row finishes succeeded on the new run. + */ + const suffix = uniqueSuffix(); + const customerId = `run-scope-retry-skipped-${suffix}`; + const attachedPlan = products.base({ + id: `run-scope-retry-skipped-attached-${suffix}`, + items: [], + }); + const unmatchedPlan = products.base({ + id: `run-scope-retry-skipped-unmatched-${suffix}`, + items: [], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer(), + s.products({ list: [attachedPlan, unmatchedPlan] }), + ], + actions: [s.billing.attach({ productId: attachedPlan.id })], + }); + const internalCustomerId = await getInternalCustomerId({ customerId, ctx }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `run-scope-retry-skipped-mig-${suffix}`, + filter: { customer: { plan: { plan_id: attachedPlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: unmatchedPlan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + }); + + const skippedRun = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: false, + }); + await waitForRunCompleted({ ctx, runId: skippedRun.run_id }); + + const skippedItemRun = await migrationItemRunRepo.getCustomer({ + ctx, + migrationInternalId: migration.internal_id, + internalCustomerId, + }); + expect(skippedItemRun).toMatchObject({ + status: MigrationItemRunStatus.Skipped, + migration_run_id: skippedRun.run_id, + }); + + await autumnV2_2.migrationsV2.update({ + id: migration.id, + updates: { + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: attachedPlan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + }, + }); + + const excludedRun = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: false, + }); + await waitForRunCompleted({ ctx, runId: excludedRun.run_id }); + + const stillSkippedItemRun = await migrationItemRunRepo.getCustomer({ + ctx, + migrationInternalId: migration.internal_id, + internalCustomerId, + }); + expect(stillSkippedItemRun).toMatchObject({ + status: MigrationItemRunStatus.Skipped, + migration_run_id: skippedRun.run_id, + }); + + const retryRun = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: false, + retry_item_statuses: [MigrationItemRunStatus.Skipped], + }); + await waitForRunCompleted({ ctx, runId: retryRun.run_id }); + + const retriedItemRun = await migrationItemRunRepo.getCustomer({ + ctx, + migrationInternalId: migration.internal_id, + internalCustomerId, + }); + expect(retriedItemRun).toMatchObject({ + status: MigrationItemRunStatus.Succeeded, + migration_run_id: retryRun.run_id, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + present: true, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("migration run scoping: limit caps live lazy sample runs")}`, + async () => { + /** + * TDD regression for sample-by-count live runs. + * + * Red-failure mode: + * - migrations.run({ limit, lazy_run: true }) persists target_limit but + * still claims every matching customer in migration_item_runs. + * + * Green-success criteria: + * - The run record keeps target_limit, and the current run only creates + * item-run rows for the requested limit. + */ + const suffix = uniqueSuffix(); + const customerIds = Array.from( + { length: 5 }, + (_, i) => `run-scope-limit-${i}-${suffix}`, + ); + const plan = products.base({ + id: `run-scope-limit-plan-${suffix}`, + items: [], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId: customerIds[0], + setup: [ + s.customer({ testClock: false }), + s.otherCustomers( + customerIds.slice(1).map((id) => ({ + id, + distinctTestClock: true, + })), + ), + s.products({ list: [plan] }), + ], + actions: [ + s.parallel( + ...customerIds.map((id) => + id === customerIds[0] + ? s.billing.attach({ productId: plan.id }) + : s.billing.attach({ customerId: id, productId: plan.id }), + ), + ), + ], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `run-scope-limit-mig-${suffix}`, + filter: { customer: { plan: { plan_id: plan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + }); + + const runResponse = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: false, + limit: 2, + lazy_run: true, + }); + + await waitForMigrationResult({ + timeoutMs: 60_000, + pollIntervalMs: 1_000, + waitFor: async () => { + const counts = await migrationItemRunRepo.getCounts({ + ctx, + migrationInternalId: migration.internal_id, + dryRun: false, + migrationRunId: runResponse.run_id, + }); + expect(counts.total).toBe(2); + }, + }); + await timeout(3_000); + + const [run] = await migrationRunRepo.list({ + ctx, + internalId: runResponse.run_id, + }); + expect(run).toBeDefined(); + expect(run.only_ids).toBeNull(); + expect(run.target_limit).toBe(2); + expect(run.lazy_run).toBe(true); + + const counts = await migrationItemRunRepo.getCounts({ + ctx, + migrationInternalId: migration.internal_id, + dryRun: false, + migrationRunId: runResponse.run_id, + }); + expect(counts.total).toBe(2); + }, +); + +test.concurrent( + `${chalk.yellowBright("migration run scoping: full run has null target fields")}`, + async () => { + const suffix = uniqueSuffix(); + const customerId = `run-scope-full-${suffix}`; + const plan = products.base({ + id: `run-scope-full-plan-${suffix}`, + items: [], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [plan] })], + actions: [s.billing.attach({ productId: plan.id })], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `run-scope-full-mig-${suffix}`, + filter: { customer: { plan: { plan_id: plan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { add_items: [itemsV2.dashboard()] }, + }, + ], + }, + }); + + const runResponse = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: false, + }); + + await waitForRunCompleted({ ctx, runId: runResponse.run_id }); + + const [run] = await migrationRunRepo.list({ + ctx, + internalId: runResponse.run_id, + }); + expect(run).toBeDefined(); + expect(run.only_ids).toBeNull(); + expect(run.target_limit).toBeNull(); + }, +); diff --git a/server/tests/integration/billing/migrations-v2/filter-planner/customer-filter-planner-parity.test.ts b/server/tests/integration/billing/migrations-v2/filter-planner/customer-filter-planner-parity.test.ts new file mode 100644 index 000000000..f7e0a2ac1 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/filter-planner/customer-filter-planner-parity.test.ts @@ -0,0 +1,813 @@ +/** + * TDD coverage for migration filter planning preserving customer selection. + * + * Red-failure mode (pre-planner guardrail): + * - An optimized access path could return a narrower customer set than the + * existing fallback compiler once wrapper filters are applied. + * + * Green-success criteria: + * - Planned and fallback SQL return the same customers, and migration + * wrappers (processed rows, checkpointing, search, cursoring) preserve + * their existing semantics. + */ + +import { expect, test } from "bun:test"; +import { + CusProductStatus, + customerProducts, + customers, + MigrationItemKind, + MigrationItemRunStatus, + migrationItemRuns, + migrations, + products as productsTable, + type CustomerFilter, +} from "@autumn/shared"; +import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js"; +import chalk from "chalk"; +import { sql, type SQL } from "drizzle-orm"; +import { + buildCustomerCount, + buildCustomerSelect, + buildProcessedPreviewCount, + buildProcessedPreviewSelect, + type CustomerQueryArgs, + type IncludeProcessed, +} from "@/internal/migrations/v2/filters/customers/buildCustomerSelect.js"; +import { getCustomerPage } from "@/internal/migrations/v2/filters/customers/filterCustomers.js"; +import { rawWithParamsToDrizzle } from "@/internal/migrations/v2/filters/rawWithParamsToDrizzle.js"; +import { initScenario } from "@tests/utils/testInitUtils/initScenario.js"; + +const CREATED_AT = 1_780_000_000_000; +const sorted = (values: string[]) => [...values].sort(); + +type TestCtx = Awaited>["ctx"]; +type TestDb = TestCtx["db"]; + +type SeededFixture = { + ctx: TestCtx; + prefix: string; + migrationInternalId: string; + migrationRunId: string; + otherDryRunId: string; + customerIds: { + active: string; + scheduled: string; + pastDue: string; + duplicateProducts: string; + expired: string; + pro: string; + otherEnv: string; + }; + args: CustomerQueryArgs; +}; + +const executeCustomerIds = async ({ + db, + query, +}: { + db: TestDb; + query: SQL; +}) => { + const rows = (await db.execute(query)) as Array<{ id: string }>; + return rows.map((row) => row.id); +}; + +const executeCount = async ({ db, query }: { db: TestDb; query: SQL }) => { + const [{ count }] = (await db.execute(query)) as Array<{ + count: bigint | number; + }>; + return Number(count); +}; + +const cleanupSeededRows = async ({ + db, + prefix, +}: { + db: TestDb; + prefix: string; +}) => { + const pattern = `${prefix}-%`; + await db.execute( + sql`DELETE FROM migration_item_runs WHERE migration_internal_id LIKE ${pattern}`, + ); + await db.execute(sql`DELETE FROM migrations WHERE internal_id LIKE ${pattern}`); + await db.execute(sql`DELETE FROM customer_products WHERE id LIKE ${pattern}`); + await db.execute(sql`DELETE FROM customers WHERE internal_id LIKE ${pattern}`); + await db.execute(sql`DELETE FROM products WHERE internal_id LIKE ${pattern}`); +}; + +const buildFallbackCustomerSelect = ({ + orgId, + env, + filter, + ctx, +}: CustomerQueryArgs): SQL => { + const where = rawWithParamsToDrizzle( + compileFilter({ filter, ctx, ambient: { orgId, env } }), + ); + return sql` + SELECT c.internal_id, c.id, c.name, c.email + FROM customers c + WHERE (${where}) + ORDER BY c.internal_id DESC + `; +}; + +const seedPlannerFixture = async (prefix: string): Promise => { + const targetPlanId = `${prefix}-enterprise`; + const otherPlanId = `${prefix}-pro`; + const otherEnv = "live"; + const { ctx } = await initScenario({ setup: [], actions: [] }); + + const customerIds = { + active: `${prefix}-active`, + scheduled: `${prefix}-scheduled`, + pastDue: `${prefix}-past-due`, + duplicateProducts: `${prefix}-duplicate-products`, + expired: `${prefix}-expired`, + pro: `${prefix}-pro`, + otherEnv: `${prefix}-other-env`, + }; + + await cleanupSeededRows({ db: ctx.db, prefix }); + await ctx.db.insert(productsTable).values([ + { + internal_id: `${prefix}-prod-enterprise-v1`, + id: targetPlanId, + name: "Enterprise v1", + org_id: ctx.org.id, + env: ctx.env, + created_at: CREATED_AT, + version: 1, + }, + { + internal_id: `${prefix}-prod-enterprise-v2`, + id: targetPlanId, + name: "Enterprise v2", + org_id: ctx.org.id, + env: ctx.env, + created_at: CREATED_AT, + version: 2, + }, + { + internal_id: `${prefix}-prod-pro`, + id: otherPlanId, + name: "Pro", + org_id: ctx.org.id, + env: ctx.env, + created_at: CREATED_AT, + version: 1, + }, + { + internal_id: `${prefix}-prod-enterprise-other-env`, + id: targetPlanId, + name: "Enterprise other env", + org_id: ctx.org.id, + env: otherEnv, + created_at: CREATED_AT, + version: 1, + }, + ]); + await ctx.db.insert(customers).values([ + { + internal_id: `${prefix}-cus-active`, + id: customerIds.active, + name: "Alpha Active Enterprise", + email: `${prefix}-active@example.com`, + org_id: ctx.org.id, + env: ctx.env, + created_at: CREATED_AT, + }, + { + internal_id: `${prefix}-cus-scheduled`, + id: customerIds.scheduled, + name: "Bravo Scheduled Enterprise", + email: `${prefix}-scheduled@example.com`, + org_id: ctx.org.id, + env: ctx.env, + created_at: CREATED_AT, + }, + { + internal_id: `${prefix}-cus-past-due`, + id: customerIds.pastDue, + name: "Charlie Past Due Enterprise", + email: `${prefix}-past-due@example.com`, + org_id: ctx.org.id, + env: ctx.env, + created_at: CREATED_AT, + }, + { + internal_id: `${prefix}-cus-duplicate-products`, + id: customerIds.duplicateProducts, + name: "Delta Duplicate Enterprise Products", + email: `${prefix}-duplicate-products@example.com`, + org_id: ctx.org.id, + env: ctx.env, + created_at: CREATED_AT, + }, + { + internal_id: `${prefix}-cus-expired`, + id: customerIds.expired, + name: "Echo Expired Enterprise", + email: `${prefix}-expired@example.com`, + org_id: ctx.org.id, + env: ctx.env, + created_at: CREATED_AT, + }, + { + internal_id: `${prefix}-cus-pro`, + id: customerIds.pro, + name: "Foxtrot Pro", + email: `${prefix}-pro@example.com`, + org_id: ctx.org.id, + env: ctx.env, + created_at: CREATED_AT, + }, + { + internal_id: `${prefix}-cus-other-env`, + id: customerIds.otherEnv, + name: "Golf Other Env Enterprise", + email: `${prefix}-other-env@example.com`, + org_id: ctx.org.id, + env: otherEnv, + created_at: CREATED_AT, + }, + ]); + await ctx.db.insert(customerProducts).values([ + { + id: `${prefix}-cp-active`, + internal_customer_id: `${prefix}-cus-active`, + internal_product_id: `${prefix}-prod-enterprise-v1`, + product_id: targetPlanId, + status: CusProductStatus.Active, + }, + { + id: `${prefix}-cp-scheduled`, + internal_customer_id: `${prefix}-cus-scheduled`, + internal_product_id: `${prefix}-prod-enterprise-v1`, + product_id: targetPlanId, + status: CusProductStatus.Scheduled, + }, + { + id: `${prefix}-cp-past-due`, + internal_customer_id: `${prefix}-cus-past-due`, + internal_product_id: `${prefix}-prod-enterprise-v1`, + product_id: targetPlanId, + status: CusProductStatus.PastDue, + }, + { + id: `${prefix}-cp-duplicate-v1`, + internal_customer_id: `${prefix}-cus-duplicate-products`, + internal_product_id: `${prefix}-prod-enterprise-v1`, + product_id: targetPlanId, + status: CusProductStatus.Active, + }, + { + id: `${prefix}-cp-duplicate-v2`, + internal_customer_id: `${prefix}-cus-duplicate-products`, + internal_product_id: `${prefix}-prod-enterprise-v2`, + product_id: targetPlanId, + status: CusProductStatus.Scheduled, + }, + { + id: `${prefix}-cp-expired`, + internal_customer_id: `${prefix}-cus-expired`, + internal_product_id: `${prefix}-prod-enterprise-v1`, + product_id: targetPlanId, + status: CusProductStatus.Expired, + }, + { + id: `${prefix}-cp-pro`, + internal_customer_id: `${prefix}-cus-pro`, + internal_product_id: `${prefix}-prod-pro`, + product_id: otherPlanId, + status: CusProductStatus.Active, + }, + { + id: `${prefix}-cp-other-env`, + internal_customer_id: `${prefix}-cus-other-env`, + internal_product_id: `${prefix}-prod-enterprise-other-env`, + product_id: targetPlanId, + status: CusProductStatus.Active, + }, + ]); + + const migrationInternalId = `${prefix}-migration`; + const migrationRunId = `${prefix}-run`; + await ctx.db.insert(migrations).values({ + internal_id: migrationInternalId, + id: `${prefix}-migration`, + org_id: ctx.org.id, + env: ctx.env, + filter: { customer: { plan: { plan_id: targetPlanId } } }, + created_at: CREATED_AT, + }); + + return { + ctx, + prefix, + migrationInternalId, + migrationRunId, + otherDryRunId: `${prefix}-other-dry-run`, + customerIds, + args: { + orgId: ctx.org.id, + env: ctx.env, + filter: { plan: { plan_id: targetPlanId } }, + ctx: { features: ctx.features }, + }, + }; +}; + +const withSeededFixture = async ( + prefix: string, + run: (fixture: SeededFixture) => Promise, +) => { + const fixture = await seedPlannerFixture(prefix); + try { + await run(fixture); + } finally { + await cleanupSeededRows({ db: fixture.ctx.db, prefix }); + } +}; + +const insertItemRun = async ({ + db, + migrationInternalId, + migrationRunId, + itemId, + status, + dryRun = false, +}: { + db: TestDb; + migrationInternalId: string; + migrationRunId: string; + itemId: string; + status: MigrationItemRunStatus; + dryRun?: boolean; +}) => { + await db.insert(migrationItemRuns).values({ + migration_item_run_id: `${migrationInternalId}-${migrationRunId}-${itemId}-${status}-${dryRun ? "dry" : "live"}`, + migration_internal_id: migrationInternalId, + migration_run_id: migrationRunId, + dry_run: dryRun, + item_kind: MigrationItemKind.Customer, + item_id: itemId, + status, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }); +}; + +const includeProcessed = ( + fixture: SeededFixture, + executionFilter?: IncludeProcessed["executionFilter"], +): IncludeProcessed => ({ + migrationInternalId: fixture.migrationInternalId, + executionFilter, +}); + +test(`${chalk.yellowBright("migration filter planner: plan_id access path matches fallback customer set")}`, async () => { + await withSeededFixture("planner-parity-base", async (fixture) => { + const plannedIds = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildCustomerSelect(fixture.args), + }); + const fallbackIds = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildFallbackCustomerSelect(fixture.args), + }); + + expect(sorted(plannedIds)).toEqual(sorted(fallbackIds)); + expect(sorted(plannedIds)).toEqual( + sorted([ + fixture.customerIds.active, + fixture.customerIds.scheduled, + fixture.customerIds.pastDue, + fixture.customerIds.duplicateProducts, + ]), + ); + expect(new Set(plannedIds).size).toBe(plannedIds.length); + }); +}); + +test(`${chalk.yellowBright("migration filter planner: includeProcessed unions stale processed rows once")}`, async () => { + await withSeededFixture("planner-parity-processed-union", async (fixture) => { + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-pro`, + status: MigrationItemRunStatus.Succeeded, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-active`, + status: MigrationItemRunStatus.Succeeded, + }); + + const ids = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildProcessedPreviewSelect({ + ...fixture.args, + includeProcessed: includeProcessed(fixture), + }), + }); + const count = await executeCount({ + db: fixture.ctx.db, + query: buildProcessedPreviewCount({ + ...fixture.args, + includeProcessed: includeProcessed(fixture), + }), + }); + + expect(sorted(ids)).toEqual( + sorted([ + fixture.customerIds.active, + fixture.customerIds.scheduled, + fixture.customerIds.pastDue, + fixture.customerIds.duplicateProducts, + fixture.customerIds.pro, + ]), + ); + expect(new Set(ids).size).toBe(ids.length); + expect(count).toBe(5); + }); +}); + +test(`${chalk.yellowBright("migration filter planner: explicit processed statuses ignore current filter")}`, async () => { + await withSeededFixture("planner-parity-explicit-status", async (fixture) => { + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-active`, + status: MigrationItemRunStatus.Succeeded, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-pro`, + status: MigrationItemRunStatus.Succeeded, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-scheduled`, + status: MigrationItemRunStatus.Failed, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-duplicate-products`, + status: MigrationItemRunStatus.Running, + }); + + const ids = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildProcessedPreviewSelect({ + ...fixture.args, + includeProcessed: includeProcessed(fixture, { + statuses: [MigrationItemRunStatus.Succeeded], + }), + }), + }); + + expect(sorted(ids)).toEqual( + sorted([fixture.customerIds.active, fixture.customerIds.pro]), + ); + + const runningIds = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildProcessedPreviewSelect({ + ...fixture.args, + includeProcessed: includeProcessed(fixture, { + statuses: [MigrationItemRunStatus.Running], + }), + }), + }); + + expect(runningIds).toEqual([fixture.customerIds.duplicateProducts]); + }); +}); + +test(`${chalk.yellowBright("migration filter planner: not_run excludes any processed customer")}`, async () => { + await withSeededFixture("planner-parity-not-run", async (fixture) => { + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-active`, + status: MigrationItemRunStatus.Succeeded, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-scheduled`, + status: MigrationItemRunStatus.Failed, + }); + + const ids = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildProcessedPreviewSelect({ + ...fixture.args, + includeProcessed: includeProcessed(fixture, { statuses: ["not_run"] }), + }), + }); + + expect(sorted(ids)).toEqual( + sorted([ + fixture.customerIds.pastDue, + fixture.customerIds.duplicateProducts, + ]), + ); + }); +}); + +test(`${chalk.yellowBright("migration filter planner: queued excludes checkpointed live item runs")}`, async () => { + await withSeededFixture("planner-parity-queued", async (fixture) => { + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-active`, + status: MigrationItemRunStatus.Succeeded, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-scheduled`, + status: MigrationItemRunStatus.Failed, + }); + + const ids = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildProcessedPreviewSelect({ + ...fixture.args, + includeProcessed: includeProcessed(fixture, { + statuses: ["queued"], + queuedRun: { + migrationRunId: fixture.migrationRunId, + dryRun: false, + }, + }), + }), + }); + + expect(sorted(ids)).toEqual( + sorted([ + fixture.customerIds.pastDue, + fixture.customerIds.duplicateProducts, + ]), + ); + }); +}); + +test(`${chalk.yellowBright("migration filter planner: mixed statuses include succeeded stale rows and matching not-run rows")}`, async () => { + await withSeededFixture("planner-parity-mixed-status", async (fixture) => { + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-pro`, + status: MigrationItemRunStatus.Succeeded, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-scheduled`, + status: MigrationItemRunStatus.Failed, + }); + + const ids = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildProcessedPreviewSelect({ + ...fixture.args, + includeProcessed: includeProcessed(fixture, { + statuses: [MigrationItemRunStatus.Succeeded, "not_run"], + }), + }), + }); + + expect(sorted(ids)).toEqual( + sorted([ + fixture.customerIds.active, + fixture.customerIds.pastDue, + fixture.customerIds.duplicateProducts, + fixture.customerIds.pro, + ]), + ); + }); +}); + +test(`${chalk.yellowBright("migration filter planner: checkpoint excludes completed items from run selection")}`, async () => { + await withSeededFixture("planner-parity-checkpoint", async (fixture) => { + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-active`, + status: MigrationItemRunStatus.Succeeded, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-scheduled`, + status: MigrationItemRunStatus.Failed, + }); + + const idsWithoutRetry = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildCustomerSelect({ + ...fixture.args, + checkpoint: { + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + dryRun: false, + excludedStatuses: [ + MigrationItemRunStatus.Running, + MigrationItemRunStatus.Succeeded, + MigrationItemRunStatus.Skipped, + MigrationItemRunStatus.Failed, + ], + }, + }), + }); + const idsWithRetry = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildCustomerSelect({ + ...fixture.args, + checkpoint: { + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + dryRun: false, + excludedStatuses: [ + MigrationItemRunStatus.Running, + MigrationItemRunStatus.Succeeded, + MigrationItemRunStatus.Skipped, + ], + }, + }), + }); + + expect(sorted(idsWithoutRetry)).toEqual( + sorted([ + fixture.customerIds.pastDue, + fixture.customerIds.duplicateProducts, + ]), + ); + expect(sorted(idsWithRetry)).toEqual( + sorted([ + fixture.customerIds.scheduled, + fixture.customerIds.pastDue, + fixture.customerIds.duplicateProducts, + ]), + ); + }); +}); + +test(`${chalk.yellowBright("migration filter planner: dry-run checkpoint scopes same run differently from other dry runs")}`, async () => { + await withSeededFixture("planner-parity-dry-checkpoint", async (fixture) => { + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-active`, + status: MigrationItemRunStatus.Succeeded, + dryRun: true, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.otherDryRunId, + itemId: `${fixture.prefix}-cus-scheduled`, + status: MigrationItemRunStatus.Succeeded, + dryRun: true, + }); + await insertItemRun({ + db: fixture.ctx.db, + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + itemId: `${fixture.prefix}-cus-past-due`, + status: MigrationItemRunStatus.Succeeded, + }); + + const ids = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildCustomerSelect({ + ...fixture.args, + checkpoint: { + migrationInternalId: fixture.migrationInternalId, + migrationRunId: fixture.migrationRunId, + dryRun: true, + excludedStatuses: [MigrationItemRunStatus.Succeeded], + }, + }), + }); + + expect(sorted(ids)).toEqual( + sorted([ + fixture.customerIds.scheduled, + fixture.customerIds.duplicateProducts, + ]), + ); + }); +}); + +test(`${chalk.yellowBright("migration filter planner: search and customer_id narrowing remain residual filters")}`, async () => { + await withSeededFixture("planner-parity-search-only", async (fixture) => { + const searchIds = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildCustomerSelect({ + ...fixture.args, + search: "scheduled@example.com", + }), + }); + const onlyIds = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildCustomerSelect({ + ...fixture.args, + filter: { + ...fixture.args.filter, + customer_id: { $in: [fixture.customerIds.pastDue] }, + }, + }), + }); + + expect(searchIds).toEqual([fixture.customerIds.scheduled]); + expect(onlyIds).toEqual([fixture.customerIds.pastDue]); + }); +}); + +test(`${chalk.yellowBright("migration filter planner: cursor pagination is stable and complete")}`, async () => { + await withSeededFixture("planner-parity-pagination", async (fixture) => { + const firstPage = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildCustomerSelect({ ...fixture.args, limit: 2 }), + }); + const secondPage = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildCustomerSelect({ + ...fixture.args, + limit: 10, + afterInternalId: `${fixture.prefix}-cus-past-due`, + }), + }); + const allIds = await executeCustomerIds({ + db: fixture.ctx.db, + query: buildCustomerSelect(fixture.args), + }); + + expect(firstPage).toEqual([ + fixture.customerIds.scheduled, + fixture.customerIds.pastDue, + ]); + expect(secondPage).toEqual([ + fixture.customerIds.duplicateProducts, + fixture.customerIds.active, + ]); + expect(sorted([...firstPage, ...secondPage])).toEqual(sorted(allIds)); + expect( + await executeCount({ + db: fixture.ctx.db, + query: buildCustomerCount(fixture.args), + }), + ).toBe(4); + }); +}); + +test(`${chalk.yellowBright("migration filter preview: cursor page helper returns non-overlapping pages")}`, async () => { + await withSeededFixture("planner-parity-preview-page", async (fixture) => { + const firstPage = await getCustomerPage({ + ctx: fixture.ctx, + filter: fixture.args.filter, + pageSize: 2, + }); + const secondPage = await getCustomerPage({ + ctx: fixture.ctx, + filter: fixture.args.filter, + pageSize: 2, + cursor: firstPage.nextCursor ?? undefined, + }); + + expect(firstPage.rows.map((row) => row.id)).toEqual([ + fixture.customerIds.scheduled, + fixture.customerIds.pastDue, + ]); + expect(secondPage.rows.map((row) => row.id)).toEqual([ + fixture.customerIds.duplicateProducts, + fixture.customerIds.active, + ]); + expect(secondPage.nextCursor).toBeNull(); + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts b/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts index e69de29bb..5a4d1b5c2 100644 --- a/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts +++ b/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from "bun:test"; +import { + ErrCode, + MigrationItemKind, + MigrationItemRunStatus, + migrations, +} from "@autumn/shared"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { CusService } from "@/internal/customers/CusService.js"; +import { migrationItemRunRepo } from "@/internal/migrations/v2/repos/index.js"; + +/** + * TDD coverage for migration draft CRUD used by the dashboard. + * + * Contract under test: + * New fields: + * - migrations.archived: boolean, default false. + * New behaviors: + * - PATCH /migrations.update accepts updates.archived. + * - POST /migrations.delete hard-deletes migrations with no customer runs. + * - POST /migrations.delete rejects migrations with customer run history. + * Side effects: + * - Rejected deletes keep the migration row and run history unchanged. + */ + +test.concurrent( + `${chalk.yellowBright("migrations.update: persists no_billing_changes from dashboard PATCH")}`, + async () => { + const customerId = "migrations-update-no-billing"; + const migrationId = `${customerId}-mig`; + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer()], + actions: [], + }); + + await autumnV2_2.migrationsV2.deleteAndCreate({ id: migrationId }); + const updated = await autumnV2_2.migrationsV2.update({ + id: migrationId, + updates: { no_billing_changes: true }, + }); + + expect(updated.no_billing_changes).toBe(true); + }, +); + +test.concurrent( + `${chalk.yellowBright("migrations.delete: hard deletes drafts that have no customer runs")}`, + async () => { + const customerId = "migrations-delete-draft"; + const migrationId = `${customerId}-mig`; + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer()], + actions: [], + }); + + await autumnV2_2.migrationsV2.deleteAndCreate({ id: migrationId }); + const deleted = await autumnV2_2.migrationsV2.delete({ id: migrationId }); + const list = await autumnV2_2.migrationsV2.list(); + + expect(deleted.id).toBe(migrationId); + expect(deleted.archived).toBe(false); + expect(list.list.some((migration) => migration.id === migrationId)).toBe(false); + }, +); + +test.concurrent( + `${chalk.yellowBright("migrations.delete: rejects migrations that have customer runs")}`, + async () => { + const customerId = `migrations-delete-reject-${Date.now()}`; + const migrationId = `${customerId}-mig`; + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer()], + actions: [], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: migrationId, + }); + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + if (!customer) throw new Error(`Expected customer ${customerId}`); + + await migrationItemRunRepo.markSucceeded({ + ctx, + migrationInternalId: migration.internal_id, + itemKind: MigrationItemKind.Customer, + itemId: customer.internal_id, + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: "has customer run history and cannot be deleted", + func: () => autumnV2_2.migrationsV2.delete({ id: migrationId }), + }); + const list = await autumnV2_2.migrationsV2.list(); + const preserved = list.list.find((candidate) => candidate.id === migrationId); + + expect(preserved).toMatchObject({ id: migrationId, archived: false }); + expect( + await migrationItemRunRepo.getCustomer({ + ctx, + migrationInternalId: migration.internal_id, + internalCustomerId: customer.internal_id, + }), + ).toMatchObject({ status: MigrationItemRunStatus.Succeeded }); + }, +); + +test.concurrent( + `${chalk.yellowBright("migrations.update: persists archived from dashboard PATCH")}`, + async () => { + const customerId = `migrations-update-archived-${Date.now()}`; + const migrationId = `${customerId}-mig`; + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer()], + actions: [], + }); + + await autumnV2_2.migrationsV2.deleteAndCreate({ id: migrationId }); + const updated = await autumnV2_2.migrationsV2.update({ + id: migrationId, + updates: { archived: true }, + }); + const [row] = await ctx.db + .select() + .from(migrations) + .where(eq(migrations.id, migrationId)); + + expect(updated.archived).toBe(true); + expect(row?.archived).toBe(true); + }, +); diff --git a/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview-update-items.test.ts b/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview-update-items.test.ts deleted file mode 100644 index 998d00f8c..000000000 --- a/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview-update-items.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * TDD coverage for migrateCustomer preview shape on update_items migrations. - * - * Contract under test: - * New types/fields: - * - balance_changes[i] is a full ApiBalanceV1 snapshot (object, feature_id, - * granted, remaining, usage, breakdown[], rollovers[], next_reset_at...) - * plus a sparse `previous_attributes` carrying the OLD values of fields - * that changed. - * - The legacy `before: { granted, remaining, usage }` shape is gone. - * New behaviors: - * - For a no-usage `update_items` bump (included 100 → 250), preview emits - * a single balance_change with new granted/remaining = 250 and - * previous_attributes.granted = previous_attributes.remaining = 100. - * - Fields that stayed the same (e.g. usage = 0 before and after) are - * omitted from previous_attributes. - * - When `update_items` lowers included but tracked usage is preserved, - * the balance_change reflects new remaining, with previous_attributes - * containing the old granted (and old remaining if it differs). - * - Migration that doesn't touch a given feature does NOT emit a - * balance_change for it. - */ - -import { expect, test } from "bun:test"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -type MigrationClient = Awaited>["autumnV2_2"]; - -const timeout = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - -const deepParse = (value: unknown): unknown => { - if (typeof value === "string") { - const trimmed = value.trim(); - if ( - (trimmed.startsWith("{") && trimmed.endsWith("}")) || - (trimmed.startsWith("[") && trimmed.endsWith("]")) - ) { - try { - return deepParse(JSON.parse(value)); - } catch { - return value; - } - } - return value; - } - if (Array.isArray(value)) return value.map(deepParse); - if (value && typeof value === "object") { - const result: Record = {}; - for (const [k, v] of Object.entries(value)) result[k] = deepParse(v); - return result; - } - return value; -}; - -const parseResponse = (response: unknown): Record => { - const parsed = deepParse(response); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) - return parsed as Record; - throw new Error(`Invalid migration event response: ${String(response)}`); -}; - -const waitForPreview = async ({ - autumnV2_2, - migrationId, - migrationRunId, - timeoutMs = 45_000, -}: { - autumnV2_2: MigrationClient; - migrationId: string; - migrationRunId: string; - timeoutMs?: number; -}): Promise> => { - const start = Date.now(); - let lastError: unknown; - while (Date.now() - start < timeoutMs) { - try { - const events = await autumnV2_2.migrationsV2.listItemEvents({ - migrationId, - migrationRunId, - }); - const event = events.list[0]; - if (!event) throw new Error("No migration item event found"); - const response = parseResponse(event.response); - const preview = response.preview; - if (!preview) throw new Error("Migration item event missing preview"); - return preview as Record; - } catch (error) { - lastError = error; - await timeout(1_000); - } - } - throw new Error( - `Timed out waiting for migration preview: ${ - lastError instanceof Error ? lastError.message : String(lastError) - }`, - ); -}; - -const runPreviewMigration = async ({ - autumnV2_2, - migrationId, - filter, - operations, -}: { - autumnV2_2: MigrationClient; - migrationId: string; - filter: Parameters< - MigrationClient["migrationsV2"]["deleteAndCreate"] - >[0]["filter"]; - operations: Parameters< - MigrationClient["migrationsV2"]["deleteAndCreate"] - >[0]["operations"]; -}) => { - const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ - id: migrationId, - filter, - operations, - }); - const runResponse = await autumnV2_2.migrationsV2.run({ - id: migration.id, - dry_run: true, - }); - return waitForPreview({ - autumnV2_2, - migrationId: migration.id, - migrationRunId: runResponse.run_id, - }); -}; - -test(`${chalk.yellowBright("migrations preview: update_items emits ApiBalanceV1 snapshot + previous_attributes for the touched feature")}`, async () => { - const suffix = Date.now(); - const customerId = `migration-preview-update-items-${suffix}`; - const freePlan = products.base({ - id: `migration-preview-update-items-plan-${suffix}`, - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyCredits({ includedUsage: 50 }), - ], - }); - - const { autumnV2_2 } = await initScenario({ - customerId, - setup: [s.customer(), s.products({ list: [freePlan] })], - actions: [s.billing.attach({ productId: freePlan.id })], - }); - - const preview = await runPreviewMigration({ - autumnV2_2, - migrationId: `${customerId}-mig`, - filter: { customer: { plan: { plan_id: freePlan.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: freePlan.id }, - customize: { - update_items: [ - { filter: { feature_id: TestFeature.Messages }, included: 250 }, - ], - }, - }, - ], - }, - }); - - const balanceChanges = preview.balance_changes as Array< - Record - >; - - // Untouched Credits feature → no entry. - expect( - balanceChanges.some((change) => change.feature_id === TestFeature.Credits), - ).toBe(false); - - const messagesChange = balanceChanges.find( - (change) => change.feature_id === TestFeature.Messages, - ); - expect(messagesChange, "expected a balance change for messages").toBeDefined(); - - const balance = messagesChange?.balance as Record; - expect(balance).toBeDefined(); - expect(balance).toMatchObject({ - granted: 250, - remaining: 250, - usage: 0, - }); - expect(balance).toHaveProperty("unlimited"); - expect(balance).toHaveProperty("next_reset_at"); - - // previous_attributes lives at the balance-change level, NOT inside balance. - expect(balance).not.toHaveProperty("previous_attributes"); - const previous = messagesChange?.previous_attributes as Record< - string, - unknown - >; - expect(previous).toBeDefined(); - expect(previous.granted).toBe(100); - expect(previous.remaining).toBe(100); - - // usage was 0 before and after — must NOT appear in previous_attributes. - expect(previous).not.toHaveProperty("usage"); - - // Top-level shape: just feature_id + balance + previous_attributes. No - // legacy before/granted at the top level. - expect(messagesChange).not.toHaveProperty("granted"); - expect(messagesChange).not.toHaveProperty("before"); - - // update_items collapses to a single "updated" item_change with the old - // included value in previous_attributes. - const planChanges = preview.plan_changes as Array>; - const patch = planChanges.find( - (change) => change.action === "updated" && change.plan_id === freePlan.id, - ); - expect(patch).toBeDefined(); - const itemChanges = patch?.item_changes as Array>; - const messagesItem = itemChanges.find( - (item) => item.feature_id === TestFeature.Messages, - ); - expect(messagesItem).toEqual( - expect.objectContaining({ - action: "updated", - feature_id: TestFeature.Messages, - previous_attributes: expect.objectContaining({ included: 100 }), - }), - ); -}); - -test(`${chalk.yellowBright("migrations preview: update_items with carried usage surfaces previous granted but not previous usage")}`, async () => { - const suffix = Date.now(); - const customerId = `migration-preview-update-items-usage-${suffix}`; - const freePlan = products.base({ - id: `migration-preview-update-items-usage-plan-${suffix}`, - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const { autumnV2_2 } = await initScenario({ - customerId, - setup: [s.customer(), s.products({ list: [freePlan] })], - actions: [ - s.billing.attach({ productId: freePlan.id }), - s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }), - ], - }); - - const preview = await runPreviewMigration({ - autumnV2_2, - migrationId: `${customerId}-mig`, - filter: { customer: { plan: { plan_id: freePlan.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: freePlan.id }, - customize: { - update_items: [ - { filter: { feature_id: TestFeature.Messages }, included: 300 }, - ], - }, - }, - ], - }, - }); - - const balanceChanges = preview.balance_changes as Array< - Record - >; - const change = balanceChanges.find( - (b) => b.feature_id === TestFeature.Messages, - ); - expect(change).toBeDefined(); - - const balance = change?.balance as Record; - // new: granted=300, remaining=270 (300-30 carried usage), usage=30 - expect(balance).toMatchObject({ - granted: 300, - remaining: 270, - usage: 30, - }); - - const previous = change?.previous_attributes as Record; - // previous: granted=100, remaining=70 (100-30), usage=30 (same) - expect(previous.granted).toBe(100); - expect(previous.remaining).toBe(70); - expect(previous).not.toHaveProperty("usage"); -}); diff --git a/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview.test.ts b/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview.test.ts deleted file mode 100644 index b1c18678e..000000000 --- a/server/tests/integration/billing/migrations-v2/preview/migrate-customer-preview.test.ts +++ /dev/null @@ -1,433 +0,0 @@ -/** - * TDD coverage for migrateCustomer preview audit responses. - * - * Contract under test: - * - response.preview is emitted on migration item events. - * - Boolean add/remove item migrations populate flag_changes and no balance_changes. - * - Metered grant updates populate balance_changes and omit untouched balances. - * - Version migrations populate plan_changes, balance_changes, and flag_changes. - * - Entity-scoped customer products surface entity_id on plan_changes. - */ - -import { expect, test } from "bun:test"; -import { ResetInterval } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -type PreviewPlanItemChange = { - action: "created" | "updated" | "deleted"; - feature_id: string; - previous_attributes: Record; -}; - -type PreviewPlanChange = { - action: "created" | "updated" | "deleted"; - plan_id: string; - entity_id?: string | null; - item_changes: PreviewPlanItemChange[]; -}; - -type PreviewBalanceChange = { - feature_id: string; - balance: { - granted: number; - remaining: number; - usage: number; - unlimited: boolean; - next_reset_at: number | null; - }; - previous_attributes: Record; -}; - -type PreviewFlagChange = { - action: "created" | "deleted"; - feature_id: string; -}; - -type PreviewMigrateCustomer = { - object: "migration_customer_preview"; - customer_id: string; - plan_changes: PreviewPlanChange[]; - balance_changes: PreviewBalanceChange[]; - flag_changes: PreviewFlagChange[]; -}; - -type MigrationClient = Awaited>["autumnV2_2"]; - -const timeout = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Tinybird's `t.json` storage round-trips nested values as JSON-encoded - * strings at one or more levels. Walk the tree, JSON.parse any string that - * looks like a JSON object/array, and return a fully-parsed structure. - */ -const deepParse = (value: unknown): unknown => { - if (typeof value === "string") { - const trimmed = value.trim(); - if ( - (trimmed.startsWith("{") && trimmed.endsWith("}")) || - (trimmed.startsWith("[") && trimmed.endsWith("]")) - ) { - try { - return deepParse(JSON.parse(value)); - } catch { - return value; - } - } - return value; - } - if (Array.isArray(value)) return value.map(deepParse); - if (value && typeof value === "object") { - const result: Record = {}; - for (const [k, v] of Object.entries(value)) result[k] = deepParse(v); - return result; - } - return value; -}; - -const parseResponse = (response: unknown): Record => { - const parsed = deepParse(response); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) - return parsed as Record; - throw new Error(`Invalid migration event response: ${String(response)}`); -}; - -const waitForPreview = async ({ - autumnV2_2, - migrationId, - migrationRunId, - timeoutMs = 45_000, -}: { - autumnV2_2: MigrationClient; - migrationId: string; - migrationRunId: string; - timeoutMs?: number; -}): Promise => { - const start = Date.now(); - let lastError: unknown; - - while (Date.now() - start < timeoutMs) { - try { - const events = await autumnV2_2.migrationsV2.listItemEvents({ - migrationId, - migrationRunId, - }); - const event = events.list[0]; - if (!event) throw new Error("No migration item event found"); - - const response = parseResponse(event.response); - const preview = response.preview; - if (!preview) throw new Error("Migration item event missing preview"); - - return preview as PreviewMigrateCustomer; - } catch (error) { - lastError = error; - await timeout(1_000); - } - } - - throw new Error( - `Timed out waiting for migration preview: ${ - lastError instanceof Error ? lastError.message : String(lastError) - }`, - ); -}; - -const runPreviewMigration = async ({ - autumnV2_2, - migrationId, - filter, - operations, -}: { - autumnV2_2: MigrationClient; - migrationId: string; - filter: Parameters< - MigrationClient["migrationsV2"]["deleteAndCreate"] - >[0]["filter"]; - operations: Parameters< - MigrationClient["migrationsV2"]["deleteAndCreate"] - >[0]["operations"]; -}) => { - const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ - id: migrationId, - filter, - operations, - }); - const runResponse = await autumnV2_2.migrationsV2.run({ - id: migration.id, - dry_run: true, - }); - - return waitForPreview({ - autumnV2_2, - migrationId: migration.id, - migrationRunId: runResponse.run_id, - }); -}; - -test(`${chalk.yellowBright("migrations preview: boolean item add/remove emits flag changes")}`, async () => { - const suffix = Date.now(); - const customerId = `migration-preview-flags-${suffix}`; - const freePlan = products.base({ - id: `migration-preview-flags-plan-${suffix}`, - items: [items.adminRights()], - }); - - const { autumnV2_2 } = await initScenario({ - customerId, - setup: [s.customer(), s.products({ list: [freePlan] })], - actions: [s.billing.attach({ productId: freePlan.id })], - }); - - const preview = await runPreviewMigration({ - autumnV2_2, - migrationId: `${customerId}-mig`, - filter: { customer: { plan: { plan_id: freePlan.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: freePlan.id }, - customize: { - remove_items: [{ feature_id: TestFeature.AdminRights }], - add_items: [itemsV2.dashboard()], - }, - }, - ], - }, - }); - - expect(preview.balance_changes).toEqual([]); - expect(preview.flag_changes).toEqual( - expect.arrayContaining([ - { action: "deleted", feature_id: TestFeature.AdminRights }, - { action: "created", feature_id: TestFeature.Dashboard }, - ]), - ); - expect(preview.plan_changes).toEqual([ - expect.objectContaining({ - action: "updated", - plan_id: freePlan.id, - item_changes: expect.arrayContaining([ - { - action: "deleted", - feature_id: TestFeature.AdminRights, - previous_attributes: {}, - }, - { - action: "created", - feature_id: TestFeature.Dashboard, - previous_attributes: {}, - }, - ]), - }), - ]); -}); - -test(`${chalk.yellowBright("migrations preview: metered grant replacement emits balance change only for touched feature")}`, async () => { - const suffix = Date.now(); - const customerId = `migration-preview-balances-${suffix}`; - const freePlan = products.base({ - id: `migration-preview-balances-plan-${suffix}`, - items: [ - items.monthlyCredits({ includedUsage: 100 }), - items.monthlyMessages({ includedUsage: 50 }), - ], - }); - - const { autumnV2_2 } = await initScenario({ - customerId, - setup: [s.customer(), s.products({ list: [freePlan] })], - actions: [s.billing.attach({ productId: freePlan.id })], - }); - - const preview = await runPreviewMigration({ - autumnV2_2, - migrationId: `${customerId}-mig`, - filter: { customer: { plan: { plan_id: freePlan.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: freePlan.id }, - customize: { - remove_items: [{ feature_id: TestFeature.Credits }], - add_items: [ - { - feature_id: TestFeature.Credits, - included: 300, - reset: { interval: ResetInterval.Month }, - }, - ], - }, - }, - ], - }, - }); - - expect(preview.flag_changes).toEqual([]); - expect(preview.balance_changes.length).toBe(1); - expect(preview.balance_changes[0]).toEqual( - expect.objectContaining({ - feature_id: TestFeature.Credits, - balance: expect.objectContaining({ - granted: 300, - remaining: 300, - usage: 0, - }), - previous_attributes: expect.objectContaining({ - granted: 100, - remaining: 100, - }), - }), - ); - // usage stayed at 0 — must NOT appear in previous_attributes - expect( - preview.balance_changes[0].previous_attributes, - ).not.toHaveProperty("usage"); - expect( - preview.balance_changes.some( - (change) => change.feature_id === TestFeature.Messages, - ), - ).toBe(false); - // remove + add on the same feature collapses into a single "updated" item_change - expect(preview.plan_changes).toEqual([ - expect.objectContaining({ - action: "updated", - plan_id: freePlan.id, - item_changes: [ - expect.objectContaining({ - action: "updated", - feature_id: TestFeature.Credits, - previous_attributes: expect.objectContaining({ - included: 100, - }), - }), - ], - }), - ]); -}); - -test(`${chalk.yellowBright("migrations preview: version update emits plan, balance, and flag changes")}`, async () => { - const suffix = Date.now(); - const customerId = `migration-preview-version-${suffix}`; - const freePlan = products.base({ - id: `migration-preview-version-plan-${suffix}`, - items: [items.monthlyMessages({ includedUsage: 100 }), items.adminRights()], - }); - - const { autumnV1, autumnV2_2 } = await initScenario({ - customerId, - setup: [s.customer(), s.products({ list: [freePlan] })], - actions: [s.billing.attach({ productId: freePlan.id })], - }); - - await autumnV1.products.update(freePlan.id, { - items: [ - items.monthlyMessages({ includedUsage: 200 }), - items.monthlyCredits({ includedUsage: 50 }), - ], - }); - - const preview = await runPreviewMigration({ - autumnV2_2, - migrationId: `${customerId}-mig`, - filter: { customer: { plan: { plan_id: freePlan.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: freePlan.id }, - version: 2, - }, - ], - }, - }); - - expect(preview.plan_changes.length).toBeGreaterThan(0); - expect(preview.balance_changes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - feature_id: TestFeature.Messages, - balance: expect.objectContaining({ - granted: 200, - remaining: 200, - usage: 0, - }), - previous_attributes: expect.objectContaining({ - granted: 100, - remaining: 100, - }), - }), - expect.objectContaining({ - feature_id: TestFeature.Credits, - balance: expect.objectContaining({ - granted: 50, - remaining: 50, - usage: 0, - }), - previous_attributes: expect.objectContaining({ - granted: 0, - remaining: 0, - }), - }), - ]), - ); - expect(preview.flag_changes).toEqual([ - { action: "deleted", feature_id: TestFeature.AdminRights }, - ]); -}); - -test(`${chalk.yellowBright("migrations preview: plan changes include entity_id")}`, async () => { - const suffix = Date.now(); - const customerId = `migration-preview-entity-${suffix}`; - const entityPlan = products.base({ - id: `migration-preview-entity-plan-${suffix}`, - items: [], - }); - - const { autumnV2_2, entities } = await initScenario({ - customerId, - setup: [ - s.customer(), - s.entities({ count: 1, featureId: TestFeature.Users }), - s.products({ list: [entityPlan] }), - ], - actions: [ - s.billing.attach({ - productId: entityPlan.id, - entityIndex: 0, - }), - ], - }); - - const preview = await runPreviewMigration({ - autumnV2_2, - migrationId: `${customerId}-mig`, - filter: { customer: { plan: { plan_id: entityPlan.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: entityPlan.id }, - customize: { - add_items: [itemsV2.dashboard()], - }, - }, - ], - }, - }); - - expect(preview.plan_changes).toEqual([ - expect.objectContaining({ - action: "updated", - plan_id: entityPlan.id, - entity_id: entities[0].id, - }), - ]); -}); diff --git a/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts b/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts index 9ce6a3166..deeb7336a 100644 --- a/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts +++ b/server/tests/integration/billing/migrations-v2/run-handler/run-handler-lazy-run-body.test.ts @@ -4,6 +4,10 @@ * Contract under test: * - `migrationsV2.run({ id, lazy_run: true })` persists `lazy_run = true` * on the resulting `migration_runs` row. + * - Live lazy runs prepare update_plan artifacts before publishing work to + * customer lazy migration tasks. + * - `lazy_run=true` rejects targeted `only` runs because request-path + * lazy execution cannot respect the target list. * - Default (`lazy_run` omitted / false) leaves the row in its * background-only shape (`lazy_run = false`). * - The response echoes the requested `lazy_run` value alongside @@ -11,12 +15,13 @@ */ import { expect, test } from "bun:test"; -import { migrationRuns } from "@autumn/shared"; +import { ErrCode, migrationRuns } from "@autumn/shared"; import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import { and, eq } from "drizzle-orm"; +import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; const buildDashboardMigration = ({ id, @@ -41,8 +46,12 @@ const buildDashboardMigration = ({ test.concurrent( `${chalk.yellowBright("run-handler lazy_run: lazy_run=true persists on migration_runs")}`, async () => { - const customerId = "run-handler-lazy-true"; - const plan = products.pro({ id: "run-handler-lazy-true-pro", items: [] }); + const suffix = Date.now(); + const customerId = `run-handler-lazy-true-${suffix}`; + const plan = products.pro({ + id: `run-handler-lazy-true-pro-${suffix}`, + items: [], + }); const { autumnV2_2, ctx } = await initScenario({ customerId, @@ -63,10 +72,20 @@ test.concurrent( const response = await autumnV2_2.migrationsV2.run({ id: migration.id, lazy_run: true, + concurrency: 7, }); expect(response.migration_id).toBe(migration.id); expect(response.lazy_run).toBe(true); + expect(response.concurrency).toBe(7); + + const updatedMigration = await migrationRepo.find({ + ctx, + id: migration.id, + }); + expect(updatedMigration.prepared_state).toHaveProperty( + "ensure_prices_and_entitlements:update_plan", + ); // Cleanup so other tests can claim this migration. Direct delete by // the returned run_id (idempotent — survives if the trigger task @@ -89,12 +108,52 @@ test.concurrent( }, ); +test.concurrent( + `${chalk.yellowBright("run-handler lazy_run: rejects targeted only runs")}`, + async () => { + const suffix = Date.now(); + const customerId = `run-handler-lazy-only-${suffix}`; + const plan = products.pro({ + id: `run-handler-lazy-only-pro-${suffix}`, + items: [], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + ], + actions: [s.billing.attach({ productId: plan.id })], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate( + buildDashboardMigration({ + id: `${customerId}-mig`, + planId: plan.id, + }), + ); + + await expect( + autumnV2_2.migrationsV2.run({ + id: migration.id, + lazy_run: true, + only: [customerId], + }), + ).rejects.toMatchObject({ + code: ErrCode.InvalidRequest, + message: expect.stringContaining("lazy_run"), + }); + }, +); + test.concurrent( `${chalk.yellowBright("run-handler lazy_run: default lazy_run=false on migration_runs")}`, async () => { - const customerId = "run-handler-lazy-default"; + const suffix = Date.now(); + const customerId = `run-handler-lazy-default-${suffix}`; const plan = products.pro({ - id: "run-handler-lazy-default-pro", + id: `run-handler-lazy-default-pro-${suffix}`, items: [], }); diff --git a/server/tests/integration/billing/migrations-v2/run-scoping/migration-run-scoping.test.ts b/server/tests/integration/billing/migrations-v2/run-scoping/migration-run-scoping.test.ts deleted file mode 100644 index c8f1058ad..000000000 --- a/server/tests/integration/billing/migrations-v2/run-scoping/migration-run-scoping.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { expect, test } from "bun:test"; -import { MigrationItemRunStatus } from "@autumn/shared"; -import { itemsV2 } from "@tests/utils/fixtures/itemsV2.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; -import { CusService } from "@/internal/customers/CusService.js"; -import { - migrationRunRepo, - migrationItemRunRepo, -} from "@/internal/migrations/v2/repos/index.js"; -import { waitForMigrationResult } from "../utils/runUpdatePlanMigration.js"; - -const timeout = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - -const getInternalCustomerId = async ({ - customerId, - ctx, -}: { - customerId: string; - ctx: Awaited>["ctx"]; -}) => { - const customer = await CusService.get({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - if (!customer) throw new Error(`Expected customer ${customerId}`); - return customer.internal_id; -}; - -const waitForRunCompleted = async ({ - ctx, - runId, -}: { - ctx: Awaited>["ctx"]; - runId: string; -}) => - waitForMigrationResult({ - timeoutMs: 60_000, - pollIntervalMs: 1_000, - waitFor: async () => { - const [run] = await migrationRunRepo.list({ - ctx, - internalId: runId, - }); - if (!run) throw new Error("Run not found"); - if (run.status !== "succeeded" && run.status !== "failed") - throw new Error(`Run still ${run.status}`); - }, - }); - -test(`${chalk.yellowBright("migration run scoping: only persists target_customer_ids on run record")}`, async () => { - const suffix = Date.now(); - const firstId = `run-scope-only-first-${suffix}`; - const secondId = `run-scope-only-second-${suffix}`; - const plan = products.base({ - id: `run-scope-only-plan-${suffix}`, - items: [], - }); - - const { autumnV2_2, ctx } = await initScenario({ - customerId: firstId, - setup: [ - s.customer(), - s.otherCustomers([{ id: secondId }]), - s.products({ list: [plan] }), - ], - actions: [ - s.parallel( - s.billing.attach({ productId: plan.id }), - s.billing.attach({ customerId: secondId, productId: plan.id }), - ), - ], - }); - - const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ - id: `run-scope-only-mig-${suffix}`, - filter: { customer: { plan: { plan_id: plan.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: plan.id }, - customize: { add_items: [itemsV2.dashboard()] }, - }, - ], - }, - }); - - const runResponse = await autumnV2_2.migrationsV2.run({ - id: migration.id, - dry_run: true, - only: [firstId], - }); - - await waitForRunCompleted({ ctx, runId: runResponse.run_id }); - - const [run] = await migrationRunRepo.list({ - ctx, - internalId: runResponse.run_id, - }); - expect(run).toBeDefined(); - expect(run.only_ids).toEqual([firstId]); - expect(run.target_limit).toBeNull(); - expect(run.dry_run).toBe(true); - - const firstInternalId = await getInternalCustomerId({ - customerId: firstId, - ctx, - }); - const secondInternalId = await getInternalCustomerId({ - customerId: secondId, - ctx, - }); - - const firstItemRun = await migrationItemRunRepo.getCustomer({ - ctx, - migrationInternalId: migration.internal_id, - internalCustomerId: firstInternalId, - dryRun: true, - migrationRunId: runResponse.run_id, - }); - expect(firstItemRun).toMatchObject({ - status: MigrationItemRunStatus.Succeeded, - }); - - const secondItemRun = await migrationItemRunRepo.getCustomer({ - ctx, - migrationInternalId: migration.internal_id, - internalCustomerId: secondInternalId, - dryRun: true, - migrationRunId: runResponse.run_id, - }); - expect(secondItemRun).toBeNull(); -}); - -test(`${chalk.yellowBright("migration run scoping: limit persists target_limit on run record")}`, async () => { - const suffix = Date.now(); - const customerIds = Array.from( - { length: 5 }, - (_, i) => `run-scope-limit-${i}-${suffix}`, - ); - const plan = products.base({ - id: `run-scope-limit-plan-${suffix}`, - items: [], - }); - - const { autumnV2_2, ctx } = await initScenario({ - customerId: customerIds[0], - setup: [ - s.customer(), - s.otherCustomers(customerIds.slice(1).map((id) => ({ id }))), - s.products({ list: [plan] }), - ], - actions: [ - s.parallel( - ...customerIds.map((id) => - id === customerIds[0] - ? s.billing.attach({ productId: plan.id }) - : s.billing.attach({ customerId: id, productId: plan.id }), - ), - ), - ], - }); - - const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ - id: `run-scope-limit-mig-${suffix}`, - filter: { customer: { plan: { plan_id: plan.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: plan.id }, - customize: { add_items: [itemsV2.dashboard()] }, - }, - ], - }, - }); - - const runResponse = await autumnV2_2.migrationsV2.run({ - id: migration.id, - dry_run: false, - limit: 2, - }); - - await waitForRunCompleted({ ctx, runId: runResponse.run_id }); - - const [run] = await migrationRunRepo.list({ - ctx, - internalId: runResponse.run_id, - }); - expect(run).toBeDefined(); - expect(run.only_ids).toBeNull(); - expect(run.target_limit).toBe(2); - - const events = await autumnV2_2.migrationsV2.listItemEvents({ - migrationId: migration.id, - migrationRunId: runResponse.run_id, - }); - expect(events.list.length).toBe(2); -}); - -test(`${chalk.yellowBright("migration run scoping: full run has null target fields")}`, async () => { - const suffix = Date.now(); - const customerId = `run-scope-full-${suffix}`; - const plan = products.base({ - id: `run-scope-full-plan-${suffix}`, - items: [], - }); - - const { autumnV2_2, ctx } = await initScenario({ - customerId, - setup: [s.customer(), s.products({ list: [plan] })], - actions: [s.billing.attach({ productId: plan.id })], - }); - - const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ - id: `run-scope-full-mig-${suffix}`, - filter: { customer: { plan: { plan_id: plan.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: plan.id }, - customize: { add_items: [itemsV2.dashboard()] }, - }, - ], - }, - }); - - const runResponse = await autumnV2_2.migrationsV2.run({ - id: migration.id, - dry_run: false, - }); - - await waitForRunCompleted({ ctx, runId: runResponse.run_id }); - - const [run] = await migrationRunRepo.list({ - ctx, - internalId: runResponse.run_id, - }); - expect(run).toBeDefined(); - expect(run.only_ids).toBeNull(); - expect(run.target_limit).toBeNull(); -}); diff --git a/server/tests/integration/billing/migrations-v2/trial/migration-paid-recurring-trial-carryover.test.ts b/server/tests/integration/billing/migrations-v2/trial/migration-paid-recurring-trial-carryover.test.ts new file mode 100644 index 000000000..d3972a26f --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/trial/migration-paid-recurring-trial-carryover.test.ts @@ -0,0 +1,374 @@ +/** + * Regression coverage for paid recurring trials during update_plan migrations. + * Migrations must preserve active Stripe trial state in normal and entity-scoped setups. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiEntityV0, Migration } from "@autumn/shared"; +import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { prepare } from "@/internal/migrations/v2/prepare/prepare.js"; +import { migrateCustomer } from "@/internal/migrations/v2/run/migrateCustomer/index.js"; +import { preProcessMigration } from "@/internal/migrations/v2/run/preProcess/index.js"; + +type MigrationClient = { + migrationsV2: { + deleteAndCreate: (params: { + id: string; + filter?: MigrationFilter | null; + operations?: Operations | null; + }) => Promise; + }; +}; + +type TrialSubSnapshot = { + id: string; + trialEnd: number | null; + subscription: Stripe.Subscription; +}; + +const activeOrTrialing = (sub: Stripe.Subscription) => + sub.status === "active" || sub.status === "trialing"; + +const getTrialSubSnapshots = async ({ + ctx, + stripeCustomerId, +}: { + ctx: TestContext; + stripeCustomerId: string; +}): Promise => { + const subscriptions = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + status: "all", + }); + const activeSubs = subscriptions.data.filter(activeOrTrialing); + expect(activeSubs.length).toBeGreaterThan(0); + + return activeSubs.map((subscription) => { + expect(subscription.status).toBe("trialing"); + expect(subscription.trial_end).toBeDefined(); + return { + id: subscription.id, + trialEnd: subscription.trial_end, + subscription, + }; + }); +}; + +const expectTrialSubsPreserved = async ({ + ctx, + before, + expectUnchanged, +}: { + ctx: TestContext; + before: TrialSubSnapshot[]; + expectUnchanged: boolean; +}) => { + for (const snapshot of before) { + const after = await ctx.stripeCli.subscriptions.retrieve(snapshot.id); + expect(after.status).toBe("trialing"); + expect(after.trial_end).toBe(snapshot.trialEnd); + + if (expectUnchanged) { + expectStripeSubscriptionUnchanged({ + before: snapshot.subscription, + after, + }); + } + } +}; + +const runVersionMigration = async ({ + ctx, + migrationClient, + migrationId, + customerId, + filter, + operations, + noBillingChanges, +}: { + ctx: AutumnContext; + migrationClient: MigrationClient; + migrationId: string; + customerId: string; + filter: MigrationFilter; + operations: Operations; + noBillingChanges: boolean; +}) => { + const migration = await migrationClient.migrationsV2.deleteAndCreate({ + id: migrationId, + filter, + operations, + }); + const processedMigration = preProcessMigration({ + ...migration, + no_billing_changes: noBillingChanges, + }); + const { preparedState } = await prepare({ + ctx, + migration: processedMigration, + dryRun: false, + }); + + await migrateCustomer({ + ctx, + customerId, + migration: { + ...processedMigration, + prepared_state: preparedState, + }, + }); +}; + +const updateTrialProductsToV2 = async ({ + autumnV1, + proId, + addonId, +}: { + autumnV1: Awaited>["autumnV1"]; + proId: string; + addonId: string; +}) => { + await autumnV1.products.update(proId, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 600 }), + ], + }); + await autumnV1.products.update(addonId, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyWords({ includedUsage: 300 }), + ], + }); +}; + +const migrationOps = ({ + proId, + addonId, +}: { + proId: string; + addonId: string; +}): Operations => ({ + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: proId }, + version: 2, + }, + { + type: "update_plan", + plan_filter: { plan_id: addonId }, + version: 2, + }, + ], +}); + +for (const noBillingChanges of [false, true]) { + test.concurrent( + `${chalk.yellowBright(`migrations trial: paid pro + addon preserves trial (${noBillingChanges ? "no billing changes" : "billing changes"})`)}`, + async () => { + const suffix = noBillingChanges ? "db-only" : "billing"; + const customerId = `mig-paid-trial-regular-${suffix}`; + const proTrial = products.proWithTrial({ + id: "mig-paid-trial-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + trialDays: 14, + cardRequired: true, + }); + const addon = products.recurringAddOn({ + id: "mig-paid-trial-addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, addon] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.billing.attach({ productId: addon.id }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + const trialEndsAt = await expectProductTrialing({ + customer: customerBefore, + productId: proTrial.id, + }); + expect(trialEndsAt).toBeDefined(); + await expectProductTrialing({ + customer: customerBefore, + productId: addon.id, + trialEndsAt: trialEndsAt!, + }); + expect(customerBefore.stripe_id).toBeDefined(); + const subSnapshots = await getTrialSubSnapshots({ + ctx, + stripeCustomerId: customerBefore.stripe_id as string, + }); + + await updateTrialProductsToV2({ + autumnV1, + proId: proTrial.id, + addonId: addon.id, + }); + + await runVersionMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: proTrial.id } } }, + operations: migrationOps({ proId: proTrial.id, addonId: addon.id }), + noBillingChanges, + }); + + const customerAfter = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfter, + active: [proTrial.id, addon.id], + }); + await expectProductTrialing({ + customer: customerAfter, + productId: proTrial.id, + trialEndsAt: trialEndsAt!, + }); + await expectProductTrialing({ + customer: customerAfter, + productId: addon.id, + trialEndsAt: trialEndsAt!, + }); + await expectTrialSubsPreserved({ + ctx, + before: subSnapshots, + expectUnchanged: noBillingChanges, + }); + }, + ); + + test.concurrent( + `${chalk.yellowBright(`migrations trial: multi-entity pro + addon preserves trial (${noBillingChanges ? "no billing changes" : "billing changes"})`)}`, + async () => { + const suffix = noBillingChanges ? "db-only" : "billing"; + const customerId = `mig-paid-trial-entities-${suffix}`; + const proTrial = products.proWithTrial({ + id: "mig-paid-trial-ent-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + trialDays: 14, + cardRequired: true, + }); + const addon = products.recurringAddOn({ + id: "mig-paid-trial-ent-addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, addon] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id, entityIndex: 0 }), + s.billing.attach({ productId: addon.id, entityIndex: 0 }), + s.billing.attach({ productId: proTrial.id, entityIndex: 1 }), + s.billing.attach({ productId: addon.id, entityIndex: 1 }), + ], + }); + + const entityBefore = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const trialEndsAt = await expectProductTrialing({ + customer: entityBefore, + productId: proTrial.id, + }); + expect(trialEndsAt).toBeDefined(); + + for (const entity of entities) { + const entityCustomer = await autumnV1.entities.get( + customerId, + entity.id, + ); + await expectProductTrialing({ + customer: entityCustomer, + productId: proTrial.id, + trialEndsAt: trialEndsAt!, + }); + await expectProductTrialing({ + customer: entityCustomer, + productId: addon.id, + trialEndsAt: trialEndsAt!, + }); + } + + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.stripe_id).toBeDefined(); + const subSnapshots = await getTrialSubSnapshots({ + ctx, + stripeCustomerId: customerBefore.stripe_id as string, + }); + + await updateTrialProductsToV2({ + autumnV1, + proId: proTrial.id, + addonId: addon.id, + }); + + await runVersionMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: proTrial.id } } }, + operations: migrationOps({ proId: proTrial.id, addonId: addon.id }), + noBillingChanges, + }); + + for (const entity of entities) { + const entityCustomer = await autumnV1.entities.get( + customerId, + entity.id, + ); + await expectCustomerProducts({ + customer: entityCustomer, + active: [proTrial.id, addon.id], + }); + await expectProductTrialing({ + customer: entityCustomer, + productId: proTrial.id, + trialEndsAt: trialEndsAt!, + }); + await expectProductTrialing({ + customer: entityCustomer, + productId: addon.id, + trialEndsAt: trialEndsAt!, + }); + } + await expectTrialSubsPreserved({ + ctx, + before: subSnapshots, + expectUnchanged: noBillingChanges, + }); + }, + ); +} diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/create-schedule/update-plan-op-scheduled-create-schedule.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/create-schedule/update-plan-op-scheduled-create-schedule.test.ts new file mode 100644 index 000000000..ffc4dac2f --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/create-schedule/update-plan-op-scheduled-create-schedule.test.ts @@ -0,0 +1,303 @@ +/** + * TDD coverage for update_plan migrations over createSchedule-managed scheduled rows. + * + * Contract under test: + * New behaviors: + * - Future scheduled rows created by createSchedule can be version-migrated. + * - Replacing one product in a multi-plan future phase rewires only that ID. + * - Feature quantities/options on scheduled rows survive replacement. + * Side effects: + * - `no_billing_changes: true` updates Autumn only and leaves the Stripe schedule unchanged. + * - Schedule phases never point at deleted customer product IDs. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { CusProductStatus, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; +import { + expectNoCustomerProductRow, + getCustomerProductBalances, + getCustomerProductFeatureIds, + getCustomerProductPriceAmounts, + getPhaseCustomerProductIds, + getRequiredStripeScheduleId, + getScheduledCustomerProductRow, +} from "../utils/scheduledCustomerProductTestUtils"; + +const stripeScheduleSignature = (schedule: Stripe.SubscriptionSchedule) => ({ + status: schedule.status, + currentPhase: schedule.current_phase, + phases: schedule.phases.map((phase) => ({ + startDate: phase.start_date, + endDate: phase.end_date, + items: phase.items.map((item) => ({ + price: typeof item.price === "string" ? item.price : item.price.id, + quantity: item.quantity, + })), + })), +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled createSchedule: future row replacement rewires one multi-plan phase ID")}`, async () => { + const customerId = "migration-update-scheduled-create-schedule"; + const activePlan = products.pro({ + id: "scheduled-create-schedule-active", + items: [items.monthlyWords({ includedUsage: 100 })], + }); + const futurePlan = products.base({ + id: "scheduled-create-schedule-base", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + const untouchedFuturePlan = products.base({ + id: "scheduled-create-schedule-untouched", + group: "backup", + items: [ + items.monthlyPrice({ price: 40 }), + items.monthlyWords({ includedUsage: 50 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [activePlan, futurePlan, untouchedFuturePlan] }), + ], + actions: [], + }); + + const now = Date.now(); + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: activePlan.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { plan_id: futurePlan.id }, + { plan_id: untouchedFuturePlan.id }, + ], + }, + ], + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: futurePlan.id, + }); + const untouchedScheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: untouchedFuturePlan.id, + }); + expect(response.phases[1]?.customer_product_ids).toEqual([ + scheduledBefore.id, + untouchedScheduledBefore.id, + ]); + + const stripeScheduleId = getRequiredStripeScheduleId({ + scheduledIds: scheduledBefore.scheduledIds, + }); + const stripeScheduleBefore = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + const stripeSignatureBefore = stripeScheduleSignature( + stripeScheduleBefore as Stripe.SubscriptionSchedule, + ); + + await autumnV1.products.update(futurePlan.id, { + items: [ + items.monthlyPrice({ price: 30 }), + items.monthlyMessages({ includedUsage: 250 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + noBillingChanges: true, + filter: { customer: { plan: { plan_id: futurePlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: futurePlan.id, version: 1 }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + await expectNoCustomerProductRow({ + ctx, + customerProductId: scheduledBefore.id, + }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: futurePlan.id, + }); + expect(scheduledAfter.id).not.toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(2); + expect(scheduledAfter.startsAt).toBe(scheduledBefore.startsAt); + expect(scheduledAfter.scheduledIds).toEqual(scheduledBefore.scheduledIds); + expect( + await getPhaseCustomerProductIds({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([scheduledAfter.id, untouchedScheduledBefore.id]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([30]); + expect( + await getCustomerProductFeatureIds({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([TestFeature.Messages]); + const stripeScheduleAfter = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + expect(stripeScheduleSignature(stripeScheduleAfter as Stripe.SubscriptionSchedule)).toEqual( + stripeSignatureBefore, + ); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: futurePlan.id }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 1, + latestTotal: 20, + }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled createSchedule: feature quantities survive replacement")}`, async () => { + const customerId = "migration-update-scheduled-quantity"; + const activePlan = products.pro({ + id: "scheduled-quantity-active", + items: [items.monthlyWords({ includedUsage: 100 })], + }); + const futurePlan = products.base({ + id: "scheduled-quantity-future", + items: [ + items.monthlyPrice({ price: 20 }), + items.prepaidMessages(), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [activePlan, futurePlan] }), + ], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: activePlan.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: futurePlan.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 400, + }, + ], + }, + ], + }, + ], + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: futurePlan.id, + }); + expect(scheduledBefore.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 4, + }), + ]); + + await autumnV1.products.update(futurePlan.id, { + items: [ + items.monthlyPrice({ price: 25 }), + items.prepaidMessages(), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + noBillingChanges: true, + filter: { customer: { plan: { plan_id: futurePlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: futurePlan.id, version: 1 }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + await expectNoCustomerProductRow({ + ctx, + customerProductId: scheduledBefore.id, + }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: futurePlan.id, + }); + expect(scheduledAfter.version).toBe(2); + expect(scheduledAfter.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 4, + }), + ]); + expect( + await getCustomerProductBalances({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([ + expect.objectContaining({ + featureId: TestFeature.Messages, + balance: 400, + }), + ]); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-items.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-items.test.ts similarity index 75% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-items.test.ts rename to server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-items.test.ts index 1295f40aa..6c199048d 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-items.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-items.test.ts @@ -8,8 +8,9 @@ * - Existing customer products are patched, not replaced or expired. */ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared"; +import { CusProductStatus, customerProducts, customers } from "@autumn/shared"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; @@ -21,7 +22,37 @@ import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; +import { and, eq } from "drizzle-orm"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; + +const getActiveCustomerProductIsCustom = async ({ + ctx, + customerId, + productId, +}: { + ctx: Awaited>["ctx"]; + customerId: string; + productId: string; +}) => { + const [row] = await ctx.db + .select({ isCustom: customerProducts.is_custom }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Active), + ), + ); + + return row?.isCustom; +}; test.concurrent(`${chalk.yellowBright("migrations update_plan: add boolean and metered entitlements")}`, async () => { const customerId = "migration-update-add-items"; @@ -87,6 +118,9 @@ test.concurrent(`${chalk.yellowBright("migrations update_plan: add boolean and m count: 1, latestTotal: 20, }); + expect( + await getActiveCustomerProductIsCustom({ ctx, customerId, productId: pro.id }), + ).toBe(false); await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); await expectStripeSubscriptionCorrect({ ctx, customerId }); }); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-paid-features.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-paid-features.test.ts new file mode 100644 index 000000000..4260ae346 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-paid-features.test.ts @@ -0,0 +1,247 @@ +/** + * TDD coverage for update_plan item patch migrations. + * + * Contract under test: + * - update_plan reuses update-subscription patch semantics for add_items, + * remove_items, usage carry, and rollover carry. + * - Migration execution does not create extra invoices. + * - Existing customer products are patched, not replaced or expired. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared"; +import { BillingMethod } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; + +test.concurrent(`${chalk.yellowBright("migrations update_plan: consumable paid feature carries usage without charging")}`, async () => { + const customerId = "migration-update-paid-consumable"; + const messagesUsage = 60; + const included = 50; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [ + itemsV2.dashboard(), + { + ...itemsV2.consumableMessages({ amount: 0.1 }), + included, + }, + ], + }, + }, + ], + }, + }); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + planId: pro.id, + }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 0, + usage: messagesUsage, + planId: pro.id, + breakdown: { + [BillingMethod.UsageBased]: { + included_grant: included, + remaining: 0, + usage: messagesUsage, + }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 1, + latestTotal: 20, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// Red: migration update_plan creates the new prepaid users item with zero paid packs. +// Green: carried usage synthesizes the same inclusive quantity an attach call would receive. +test.concurrent(`${chalk.yellowBright("migrations update_plan: prepaid users replacement keeps carried usage quantity")}`, async () => { + const customerId = "migration-update-paid-prepaid-users"; + const usersUsage = 9; + const pro = products.pro({ + id: "migration-update-paid-prepaid-users-plan", + items: [items.monthlyUsers({ includedUsage: 10 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.track({ featureId: TestFeature.Users, value: usersUsage, timeout: 2000 }), + ], + }); + + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Users }], + add_items: [ + itemsV2.prepaidUsers({ + amount: 20, + included: 1, + }), + ], + }, + }, + ], + }, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Users, + remaining: 0, + usage: usersUsage, + planId: pro.id, + breakdown: { + [BillingMethod.Prepaid]: { + included_grant: 1, + prepaid_grant: 8, + remaining: 0, + usage: usersUsage, + }, + }, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("migrations update_plan: no_billing_changes remove paid feature stays DB-only")}`, async () => { + const customerId = "migration-update-paid-remove-no-billing"; + const pro = products.pro({ + id: "migration-update-paid-remove-no-billing-plan", + items: [items.consumableMessages({ includedUsage: 100, price: 0.1 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const customerBefore = await autumnV1.customers.get(customerId); + const invoiceCountBefore = customerBefore.invoices?.length ?? 0; + const subsBefore = await ctx.stripeCli.subscriptions.list({ + customer: customerBefore.stripe_id as string, + status: "all", + }); + const subBefore = subsBefore.data.find( + (sub) => sub.status === "active" || sub.status === "trialing", + ); + expect(subBefore).toBeDefined(); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + }, + }, + ], + }, + noBillingChanges: true, + runOnServer: false, + }); + + const customer = await autumnV2_2.customers.get(customerId); + await expectCustomerProducts({ customer, active: [pro.id] }); + expect(customer.balances[TestFeature.Messages]).toBeUndefined(); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + + const subAfter = await ctx.stripeCli.subscriptions.retrieve(subBefore!.id); + expectStripeSubscriptionUnchanged({ before: subBefore!, after: subAfter }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-price.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-price.test.ts similarity index 98% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-price.test.ts rename to server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-price.test.ts index 8c3757ac9..ac10d0f28 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-price.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-price.test.ts @@ -12,7 +12,7 @@ import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; test.concurrent(`${chalk.yellowBright("migrations update_plan: update price and add boolean entitlement")}`, async () => { const customerId = "migration-update-price"; diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-scheduled-patch.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-scheduled-patch.test.ts new file mode 100644 index 000000000..1a7c63c88 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/customize/update-plan-op-scheduled-patch.test.ts @@ -0,0 +1,108 @@ +/** + * TDD coverage for scheduled `update_plan` patch/customize migrations. + * + * Contract under test: + * New behaviors: + * - Scheduled customer products are selected by update_plan customize operations. + * - Customize patches mutate the scheduled row in place instead of delete+insert. + * Side effects: + * - No expired scheduled rows are created. + * - Coupled migrations keep Stripe subscription schedules consistent with Autumn. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; +import { + getCustomerProductFeatureIds, + getCustomerProductPriceAmounts, + getScheduledCustomerProductRow, +} from "../utils/scheduledCustomerProductTestUtils"; + +test(`${chalk.yellowBright("migrations update_plan scheduled patch: customize updates scheduled row in place")}`, async () => { + const customerId = "migration-update-scheduled-patch"; + const pro = products.pro({ + id: "scheduled-patch-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + id: "scheduled-patch-premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: premium.id }), + s.billing.attach({ productId: pro.id }), + ], + }); + + const beforeCustomer = await autumnV1.customers.get(customerId); + const invoiceCountBefore = beforeCustomer.invoices?.length ?? 0; + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 24 }), + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + runOnServer: false, + }); + + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledAfter.id).toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(1); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([24]); + expect( + await getCustomerProductFeatureIds({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([TestFeature.Dashboard, TestFeature.Messages]); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/delete-add-preview.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/delete-add-preview.test.ts new file mode 100644 index 000000000..73d7e3036 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/delete-add-preview.test.ts @@ -0,0 +1,285 @@ +/** + * TDD coverage for update_plan delete/add preview output. + * + * Contract under test: + * New types/fields: + * - plan_changes: structured array of plan-change objects, never JSON strings. + * - plan_changes[i].item_changes: structured array of item-change objects. + * - balance_changes: structured balance snapshots with sparse previous_attributes. + * New endpoints: + * - None; existing migrations dry-run item events return response.preview. + * New behaviors: + * - Monthly credits -> one-off prepaid credits emits created/deleted + * item_changes and a balance_change whose post-state has next_reset_at: null. + * - Monthly credits included +100 emits created/deleted item_changes and a + * balance_change for credits reflecting the +100 grant/remaining delta. + * Side effects: + * - Dry-run preview does not execute billing changes. + * + * Pre-impl red: Tinybird-backed event responses can expose nested preview + * fields as JSON strings, and delete/add item changes may be empty. + * Post-impl green: preview consumers receive structured plan/item/balance changes. + */ + +import { expect, test } from "bun:test"; +import { BillingInterval, BillingMethod, ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { + expectMigrationPreviewCorrect, + expectPreviewBalanceChange, + expectPreviewFlagChanges, + expectPreviewPlanChange, +} from "./expectMigrationPreviewCorrect"; +import { runUpdatePlanPreview, waitForPreview } from "./previewTestUtils"; + +test(`${chalk.yellowBright("migrations preview delete/add: API run + list emits monthly credits to one-off changes")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-monthly-to-one-off-${suffix}`; + const pro = products.pro({ + id: `migration-preview-monthly-to-one-off-plan-${suffix}`, + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Credits }], + add_items: [ + { + feature_id: TestFeature.Credits, + included: 150, + price: { + amount: 10, + interval: BillingInterval.OneOff, + billing_method: BillingMethod.Prepaid, + billing_units: 100, + }, + }, + ], + }, + }, + ], + }, + no_billing_changes: true, + }); + const runResponse = await autumnV2_2.migrationsV2.run({ + id: migration.id, + dry_run: true, + }); + const preview = await waitForPreview({ + autumn: autumnV2_2, + migrationId: migration.id, + migrationRunId: runResponse.run_id, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + expect(preview.flag_changes).toEqual([]); + const planChange = expectPreviewPlanChange({ + preview, + action: "updated", + planId: pro.id, + itemChanges: [ + { + action: "created", + feature_id: TestFeature.Credits, + }, + { + action: "deleted", + feature_id: TestFeature.Credits, + }, + ], + }); + const createdCreditsChange = planChange.item_changes.find( + (change) => + change.action === "created" && change.feature_id === TestFeature.Credits, + ); + const deletedCreditsChange = planChange.item_changes.find( + (change) => + change.action === "deleted" && change.feature_id === TestFeature.Credits, + ); + expect(createdCreditsChange?.item).toEqual( + expect.objectContaining({ + feature_id: TestFeature.Credits, + included: 150, + reset: expect.objectContaining({ interval: BillingInterval.OneOff }), + price: expect.objectContaining({ + billing_method: BillingMethod.Prepaid, + interval: BillingInterval.OneOff, + }), + }), + ); + expect(deletedCreditsChange?.item).toEqual( + expect.objectContaining({ + feature_id: TestFeature.Credits, + included: 100, + reset: expect.objectContaining({ interval: ResetInterval.Month }), + }), + ); + const creditsBalanceChange = expectPreviewBalanceChange({ + preview, + featureId: TestFeature.Credits, + balance: { + granted: 150, + remaining: 150, + usage: 0, + next_reset_at: null, + }, + previousAttributes: { + granted: 100, + remaining: 100, + }, + }); + expect(creditsBalanceChange.previous_attributes.next_reset_at).not.toBeNull(); +}); + +test(`${chalk.yellowBright("migrations preview delete/add: monthly included increase is reflected in balance changes")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-included-increase-${suffix}`; + const pro = products.pro({ + id: `migration-preview-included-increase-plan-${suffix}`, + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const preview = await runUpdatePlanPreview({ + autumn: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Credits }], + add_items: [ + { + feature_id: TestFeature.Credits, + included: 200, + reset: { interval: ResetInterval.Month }, + }, + ], + }, + }, + ], + }, + noBillingChanges: true, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + expectPreviewPlanChange({ + preview, + action: "updated", + planId: pro.id, + itemChanges: [ + { + action: "created", + feature_id: TestFeature.Credits, + }, + { + action: "deleted", + feature_id: TestFeature.Credits, + }, + ], + }); + expectPreviewBalanceChange({ + preview, + featureId: TestFeature.Credits, + balance: { + granted: 200, + remaining: 200, + usage: 0, + }, + previousAttributes: { + granted: 100, + remaining: 100, + }, + absentPreviousAttributes: ["usage"], + }); +}); + +test(`${chalk.yellowBright("migrations preview delete/add: boolean item add/remove emits flag changes")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-flags-${suffix}`; + const freePlan = products.base({ + id: `migration-preview-flags-plan-${suffix}`, + items: [items.adminRights()], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [freePlan] })], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + const preview = await runUpdatePlanPreview({ + autumn: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: freePlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: freePlan.id }, + customize: { + remove_items: [{ feature_id: TestFeature.AdminRights }], + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + expect(preview.balance_changes).toEqual([]); + expectPreviewFlagChanges({ + preview, + changes: [ + { action: "deleted", feature_id: TestFeature.AdminRights }, + { action: "created", feature_id: TestFeature.Dashboard }, + ], + }); + expectPreviewPlanChange({ + preview, + action: "updated", + planId: freePlan.id, + itemChanges: [ + { + action: "deleted", + feature_id: TestFeature.AdminRights, + }, + { + action: "created", + feature_id: TestFeature.Dashboard, + }, + ], + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/expectMigrationPreviewCorrect.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/expectMigrationPreviewCorrect.ts new file mode 100644 index 000000000..349048c77 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/expectMigrationPreviewCorrect.ts @@ -0,0 +1,139 @@ +import { expect } from "bun:test"; +import type { + PreviewBalanceChange, + PreviewMigrateCustomer, + PreviewPlanChange, + PreviewPlanItemChange, +} from "./previewTestUtils"; + +type PreviewBalanceExpectation = Partial; + +const getPreviewPlanId = (change: PreviewPlanChange): string | undefined => + change.subscription?.plan_id ?? change.purchase?.plan_id; + +export const logMigrationPreview = ({ + preview, + log = true, +}: { + preview: PreviewMigrateCustomer; + log?: boolean; +}) => { + if (!log) return; + console.log("MIGRATION_PREVIEW", JSON.stringify(preview, null, 2)); +}; + +export const expectMigrationPreviewCorrect = ({ + preview, + customerId, + log = true, +}: { + preview: PreviewMigrateCustomer; + customerId?: string; + log?: boolean; +}) => { + logMigrationPreview({ preview, log }); + expect(preview.object).toBe("migration_customer_preview"); + if (customerId) expect(preview.customer_id).toBe(customerId); + + expect(Array.isArray(preview.plan_changes)).toBe(true); + expect(Array.isArray(preview.balance_changes)).toBe(true); + expect(Array.isArray(preview.flag_changes)).toBe(true); + + for (const planChange of preview.plan_changes) { + expect(typeof planChange).toBe("object"); + expect(planChange).not.toBeNull(); + expect(Array.isArray(planChange.item_changes)).toBe(true); + for (const itemChange of planChange.item_changes) { + expect(itemChange.item).toEqual( + expect.objectContaining({ + feature_id: itemChange.feature_id, + }), + ); + } + } +}; + +export const expectPreviewPlanChange = ({ + preview, + action, + planId, + itemChanges, +}: { + preview: PreviewMigrateCustomer; + action: PreviewPlanChange["action"]; + planId: string; + itemChanges?: Partial[]; +}): PreviewPlanChange => { + const matchingPlanChanges = preview.plan_changes.filter( + (change) => change.action === action && getPreviewPlanId(change) === planId, + ); + const planChange = itemChanges + ? matchingPlanChanges.find((change) => change.item_changes.length > 0) + : matchingPlanChanges[0]; + expect(planChange).toBeDefined(); + + if (itemChanges) { + expect(planChange?.item_changes).toEqual( + expect.arrayContaining( + itemChanges.map((itemChange) => expect.objectContaining(itemChange)), + ), + ); + } + + return planChange!; +}; + +export const expectPreviewBalanceChange = ({ + preview, + featureId, + balance, + previousAttributes, + absentPreviousAttributes = [], +}: { + preview: PreviewMigrateCustomer; + featureId: string; + balance?: PreviewBalanceExpectation; + previousAttributes?: Record; + absentPreviousAttributes?: string[]; +}): PreviewBalanceChange => { + const balanceChange = preview.balance_changes.find( + (change) => change.feature_id === featureId, + ); + expect(balanceChange).toBeDefined(); + + if (balance) { + expect(balanceChange?.balance).toEqual(expect.objectContaining(balance)); + } + if (previousAttributes) { + expect(balanceChange?.previous_attributes).toEqual( + expect.objectContaining(previousAttributes), + ); + } + for (const field of absentPreviousAttributes) { + expect(balanceChange?.previous_attributes).not.toHaveProperty(field); + } + + return balanceChange!; +}; + +export const expectNoPreviewBalanceChange = ({ + preview, + featureId, +}: { + preview: PreviewMigrateCustomer; + featureId: string; +}) => { + expect( + preview.balance_changes.some((change) => change.feature_id === featureId), + ).toBe(false); +}; + +export const expectPreviewFlagChanges = ({ + preview, + changes, +}: { + preview: PreviewMigrateCustomer; + changes: Array<{ action: "created" | "deleted"; feature_id: string }>; +}) => { + expect(preview.flag_changes).toEqual(expect.arrayContaining(changes)); +}; diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/previewTestUtils.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/previewTestUtils.ts new file mode 100644 index 000000000..bec739e24 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/previewTestUtils.ts @@ -0,0 +1,153 @@ +import type { Migration } from "@autumn/shared"; +import type { + CustomerPlanChange, + CustomerPlanItemChange, +} from "@autumn/shared/api/billing/common/customerPlanChange.js"; +import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; +import { logMigrationPreview } from "./expectMigrationPreviewCorrect"; + +type MigrationItemEvent = { + status: string; + dry_run: boolean; + item_id: string; + response: unknown; +}; + +type MigrationClient = { + migrationsV2: { + deleteAndCreate: (params: { + id: string; + filter?: MigrationFilter | null; + operations?: Operations | null; + no_billing_changes?: boolean; + }) => Promise; + run: (params: { id: string; dry_run?: boolean }) => Promise<{ + migration_id: string; + dry_run: boolean; + run_id: string; + }>; + listItemEvents: (params: { + migrationId: string; + migrationRunId?: string; + }) => Promise<{ list: MigrationItemEvent[] }>; + }; +}; + +export type PreviewPlanItemChange = CustomerPlanItemChange; + +export type PreviewPlanChange = CustomerPlanChange; + +export type PreviewBalanceChange = { + feature_id: string; + balance: { + granted: number; + remaining: number; + usage: number; + unlimited: boolean; + next_reset_at: number | null; + }; + previous_attributes: Record; +}; + +export type PreviewMigrateCustomer = { + object: "migration_customer_preview"; + customer_id: string; + plan_changes: PreviewPlanChange[]; + balance_changes: PreviewBalanceChange[]; + flag_changes: PreviewFlagChange[]; +}; + +export type PreviewFlagChange = { + action: "created" | "deleted"; + feature_id: string; +}; + +const timeout = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const parseResponse = (response: unknown): Record => { + if (typeof response === "string") return JSON.parse(response); + if (response && typeof response === "object") + return response as Record; + throw new Error(`Invalid migration event response: ${String(response)}`); +}; + +export const waitForPreview = async ({ + autumn, + migrationId, + migrationRunId, + timeoutMs = 45_000, + log = true, +}: { + autumn: MigrationClient; + migrationId: string; + migrationRunId: string; + timeoutMs?: number; + log?: boolean; +}): Promise => { + const start = Date.now(); + let lastError: unknown; + + while (Date.now() - start < timeoutMs) { + try { + const events = await autumn.migrationsV2.listItemEvents({ + migrationId, + migrationRunId, + }); + const event = events.list[0]; + if (!event) throw new Error("No migration item event found"); + const response = parseResponse(event.response); + const preview = response.preview; + if (!preview || typeof preview !== "object" || Array.isArray(preview)) { + throw new Error("Migration item event missing structured preview"); + } + const typedPreview = preview as PreviewMigrateCustomer; + logMigrationPreview({ preview: typedPreview, log }); + return typedPreview; + } catch (error) { + lastError = error; + await timeout(1_000); + } + } + + throw new Error( + `Timed out waiting for migration preview: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +}; + +export const runUpdatePlanPreview = async ({ + autumn, + migrationId, + filter, + operations, + noBillingChanges, + log = true, +}: { + autumn: MigrationClient; + migrationId: string; + filter: MigrationFilter; + operations: Operations; + noBillingChanges?: boolean; + log?: boolean; +}): Promise => { + const migration = await autumn.migrationsV2.deleteAndCreate({ + id: migrationId, + filter, + operations, + no_billing_changes: noBillingChanges, + }); + const runResponse = await autumn.migrationsV2.run({ + id: migration.id, + dry_run: true, + }); + + return waitForPreview({ + autumn, + migrationId: migration.id, + migrationRunId: runResponse.run_id, + log, + }); +}; diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/scheduled-duplicate-items-preview.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/scheduled-duplicate-items-preview.test.ts new file mode 100644 index 000000000..092fd3d86 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/scheduled-duplicate-items-preview.test.ts @@ -0,0 +1,108 @@ +/** + * Active and scheduled rows for the same plan must stay separate in previews. + * Merging them duplicates boolean item_changes and hides the scheduled scope. + */ + +import { expect, test } from "bun:test"; +import { ms } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { expectMigrationPreviewCorrect } from "./expectMigrationPreviewCorrect"; +import type { PreviewMigrateCustomer, PreviewPlanChange } from "./previewTestUtils"; +import { runUpdatePlanPreview } from "./previewTestUtils"; + +const getPreviewPlanId = (change: PreviewPlanChange): string | undefined => + change.subscription?.plan_id ?? change.purchase?.plan_id; + +const getUpdatedPlanChanges = ({ + preview, + planId, +}: { + preview: PreviewMigrateCustomer; + planId: string; +}) => + preview.plan_changes.filter( + (change) => change.action === "updated" && getPreviewPlanId(change) === planId, + ); + +const getCreatedFeatureIds = (change: PreviewPlanChange) => + change.item_changes + .filter((itemChange) => itemChange.action === "created") + .map((itemChange) => itemChange.feature_id) + .sort(); + +test(`${chalk.yellowBright("migrations preview scheduled: same-plan active and scheduled updates do not duplicate item changes")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-scheduled-duplicate-${suffix}`; + const plan = products.base({ + id: `migration-preview-scheduled-duplicate-plan-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [plan] })], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: plan.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: plan.id }], + }, + ], + }); + + const preview = await runUpdatePlanPreview({ + autumn: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: plan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id }, + customize: { + add_items: [ + itemsV2.dashboard(), + { feature_id: TestFeature.AdminRights }, + ], + }, + }, + ], + }, + log: false, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + expect( + preview.flag_changes.filter( + (change) => change.feature_id === TestFeature.AdminRights, + ), + ).toHaveLength(1); + expect( + preview.flag_changes.filter( + (change) => change.feature_id === TestFeature.Dashboard, + ), + ).toHaveLength(1); + + const planChanges = getUpdatedPlanChanges({ preview, planId: plan.id }); + expect(planChanges).toHaveLength(2); + for (const planChange of planChanges) { + expect(getCreatedFeatureIds(planChange)).toEqual([ + TestFeature.AdminRights, + TestFeature.Dashboard, + ]); + } +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/selection-preview.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/selection-preview.test.ts new file mode 100644 index 000000000..d5af3d229 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/selection-preview.test.ts @@ -0,0 +1,66 @@ +/** + * Preview coverage for update_plan selection metadata. + * + * Contract under test: + * - Entity-scoped customer products still surface webhook-style plan changes. + */ + +import { test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { + expectMigrationPreviewCorrect, + expectPreviewPlanChange, +} from "./expectMigrationPreviewCorrect"; +import { runUpdatePlanPreview } from "./previewTestUtils"; + +test(`${chalk.yellowBright("migrations preview selection: entity-scoped plan changes use webhook shape")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-entity-${suffix}`; + const entityPlan = products.base({ + id: `migration-preview-entity-plan-${suffix}`, + items: [], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer(), + s.entities({ count: 1, featureId: TestFeature.Users }), + s.products({ list: [entityPlan] }), + ], + actions: [ + s.billing.attach({ + productId: entityPlan.id, + entityIndex: 0, + }), + ], + }); + + const preview = await runUpdatePlanPreview({ + autumn: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: entityPlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: entityPlan.id }, + customize: { + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + expectPreviewPlanChange({ + preview, + action: "updated", + planId: entityPlan.id, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/state-preservation-preview.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/state-preservation-preview.test.ts new file mode 100644 index 000000000..ccbd9cc8a --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/state-preservation-preview.test.ts @@ -0,0 +1,97 @@ +/** + * TDD coverage for update_plan preview state-preservation scenarios. + * + * Contract under test: + * New behaviors: + * - Same-feature delete/add previews preserve carried usage in the post + * balance snapshot. + * - previous_attributes contains old grant/remaining values and omits + * usage when usage itself did not change. + */ + +import { test } from "bun:test"; +import { ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { + expectMigrationPreviewCorrect, + expectPreviewBalanceChange, + expectPreviewPlanChange, +} from "./expectMigrationPreviewCorrect"; +import { runUpdatePlanPreview } from "./previewTestUtils"; + +test(`${chalk.yellowBright("migrations preview state: same-feature replacement carries usage into balance change")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-carry-usage-${suffix}`; + const base = products.base({ + id: `migration-preview-carry-usage-plan-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [base] })], + actions: [ + s.billing.attach({ productId: base.id }), + s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }), + ], + }); + + const preview = await runUpdatePlanPreview({ + autumn: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [ + { + feature_id: TestFeature.Messages, + included: 200, + reset: { interval: ResetInterval.Month }, + }, + ], + }, + }, + ], + }, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + expectPreviewPlanChange({ + preview, + action: "updated", + planId: base.id, + itemChanges: [ + { + action: "created", + feature_id: TestFeature.Messages, + }, + { + action: "deleted", + feature_id: TestFeature.Messages, + }, + ], + }); + expectPreviewBalanceChange({ + preview, + featureId: TestFeature.Messages, + balance: { + granted: 200, + remaining: 170, + usage: 30, + }, + previousAttributes: { + granted: 100, + remaining: 70, + }, + absentPreviousAttributes: ["usage"], + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/update-items-preview.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/update-items-preview.test.ts new file mode 100644 index 000000000..927a91655 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/update-items-preview.test.ts @@ -0,0 +1,149 @@ +/** + * Preview coverage for legacy update_items migrations. + * + * Contract under test: + * - update_items balance_changes use the balance snapshot + + * previous_attributes shape. + * - Untouched features do not emit balance_changes. + * - Carried usage remains in the post-preview balance and is omitted from + * previous_attributes when unchanged. + */ + +import { test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { + expectMigrationPreviewCorrect, + expectNoPreviewBalanceChange, + expectPreviewBalanceChange, + expectPreviewPlanChange, +} from "./expectMigrationPreviewCorrect"; +import { runUpdatePlanPreview } from "./previewTestUtils"; + +test(`${chalk.yellowBright("migrations preview update_items: emits balance snapshot and item change")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-update-items-${suffix}`; + const freePlan = products.base({ + id: `migration-preview-update-items-plan-${suffix}`, + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyCredits({ includedUsage: 50 }), + ], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [freePlan] })], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + const preview = await runUpdatePlanPreview({ + autumn: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: freePlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: freePlan.id }, + customize: { + update_items: [ + { filter: { feature_id: TestFeature.Messages }, included: 250 }, + ], + }, + }, + ], + }, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + expectNoPreviewBalanceChange({ + preview, + featureId: TestFeature.Credits, + }); + expectPreviewBalanceChange({ + preview, + featureId: TestFeature.Messages, + balance: { + granted: 250, + remaining: 250, + usage: 0, + }, + previousAttributes: { + granted: 100, + remaining: 100, + }, + absentPreviousAttributes: ["usage"], + }); + expectPreviewPlanChange({ + preview, + action: "updated", + planId: freePlan.id, + itemChanges: [ + { + action: "created", + feature_id: TestFeature.Messages, + }, + { + action: "deleted", + feature_id: TestFeature.Messages, + }, + ], + }); +}); + +test(`${chalk.yellowBright("migrations preview update_items: carried usage is preserved in balance change")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-update-items-usage-${suffix}`; + const freePlan = products.base({ + id: `migration-preview-update-items-usage-plan-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [freePlan] })], + actions: [ + s.billing.attach({ productId: freePlan.id }), + s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }), + ], + }); + + const preview = await runUpdatePlanPreview({ + autumn: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: freePlan.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: freePlan.id }, + customize: { + update_items: [ + { filter: { feature_id: TestFeature.Messages }, included: 300 }, + ], + }, + }, + ], + }, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + expectPreviewBalanceChange({ + preview, + featureId: TestFeature.Messages, + balance: { + granted: 300, + remaining: 270, + usage: 30, + }, + previousAttributes: { + granted: 100, + remaining: 70, + }, + absentPreviousAttributes: ["usage"], + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/versioning-preview.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/versioning-preview.test.ts new file mode 100644 index 000000000..668cad9a8 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/previews/versioning-preview.test.ts @@ -0,0 +1,84 @@ +/** + * TDD coverage for update_plan version preview scenarios. + * + * Contract under test: + * New behaviors: + * - Version previews use the webhook-shaped plan change contract. + * - Version previews emit balance_changes for metered grant changes and + * flag_changes for boolean removals. + */ + +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { + expectMigrationPreviewCorrect, + expectPreviewBalanceChange, + expectPreviewFlagChanges, + expectPreviewPlanChange, +} from "./expectMigrationPreviewCorrect"; +import { runUpdatePlanPreview } from "./previewTestUtils"; + +test(`${chalk.yellowBright("migrations preview version: emits plan, balance, and flag changes")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-preview-version-update-${suffix}`; + const base = products.base({ + id: `migration-preview-version-update-plan-${suffix}`, + items: [items.monthlyMessages({ includedUsage: 100 }), items.adminRights()], + }); + + const { autumnV1, autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer(), s.products({ list: [base] })], + actions: [s.billing.attach({ productId: base.id })], + }); + + await autumnV1.products.update(base.id, { + items: [ + items.monthlyMessages({ includedUsage: 200 }), + items.monthlyCredits({ includedUsage: 50 }), + ], + }); + + const preview = await runUpdatePlanPreview({ + autumn: autumnV2_2, + migrationId: `${customerId}-mig`, + filter: { customer: { plan: { plan_id: base.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: base.id }, + version: 2, + }, + ], + }, + }); + + expectMigrationPreviewCorrect({ preview, customerId, log: false }); + const planChange = expectPreviewPlanChange({ + preview, + action: "updated", + planId: base.id, + }); + expect(planChange.item_changes).toEqual([]); + expectPreviewBalanceChange({ + preview, + featureId: TestFeature.Messages, + balance: { granted: 200, remaining: 200, usage: 0 }, + previousAttributes: { granted: 100, remaining: 100 }, + }); + expectPreviewBalanceChange({ + preview, + featureId: TestFeature.Credits, + balance: { granted: 50, remaining: 50, usage: 0 }, + previousAttributes: { granted: 0, remaining: 0 }, + }); + expectPreviewFlagChanges({ + preview, + changes: [{ action: "deleted", feature_id: TestFeature.AdminRights }], + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/plan-filter/plan-filter-version.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/plan-filter-version.test.ts similarity index 100% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/plan-filter/plan-filter-version.test.ts rename to server/tests/integration/billing/migrations-v2/update-plan-operation/selection/plan-filter-version.test.ts diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-custom.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-custom.test.ts similarity index 57% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-custom.test.ts rename to server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-custom.test.ts index 5d9c58fad..afd7445e8 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-custom.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-custom.test.ts @@ -14,9 +14,24 @@ */ import { expect, test } from "bun:test"; -import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + CusProductStatus, + customerEntitlements, + customerPrices, + customerProducts, + customers, + entitlements, + features, + prices, + ResetInterval, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; import { TestFeature } from "@tests/setup/v2Features"; import { items } from "@tests/utils/fixtures/items"; @@ -24,7 +39,154 @@ import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; +import { and, eq, isNull } from "drizzle-orm"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; + +const getActiveCustomerProductIsCustom = async ({ + ctx, + customerId, + productId, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; +}): Promise => { + const [row] = await ctx.db + .select({ isCustom: customerProducts.is_custom }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Active), + ), + ); + + return row?.isCustom; +}; + +const getActiveCustomerProductFeatureIds = async ({ + ctx, + customerId, + productId, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; +}): Promise => { + const rows = await ctx.db + .select({ featureId: features.id }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + customerEntitlements, + eq(customerEntitlements.customer_product_id, customerProducts.id), + ) + .innerJoin( + entitlements, + eq(customerEntitlements.entitlement_id, entitlements.id), + ) + .innerJoin(features, eq(entitlements.internal_feature_id, features.internal_id)) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Active), + ), + ); + + return rows.map((row) => row.featureId); +}; + +const getActiveBasePriceAmount = async ({ + ctx, + customerId, + productId, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; +}): Promise => { + const [row] = await ctx.db + .select({ config: prices.config }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + customerPrices, + eq(customerPrices.customer_product_id, customerProducts.id), + ) + .innerJoin(prices, eq(customerPrices.price_id, prices.id)) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Active), + isNull(prices.entitlement_id), + ), + ); + + const config = row?.config; + return config && "amount" in config && typeof config.amount === "number" + ? config.amount + : undefined; +}; + +const getActiveFeatureResetInterval = async ({ + ctx, + customerId, + productId, + featureId, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; + featureId: string; +}): Promise => { + const [row] = await ctx.db + .select({ interval: entitlements.interval }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + customerEntitlements, + eq(customerEntitlements.customer_product_id, customerProducts.id), + ) + .innerJoin( + entitlements, + eq(customerEntitlements.entitlement_id, entitlements.id), + ) + .innerJoin(features, eq(entitlements.internal_feature_id, features.internal_id)) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Active), + eq(features.id, featureId), + ), + ); + + return row?.interval; +}; test.concurrent(`${chalk.yellowBright("update_plan custom: customer with is_custom plan is skipped")}`, async () => { const customerId = "migration-v2-custom-skip"; @@ -403,3 +565,183 @@ test.concurrent(`${chalk.yellowBright("update_plan custom: explicit `custom: tru usage: 0, }); }); + +test.concurrent(`${chalk.yellowBright("update_plan reset: same-version custom plan resets to catalog")}`, async () => { + const customerId = "migration-v2-same-version-custom-reset"; + const catalogBasePrice = 20; + const customBasePrice = 30; + const customMessages = { + ...itemsV2.monthlyMessages({ included: 850 }), + reset: { interval: ResetInterval.Hour }, + }; + + const pro = products.pro({ + id: "v2-same-version-reset-pro", + items: [ + items.monthlyMessages({ includedUsage: 500 }), + items.adminRights(), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: customBasePrice }), + items: [customMessages, itemsV2.dashboard()], + }, + }); + let customer = await autumnV2_2.customers.get(customerId); + expectFlagCorrect({ + customer, + featureId: TestFeature.Dashboard, + present: true, + }); + expectFlagCorrect({ + customer, + featureId: TestFeature.AdminRights, + present: false, + }); + expect( + await getActiveCustomerProductIsCustom({ ctx, customerId, productId: pro.id }), + ).toBe(true); + expect( + await getActiveBasePriceAmount({ ctx, customerId, productId: pro.id }), + ).toBe(customBasePrice); + expect( + await getActiveFeatureResetInterval({ + ctx, + customerId, + productId: pro.id, + featureId: TestFeature.Messages, + }), + ).toBe(ResetInterval.Hour); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 850, + usage: 0, + planId: pro.id, + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { customer_id: customerId } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id, version: 1 }, + version: 1, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + customer = await autumnV2_2.customers.get(customerId); + const featureIds = await getActiveCustomerProductFeatureIds({ + ctx, + customerId, + productId: pro.id, + }); + expect(featureIds).not.toContain(TestFeature.Dashboard); + expect(featureIds).toContain(TestFeature.AdminRights); + expect( + await getActiveBasePriceAmount({ ctx, customerId, productId: pro.id }), + ).toBe(catalogBasePrice); + expect( + await getActiveFeatureResetInterval({ + ctx, + customerId, + productId: pro.id, + featureId: TestFeature.Messages, + }), + ).toBe(ResetInterval.Month); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 500, + usage: 0, + planId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("update_plan reset: same-version regular plan stays non-custom")}`, async () => { + const customerId = "migration-v2-same-version-regular-reset"; + + const pro = products.pro({ + id: "v2-same-version-regular-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.track({ featureId: TestFeature.Messages, value: 100, timeout: 2000 }), + ], + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id, version: 1 } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id, version: 1 }, + version: 1, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 400, + usage: 100, + planId: pro.id, + }); + expect( + await getActiveCustomerProductIsCustom({ ctx, customerId, productId: pro.id }), + ).toBe(false); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-multi-targets.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-multi-targets.test.ts similarity index 99% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-multi-targets.test.ts rename to server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-multi-targets.test.ts index 4bf8c4d71..d889401f2 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-multi-targets.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-multi-targets.test.ts @@ -27,7 +27,7 @@ import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import { and, eq } from "drizzle-orm"; -import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; const getCustomerProductPriceAmounts = async ({ ctx, diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-scheduled-dangling.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-scheduled-dangling.test.ts new file mode 100644 index 000000000..a9ec50fcb --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/selection/update-plan-op-scheduled-dangling.test.ts @@ -0,0 +1,163 @@ +/** + * TDD coverage for server-run migrations when Autumn scheduled rows are missing. + * + * Contract under test: + * New behaviors: + * - A server-run migration can still update selected non-scheduled rows when + * a Stripe schedule exists but its Autumn scheduled customer product was deleted. + * Side effects: + * - `no_billing_changes: true` must not mutate the existing Stripe subscription schedule. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { CusProductStatus, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario } from "@tests/utils/testInitUtils/initScenario"; +import { s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; +import { + deleteCustomerProductRows, + expectNoCustomerProductRow, + getCustomerProductFeatureIds, + getCustomerProductRows, + getRequiredStripeScheduleId, + getScheduledCustomerProductRow, +} from "../utils/scheduledCustomerProductTestUtils"; + +const stripeScheduleSignature = (schedule: Stripe.SubscriptionSchedule) => ({ + status: schedule.status, + currentPhase: schedule.current_phase, + phases: schedule.phases.map((phase) => ({ + startDate: phase.start_date, + endDate: phase.end_date, + items: phase.items.map((item) => ({ + price: typeof item.price === "string" ? item.price : item.price.id, + quantity: item.quantity, + })), + })), +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled dangling: server-run no billing does not touch Stripe schedule")}`, async () => { + const customerId = "migration-update-scheduled-dangling"; + const pro = products.pro({ + id: "scheduled-dangling-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "scheduled-dangling-premium", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: premium.id }], + }, + ], + }); + + const scheduledPremium = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: premium.id, + }); + const stripeScheduleId = getRequiredStripeScheduleId({ + scheduledIds: scheduledPremium.scheduledIds, + }); + const stripeScheduleBefore = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + const stripeSignatureBefore = stripeScheduleSignature( + stripeScheduleBefore as Stripe.SubscriptionSchedule, + ); + + await deleteCustomerProductRows({ + ctx, + customerProductIds: [scheduledPremium.id], + }); + await expectNoCustomerProductRow({ + ctx, + customerProductId: scheduledPremium.id, + }); + + const expectActivePlanUpdated = async () => { + const activeRows = await getCustomerProductRows({ + ctx, + customerId, + productId: pro.id, + status: CusProductStatus.Active, + }); + expect(activeRows).toHaveLength(1); + expect( + await getCustomerProductFeatureIds({ + ctx, + customerProductId: activeRows[0]!.id, + }), + ).toEqual([TestFeature.Dashboard, TestFeature.Messages]); + }; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + noBillingChanges: true, + runOnServer: true, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + waitFor: expectActivePlanUpdated, + timeoutMs: 60_000, + }); + await expectActivePlanUpdated(); + + const stripeScheduleAfter = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + expect(stripeScheduleSignature(stripeScheduleAfter as Stripe.SubscriptionSchedule)).toEqual( + stripeSignatureBefore, + ); + expect( + await getCustomerProductRows({ + ctx, + customerId, + productId: premium.id, + status: CusProductStatus.Scheduled, + }), + ).toEqual([]); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 1, + latestTotal: 20, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/balances/delete-add-carry.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/balances/delete-add-carry.test.ts new file mode 100644 index 000000000..dc4a9a164 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/balances/delete-add-carry.test.ts @@ -0,0 +1,408 @@ +/** + * Contract: delete/add patch migrations carry same-feature usage, one-off prepaid balance, and reset anchors. + * These scenarios intentionally avoid update_items; item changes are remove_items + add_items. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared"; +import { + BillingInterval, + BillingMethod, + customerEntitlements, + ResetInterval, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { getBalanceBucket } from "@tests/integration/utils/getBalanceBucket"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { and, eq } from "drizzle-orm"; +import { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration"; + +const TEN_MINUTES_MS = 10 * 60 * 1000; + +const expectCloseToMs = ({ + actual, + expected, +}: { + actual?: number | null; + expected: number; +}) => { + expect(actual).not.toBeNull(); + expect(Math.abs((actual ?? 0) - expected)).toBeLessThanOrEqual( + TEN_MINUTES_MS, + ); +}; + +test.concurrent(`${chalk.yellowBright("migrations complex delete/add: lifetime item to monthly carries usage onto subscription reset")}`, async () => { + const customerId = "migration-complex-lifetime-to-monthly"; + const pro = products.pro({ + id: "migration-complex-lifetime-to-monthly-plan", + items: [items.lifetimeMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ days: 10 }), + s.track({ featureId: TestFeature.Messages, value: 40, timeout: 2000 }), + ], + }); + const before = await autumnV2_2.customers.get(customerId); + const currentPeriodEnd = before.subscriptions.find( + (subscription) => subscription.plan_id === pro.id, + )?.current_period_end; + expect(currentPeriodEnd).not.toBeNull(); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [itemsV2.monthlyMessages({ included: 150 })], + }, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 110, + usage: 40, + nextResetAt: currentPeriodEnd!, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { + included_grant: 150, + remaining: 110, + usage: 40, + }, + }, + }); + const monthlyBucket = getBalanceBucket({ + subject: customer, + featureId: TestFeature.Messages, + resetInterval: ResetInterval.Month, + }); + expectCloseToMs({ + actual: monthlyBucket.reset?.resets_at, + expected: currentPeriodEnd!, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations complex delete/add: monthly to one-off clears reset timestamp")}`, async () => { + const suffix = Date.now(); + const customerId = `migration-complex-monthly-to-one-off-${suffix}`; + const pro = products.pro({ + id: `${customerId}-plan`, + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Credits }], + add_items: [ + { + feature_id: TestFeature.Credits, + included: 150, + price: { + amount: 10, + interval: BillingInterval.OneOff, + billing_method: BillingMethod.Prepaid, + billing_units: 100, + }, + }, + ], + }, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Credits, + remaining: 150, + usage: 0, + nextResetAt: null, + planId: pro.id, + breakdown: { + [ResetInterval.OneOff]: { + included_grant: 150, + remaining: 150, + usage: 0, + }, + }, + }); + const oneOffBucket = getBalanceBucket({ + subject: customer, + featureId: TestFeature.Credits, + resetInterval: ResetInterval.OneOff, + }); + expect(oneOffBucket.reset?.resets_at).toBeNull(); + const [oneOffCustomerEntitlement] = await ctx.db + .select() + .from(customerEntitlements) + .where( + and( + eq(customerEntitlements.customer_id, customerId), + eq(customerEntitlements.feature_id, TestFeature.Credits), + ), + ); + expect(oneOffCustomerEntitlement?.next_reset_at).toBeNull(); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations complex delete/add: monthly plus one-off prepaid to monthly carries usage and lifetime balance")}`, async () => { + const customerId = "migration-complex-monthly-oneoff-to-monthly"; + const pro = products.pro({ + id: "migration-complex-monthly-oneoff-to-monthly-plan", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.oneOffMessages({ includedUsage: 0, billingUnits: 100, price: 10 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + s.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }), + ], + }); + const before = await autumnV2_2.customers.get(customerId); + const currentPeriodEnd = before.subscriptions.find( + (subscription) => subscription.plan_id === pro.id, + )?.current_period_end; + expect(currentPeriodEnd).not.toBeNull(); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [itemsV2.monthlyMessages({ included: 300 })], + }, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 350, + usage: 100, + nextResetAt: currentPeriodEnd!, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { + included_grant: 300, + remaining: 200, + usage: 100, + }, + [ResetInterval.OneOff]: { + included_grant: 150, + prepaid_grant: 0, + remaining: 150, + usage: 0, + }, + }, + }); + const monthlyBucket = getBalanceBucket({ + subject: customer, + featureId: TestFeature.Messages, + resetInterval: ResetInterval.Month, + }); + expectCloseToMs({ + actual: monthlyBucket.reset?.resets_at, + expected: currentPeriodEnd!, + }); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); + +test.concurrent(`${chalk.yellowBright("migrations complex delete/add: monthly included increase plus one-off price change preserves both buckets")}`, async () => { + const customerId = "migration-complex-monthly-oneoff-price-change"; + const pro = products.pro({ + id: "migration-complex-monthly-oneoff-price-change-plan", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.oneOffMessages({ includedUsage: 0, billingUnits: 100, price: 10 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + s.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }), + ], + }); + const before = await autumnV2_2.customers.get(customerId); + const currentPeriodEnd = before.subscriptions.find( + (subscription) => subscription.plan_id === pro.id, + )?.current_period_end; + expect(currentPeriodEnd).not.toBeNull(); + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices?.length ?? + 0; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [ + itemsV2.monthlyMessages({ included: 300 }), + itemsV2.oneOffPrepaidMessages({ + amount: 15, + billingUnits: 100, + }), + ], + }, + }, + ], + }, + runOnServer: false, + noBillingChanges: true, + }); + + const customer = await autumnV2_2.customers.get(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining: 350, + usage: 100, + nextResetAt: currentPeriodEnd!, + planId: pro.id, + breakdown: { + [ResetInterval.Month]: { + included_grant: 300, + remaining: 200, + usage: 100, + }, + [BillingMethod.Prepaid]: { + included_grant: 150, + prepaid_grant: 0, + remaining: 150, + usage: 0, + }, + }, + }); + const monthlyBucket = getBalanceBucket({ + subject: customer, + featureId: TestFeature.Messages, + resetInterval: ResetInterval.Month, + }); + const prepaidBucket = getBalanceBucket({ + subject: customer, + featureId: TestFeature.Messages, + billingMethod: BillingMethod.Prepaid, + }); + expectCloseToMs({ + actual: monthlyBucket.reset?.resets_at, + expected: currentPeriodEnd!, + }); + expect(prepaidBucket.reset?.interval).toBe(ResetInterval.OneOff); + expect(prepaidBucket.price?.amount).toBe(15); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-carry-rollover.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/balances/update-plan-op-carry-rollover.test.ts similarity index 97% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-carry-rollover.test.ts rename to server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/balances/update-plan-op-carry-rollover.test.ts index d6515decf..71c0f5bc1 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-carry-rollover.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/balances/update-plan-op-carry-rollover.test.ts @@ -24,7 +24,7 @@ import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; +import { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration"; test.concurrent(`${chalk.yellowBright("migrations update_plan: metered rollover carries to added item")}`, async () => { const customerId = "migration-update-carry-rollover"; diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-carry-usage.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/balances/update-plan-op-carry-usage.test.ts similarity index 97% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-carry-usage.test.ts rename to server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/balances/update-plan-op-carry-usage.test.ts index 6e7b174af..1e924ae4c 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-carry-usage.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/balances/update-plan-op-carry-usage.test.ts @@ -21,7 +21,7 @@ import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; +import { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration"; test.concurrent(`${chalk.yellowBright("migrations update_plan: same-feature replacement carries only matching usage")}`, async () => { const customerId = "migration-update-carry-usage-same-feature"; diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/subscriptions/update-plan-op-states.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/subscriptions/update-plan-op-states.test.ts new file mode 100644 index 000000000..85ceab443 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/state-preservation/subscriptions/update-plan-op-states.test.ts @@ -0,0 +1,598 @@ +/** + * TDD coverage for update_plan migrations preserving in-flight subscription + * states. + * + * Contract under test: + * - Updating the active plan's base price does not clear a scheduled downgrade. + * - Updating a canceling plan's base price does not clear end-of-cycle cancel. + * - Entity-scoped and multi-product states survive a customer migration. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiEntityV0, + CusProductStatus, + customerPrices, + customerProducts, + customers, + findActiveCustomerProductById, + prices, +} from "@autumn/shared"; +import { + expectCustomerProducts, + expectProductCanceling, + expectProductNotPresent, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectCustomerProductStatuses } from "@tests/integration/billing/utils/expectCustomerProductStatuses"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { and, eq, isNull } from "drizzle-orm"; +import { CusService } from "@/internal/customers/CusService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration"; + +const getScheduledIds = async ({ + ctx, + customerId, + productId, + entityId, +}: { + ctx: Awaited>["ctx"]; + customerId: string; + productId: string; + entityId?: string; +}) => + ( + await ctx.db + .select({ scheduledIds: customerProducts.scheduled_ids }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + eq(customerProducts.status, CusProductStatus.Scheduled), + entityId + ? eq(customerProducts.entity_id, entityId) + : isNull(customerProducts.entity_id), + ), + ) + ) + .flatMap((row) => row.scheduledIds ?? []) + .sort(); + +const getCustomerProductPriceAmounts = async ({ + ctx, + customerId, + productId, + entityId, +}: { + ctx: Awaited>["ctx"]; + customerId: string; + productId: string; + entityId?: string; +}) => + ( + await ctx.db + .select({ config: prices.config }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + customerPrices, + eq(customerPrices.customer_product_id, customerProducts.id), + ) + .innerJoin(prices, eq(customerPrices.price_id, prices.id)) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + entityId + ? eq(customerProducts.entity_id, entityId) + : isNull(customerProducts.entity_id), + ), + ) + ) + .map((row) => + row.config && "amount" in row.config ? row.config.amount : undefined, + ) + .filter((amount): amount is number => typeof amount === "number") + .sort((a, b) => a - b); + +// Red: version update_plan replacement reset a past_due cusProduct to active. +// Green: the replacement inherits past_due while the old row expires. +test.concurrent( + `${chalk.yellowBright("migrations update_plan states: past_due survives version update")}`, + async () => { + const customerId = "migration-update-state-past-due"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const fullCustomerBefore = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const cusProductBefore = findActiveCustomerProductById({ + fullCus: fullCustomerBefore, + productId: pro.id, + }); + expect(cusProductBefore).toBeDefined(); + + await CusProductService.update({ + ctx, + cusProductId: cusProductBefore!.id, + updates: { status: CusProductStatus.PastDue }, + }); + + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 600 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + version: 2, + }, + ], + }, + }); + + const customerAfter = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfter, + pastDue: [pro.id], + }); + + const { byStatus } = await expectCustomerProductStatuses({ + ctx, + customerId, + productId: pro.id, + expected: { + [CusProductStatus.PastDue]: 1, + [CusProductStatus.Expired]: 1, + }, + }); + + expect(byStatus[CusProductStatus.PastDue]?.[0]?.product.version).toBe(2); + expect(customerAfter.invoices?.length ?? 0).toBe(invoiceCountBefore); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + +test.concurrent( + `${chalk.yellowBright("migrations update_plan states: scheduled downgrade survives active plan price update")}`, + async () => { + const customerId = "migration-update-state-downgrade"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: premium.id }), + s.billing.attach({ productId: pro.id }), + ], + }); + + const before = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: before, productId: premium.id }); + await expectProductScheduled({ customer: before, productId: pro.id }); + const scheduledIdsBefore = await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledIdsBefore.length).toBeGreaterThan(0); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: premium.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: premium.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 100 }), + }, + }, + ], + }, + }); + + const after = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: after, productId: premium.id }); + await expectProductScheduled({ customer: after, productId: pro.id }); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: premium.id, + }), + ).toEqual([100]); + expect( + await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }), + ).toEqual(scheduledIdsBefore); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: premium.id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + +test.concurrent( + `${chalk.yellowBright("migrations update_plan states: end-of-cycle cancel survives price update")}`, + async () => { + const customerId = "migration-update-state-cancel"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.updateSubscription({ + productId: pro.id, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const before = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: before, productId: pro.id }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 50 }), + }, + }, + ], + }, + }); + + const after = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: after, productId: pro.id }); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: pro.id, + }), + ).toEqual([50]); + expect( + await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }), + ).toEqual([]); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: pro.id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + +test.concurrent( + `${chalk.yellowBright("migrations update_plan states: entity scheduled and canceling states survive")}`, + async () => { + const customerId = "migration-update-state-entities"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: premium.id, entityIndex: 0 }), + s.billing.attach({ productId: premium.id, entityIndex: 1 }), + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.updateSubscription({ + productId: premium.id, + entityIndex: 1, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const entity1Before = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2Before = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductCanceling({ + customer: entity1Before, + productId: premium.id, + }); + await expectProductScheduled({ + customer: entity1Before, + productId: pro.id, + }); + await expectProductCanceling({ + customer: entity2Before, + productId: premium.id, + }); + await expectProductNotPresent({ + customer: entity2Before, + productId: pro.id, + }); + const scheduledIdsBefore = await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + entityId: entities[0].id, + }); + expect(scheduledIdsBefore.length).toBeGreaterThan(0); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: premium.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: premium.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 100 }), + }, + }, + ], + }, + }); + + const entity1After = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2After = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductCanceling({ + customer: entity1After, + productId: premium.id, + }); + await expectProductScheduled({ customer: entity1After, productId: pro.id }); + await expectProductCanceling({ + customer: entity2After, + productId: premium.id, + }); + await expectProductNotPresent({ + customer: entity2After, + productId: pro.id, + }); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: premium.id, + entityId: entities[0].id, + }), + ).toEqual([100]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: premium.id, + entityId: entities[1].id, + }), + ).toEqual([100]); + expect( + await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + entityId: entities[0].id, + }), + ).toEqual(scheduledIdsBefore); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + +test.concurrent( + `${chalk.yellowBright("migrations update_plan states: multi-product scheduled downgrade and canceling addon survive")}`, + async () => { + const customerId = "migration-update-state-products"; + const pro = products.pro({ + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + const addon = products.recurringAddOn({ + items: [items.monthlyWords({ includedUsage: 300 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium, addon] }), + ], + actions: [ + s.billing.attach({ productId: premium.id }), + s.billing.attach({ productId: addon.id }), + s.billing.attach({ productId: pro.id }), + s.updateSubscription({ + productId: addon.id, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const before = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: before, productId: premium.id }); + await expectProductScheduled({ customer: before, productId: pro.id }); + await expectProductCanceling({ customer: before, productId: addon.id }); + const scheduledIdsBefore = await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledIdsBefore.length).toBeGreaterThan(0); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { + customer: { + plan: { $or: [{ plan_id: premium.id }, { plan_id: addon.id }] }, + }, + }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: premium.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 100 }), + }, + }, + { + type: "update_plan", + plan_filter: { plan_id: addon.id }, + customize: { + price: itemsV2.monthlyPrice({ amount: 40 }), + }, + }, + ], + }, + }); + + const after = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: after, productId: premium.id }); + await expectProductScheduled({ customer: after, productId: pro.id }); + await expectProductCanceling({ customer: after, productId: addon.id }); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: premium.id, + }), + ).toEqual([100]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerId, + productId: addon.id, + }), + ).toEqual([40]); + expect( + await getScheduledIds({ + ctx, + customerId, + productId: pro.id, + }), + ).toEqual(scheduledIdsBefore); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: premium.id, + }); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: addon.id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-paid-features.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-paid-features.test.ts deleted file mode 100644 index 89ab13a08..000000000 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-paid-features.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * TDD coverage for update_plan item patch migrations. - * - * Contract under test: - * - update_plan reuses update-subscription patch semantics for add_items, - * remove_items, usage carry, and rollover carry. - * - Migration execution does not create extra invoices. - * - Existing customer products are patched, not replaced or expired. - */ - -import { test } from "bun:test"; -import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared"; -import { BillingMethod } from "@autumn/shared"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; -import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; -import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; -import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; -import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; - -test.concurrent(`${chalk.yellowBright("migrations update_plan: consumable paid feature carries usage without charging")}`, async () => { - const customerId = "migration-update-paid-consumable"; - const messagesUsage = 60; - const included = 50; - const pro = products.pro({ - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const { autumnV1, autumnV2_2, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.billing.attach({ productId: pro.id })], - }); - - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - await runUpdatePlanMigration({ - ctx, - migrationClient: autumnV2_2, - migrationId: `${customerId}-mig`, - customerId, - filter: { customer: { plan: { plan_id: pro.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: pro.id }, - customize: { - remove_items: [{ feature_id: TestFeature.Messages }], - add_items: [ - itemsV2.dashboard(), - { - ...itemsV2.consumableMessages({ amount: 0.1 }), - included, - }, - ], - }, - }, - ], - }, - }); - - const customer = await autumnV2_2.customers.get(customerId); - await expectCustomerProducts({ customer, active: [pro.id] }); - expectFlagCorrect({ - customer, - featureId: TestFeature.Dashboard, - planId: pro.id, - }); - expectBalanceCorrect({ - customer, - featureId: TestFeature.Messages, - remaining: 0, - usage: messagesUsage, - planId: pro.id, - breakdown: { - [BillingMethod.UsageBased]: { - included_grant: included, - remaining: 0, - usage: messagesUsage, - }, - }, - }); - await expectCustomerInvoiceCorrect({ - customer: await autumnV1.customers.get(customerId), - count: 1, - latestTotal: 20, - }); - await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts deleted file mode 100644 index 28a4cf79d..000000000 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -/** - * TDD coverage for update_plan migrations preserving in-flight subscription - * states. - * - * Contract under test: - * - Updating the active plan's base price does not clear a scheduled downgrade. - * - Updating a canceling plan's base price does not clear end-of-cycle cancel. - * - Entity-scoped and multi-product states survive a customer migration. - */ - -import { expect, test } from "bun:test"; -import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; -import { - CusProductStatus, - customerPrices, - customerProducts, - customers, - prices, -} from "@autumn/shared"; -import { - expectProductCanceling, - expectProductNotPresent, - expectProductScheduled, -} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; -import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; -import { and, eq, isNull } from "drizzle-orm"; -import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; - -const getScheduledIds = async ({ - ctx, - customerId, - productId, - entityId, -}: { - ctx: Awaited>["ctx"]; - customerId: string; - productId: string; - entityId?: string; -}) => - ( - await ctx.db - .select({ scheduledIds: customerProducts.scheduled_ids }) - .from(customerProducts) - .innerJoin( - customers, - eq(customerProducts.internal_customer_id, customers.internal_id), - ) - .where( - and( - eq(customers.org_id, ctx.org.id), - eq(customers.env, ctx.env), - eq(customers.id, customerId), - eq(customerProducts.product_id, productId), - eq(customerProducts.status, CusProductStatus.Scheduled), - entityId - ? eq(customerProducts.entity_id, entityId) - : isNull(customerProducts.entity_id), - ), - ) - ) - .map((row) => row.scheduledIds ?? []) - .flat() - .sort(); - -const getCustomerProductPriceAmounts = async ({ - ctx, - customerId, - productId, - entityId, -}: { - ctx: Awaited>["ctx"]; - customerId: string; - productId: string; - entityId?: string; -}) => - ( - await ctx.db - .select({ config: prices.config }) - .from(customerProducts) - .innerJoin( - customers, - eq(customerProducts.internal_customer_id, customers.internal_id), - ) - .innerJoin( - customerPrices, - eq(customerPrices.customer_product_id, customerProducts.id), - ) - .innerJoin(prices, eq(customerPrices.price_id, prices.id)) - .where( - and( - eq(customers.org_id, ctx.org.id), - eq(customers.env, ctx.env), - eq(customers.id, customerId), - eq(customerProducts.product_id, productId), - entityId - ? eq(customerProducts.entity_id, entityId) - : isNull(customerProducts.entity_id), - ), - ) - ) - .map((row) => - row.config && "amount" in row.config ? row.config.amount : undefined, - ) - .filter((amount): amount is number => typeof amount === "number") - .sort((a, b) => a - b); - -test.concurrent(`${chalk.yellowBright("migrations update_plan states: scheduled downgrade survives active plan price update")}`, async () => { - const customerId = "migration-update-state-downgrade"; - const pro = products.pro({ - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - const premium = products.premium({ - items: [items.monthlyMessages({ includedUsage: 1000 })], - }); - - const { autumnV1, autumnV2_2, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ productId: premium.id }), - s.billing.attach({ productId: pro.id }), - ], - }); - - const before = await autumnV1.customers.get(customerId); - await expectProductCanceling({ customer: before, productId: premium.id }); - await expectProductScheduled({ customer: before, productId: pro.id }); - const scheduledIdsBefore = await getScheduledIds({ - ctx, - customerId, - productId: pro.id, - }); - expect(scheduledIdsBefore.length).toBeGreaterThan(0); - - await runUpdatePlanMigration({ - ctx, - migrationClient: autumnV2_2, - migrationId: `${customerId}-mig`, - customerId, - runOnServer: false, - filter: { customer: { plan: { plan_id: premium.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: premium.id }, - customize: { - price: itemsV2.monthlyPrice({ amount: 100 }), - }, - }, - ], - }, - }); - - const after = await autumnV1.customers.get(customerId); - await expectProductCanceling({ customer: after, productId: premium.id }); - await expectProductScheduled({ customer: after, productId: pro.id }); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerId, - productId: premium.id, - }), - ).toEqual([100]); - expect( - await getScheduledIds({ - ctx, - customerId, - productId: pro.id, - }), - ).toEqual(scheduledIdsBefore); - await expectNoExpiredCustomerProducts({ - ctx, - customerId, - productId: premium.id, - }); - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); - -test.concurrent(`${chalk.yellowBright("migrations update_plan states: end-of-cycle cancel survives price update")}`, async () => { - const customerId = "migration-update-state-cancel"; - const pro = products.pro({ - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - - const { autumnV1, autumnV2_2, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.billing.attach({ productId: pro.id }), - s.updateSubscription({ - productId: pro.id, - cancelAction: "cancel_end_of_cycle", - }), - ], - }); - - const before = await autumnV1.customers.get(customerId); - await expectProductCanceling({ customer: before, productId: pro.id }); - - await runUpdatePlanMigration({ - ctx, - migrationClient: autumnV2_2, - migrationId: `${customerId}-mig`, - customerId, - runOnServer: false, - filter: { customer: { plan: { plan_id: pro.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: pro.id }, - customize: { - price: itemsV2.monthlyPrice({ amount: 50 }), - }, - }, - ], - }, - }); - - const after = await autumnV1.customers.get(customerId); - await expectProductCanceling({ customer: after, productId: pro.id }); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerId, - productId: pro.id, - }), - ).toEqual([50]); - expect( - await getScheduledIds({ - ctx, - customerId, - productId: pro.id, - }), - ).toEqual([]); - await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); - -test.concurrent(`${chalk.yellowBright("migrations update_plan states: entity scheduled and canceling states survive")}`, async () => { - const customerId = "migration-update-state-entities"; - const pro = products.pro({ - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - const premium = products.premium({ - items: [items.monthlyMessages({ includedUsage: 1000 })], - }); - - const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.billing.attach({ productId: premium.id, entityIndex: 0 }), - s.billing.attach({ productId: premium.id, entityIndex: 1 }), - s.billing.attach({ productId: pro.id, entityIndex: 0 }), - s.updateSubscription({ - productId: premium.id, - entityIndex: 1, - cancelAction: "cancel_end_of_cycle", - }), - ], - }); - - const entity1Before = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - const entity2Before = await autumnV1.entities.get( - customerId, - entities[1].id, - ); - await expectProductCanceling({ - customer: entity1Before, - productId: premium.id, - }); - await expectProductScheduled({ customer: entity1Before, productId: pro.id }); - await expectProductCanceling({ - customer: entity2Before, - productId: premium.id, - }); - await expectProductNotPresent({ customer: entity2Before, productId: pro.id }); - const scheduledIdsBefore = await getScheduledIds({ - ctx, - customerId, - productId: pro.id, - entityId: entities[0].id, - }); - expect(scheduledIdsBefore.length).toBeGreaterThan(0); - - await runUpdatePlanMigration({ - ctx, - migrationClient: autumnV2_2, - migrationId: `${customerId}-mig`, - customerId, - runOnServer: false, - filter: { customer: { plan: { plan_id: premium.id } } }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: premium.id }, - customize: { - price: itemsV2.monthlyPrice({ amount: 100 }), - }, - }, - ], - }, - }); - - const entity1After = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - const entity2After = await autumnV1.entities.get( - customerId, - entities[1].id, - ); - await expectProductCanceling({ - customer: entity1After, - productId: premium.id, - }); - await expectProductScheduled({ customer: entity1After, productId: pro.id }); - await expectProductCanceling({ - customer: entity2After, - productId: premium.id, - }); - await expectProductNotPresent({ customer: entity2After, productId: pro.id }); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerId, - productId: premium.id, - entityId: entities[0].id, - }), - ).toEqual([100]); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerId, - productId: premium.id, - entityId: entities[1].id, - }), - ).toEqual([100]); - expect( - await getScheduledIds({ - ctx, - customerId, - productId: pro.id, - entityId: entities[0].id, - }), - ).toEqual(scheduledIdsBefore); - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); - -test.concurrent(`${chalk.yellowBright("migrations update_plan states: multi-product scheduled downgrade and canceling addon survive")}`, async () => { - const customerId = "migration-update-state-products"; - const pro = products.pro({ - items: [items.monthlyMessages({ includedUsage: 500 })], - }); - const premium = products.premium({ - items: [items.monthlyMessages({ includedUsage: 1000 })], - }); - const addon = products.recurringAddOn({ - items: [items.monthlyWords({ includedUsage: 300 })], - }); - - const { autumnV1, autumnV2_2, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium, addon] }), - ], - actions: [ - s.billing.attach({ productId: premium.id }), - s.billing.attach({ productId: addon.id }), - s.billing.attach({ productId: pro.id }), - s.updateSubscription({ - productId: addon.id, - cancelAction: "cancel_end_of_cycle", - }), - ], - }); - - const before = await autumnV1.customers.get(customerId); - await expectProductCanceling({ customer: before, productId: premium.id }); - await expectProductScheduled({ customer: before, productId: pro.id }); - await expectProductCanceling({ customer: before, productId: addon.id }); - const scheduledIdsBefore = await getScheduledIds({ - ctx, - customerId, - productId: pro.id, - }); - expect(scheduledIdsBefore.length).toBeGreaterThan(0); - - await runUpdatePlanMigration({ - ctx, - migrationClient: autumnV2_2, - migrationId: `${customerId}-mig`, - customerId, - runOnServer: false, - filter: { - customer: { - plan: { $or: [{ plan_id: premium.id }, { plan_id: addon.id }] }, - }, - }, - operations: { - customer: [ - { - type: "update_plan", - plan_filter: { plan_id: premium.id }, - customize: { - price: itemsV2.monthlyPrice({ amount: 100 }), - }, - }, - { - type: "update_plan", - plan_filter: { plan_id: addon.id }, - customize: { - price: itemsV2.monthlyPrice({ amount: 40 }), - }, - }, - ], - }, - }); - - const after = await autumnV1.customers.get(customerId); - await expectProductCanceling({ customer: after, productId: premium.id }); - await expectProductScheduled({ customer: after, productId: pro.id }); - await expectProductCanceling({ customer: after, productId: addon.id }); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerId, - productId: premium.id, - }), - ).toEqual([100]); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerId, - productId: addon.id, - }), - ).toEqual([40]); - expect( - await getScheduledIds({ - ctx, - customerId, - productId: pro.id, - }), - ).toEqual(scheduledIdsBefore); - await expectNoExpiredCustomerProducts({ - ctx, - customerId, - productId: premium.id, - }); - await expectNoExpiredCustomerProducts({ ctx, customerId, productId: addon.id }); - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/utils/scheduledCustomerProductTestUtils.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/utils/scheduledCustomerProductTestUtils.ts new file mode 100644 index 000000000..054fc772d --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/utils/scheduledCustomerProductTestUtils.ts @@ -0,0 +1,215 @@ +import { + CusProductStatus, + customerEntitlements, + customerPrices, + customerProducts, + customers, + prices, + products as productsTable, + schedulePhases, +} from "@autumn/shared"; +import type { initScenario } from "@tests/utils/testInitUtils/initScenario"; +import { and, eq, inArray, isNull } from "drizzle-orm"; + +export type MigrationTestCtx = Awaited>["ctx"]; + +export const getCustomerProductRows = async ({ + ctx, + customerId, + productId, + status, + entityId, +}: { + ctx: MigrationTestCtx; + customerId: string; + productId: string; + status?: CusProductStatus; + entityId?: string | null; +}) => + await ctx.db + .select({ + id: customerProducts.id, + status: customerProducts.status, + startsAt: customerProducts.starts_at, + scheduledIds: customerProducts.scheduled_ids, + isCustom: customerProducts.is_custom, + entityId: customerProducts.entity_id, + options: customerProducts.options, + version: productsTable.version, + }) + .from(customerProducts) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .innerJoin( + productsTable, + eq(customerProducts.internal_product_id, productsTable.internal_id), + ) + .where( + and( + eq(customers.org_id, ctx.org.id), + eq(customers.env, ctx.env), + eq(customers.id, customerId), + eq(customerProducts.product_id, productId), + status ? eq(customerProducts.status, status) : undefined, + entityId === undefined + ? undefined + : entityId === null + ? isNull(customerProducts.entity_id) + : eq(customerProducts.entity_id, entityId), + ), + ); + +export const getScheduledCustomerProductRow = async ({ + ctx, + customerId, + productId, + entityId, +}: { + ctx: MigrationTestCtx; + customerId: string; + productId: string; + entityId?: string | null; +}) => { + const rows = await getCustomerProductRows({ + ctx, + customerId, + productId, + status: CusProductStatus.Scheduled, + entityId, + }); + if (rows.length !== 1) { + throw new Error( + `Expected exactly one scheduled customer product for ${customerId}/${productId}, got ${rows.length}`, + ); + } + return rows[0]!; +}; + +export const getScheduledCustomerProductRows = async ({ + ctx, + customerId, + productId, +}: { + ctx: MigrationTestCtx; + customerId: string; + productId: string; +}) => + await getCustomerProductRows({ + ctx, + customerId, + productId, + status: CusProductStatus.Scheduled, + }); + +export const getCustomerProductFeatureIds = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => + ( + await ctx.db + .select({ featureId: customerEntitlements.feature_id }) + .from(customerEntitlements) + .where(eq(customerEntitlements.customer_product_id, customerProductId)) + ) + .map((row) => row.featureId) + .sort(); + +export const getCustomerProductBalances = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => + ( + await ctx.db + .select({ + featureId: customerEntitlements.feature_id, + balance: customerEntitlements.balance, + }) + .from(customerEntitlements) + .where(eq(customerEntitlements.customer_product_id, customerProductId)) + ).sort((a, b) => (a.featureId ?? "").localeCompare(b.featureId ?? "")); + +export const getCustomerProductPriceAmounts = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => + ( + await ctx.db + .select({ config: prices.config }) + .from(customerPrices) + .innerJoin(prices, eq(customerPrices.price_id, prices.id)) + .where(eq(customerPrices.customer_product_id, customerProductId)) + ) + .map((row) => + row.config && "amount" in row.config ? row.config.amount : undefined, + ) + .filter((amount): amount is number => typeof amount === "number") + .sort((a, b) => a - b); + +export const getPhaseCustomerProductIds = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => + ( + await ctx.db + .select({ customerProductIds: schedulePhases.customer_product_ids }) + .from(schedulePhases) + ) + .map((phase) => phase.customerProductIds) + .find((customerProductIds) => + customerProductIds.includes(customerProductId), + ); + +export const getRequiredStripeScheduleId = ({ + scheduledIds, +}: { + scheduledIds: string[] | null; +}) => { + const scheduleId = scheduledIds?.[0]; + if (!scheduleId) { + throw new Error("Expected customer product to have a Stripe schedule ID"); + } + return scheduleId; +}; + +export const deleteCustomerProductRows = async ({ + ctx, + customerProductIds, +}: { + ctx: MigrationTestCtx; + customerProductIds: string[]; +}) => { + if (customerProductIds.length === 0) return; + await ctx.db + .delete(customerProducts) + .where(inArray(customerProducts.id, customerProductIds)); +}; + +export const expectNoCustomerProductRow = async ({ + ctx, + customerProductId, +}: { + ctx: MigrationTestCtx; + customerProductId: string; +}) => { + const rows = await ctx.db + .select({ id: customerProducts.id }) + .from(customerProducts) + .where(eq(customerProducts.id, customerProductId)); + if (rows.length !== 0) { + throw new Error(`Expected customer product ${customerProductId} to be deleted`); + } +}; diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/versioning/update-plan-op-scheduled-version.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/versioning/update-plan-op-scheduled-version.test.ts new file mode 100644 index 000000000..65477c7d9 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/versioning/update-plan-op-scheduled-version.test.ts @@ -0,0 +1,513 @@ +/** + * TDD coverage for `update_plan` version migrations targeting scheduled customer products. + * + * Contract under test: + * New behaviors: + * - Scheduled customer products are selected by customer and operation plan filters. + * - Scheduled version updates delete the old scheduled row and insert a replacement. + * - Entity-scoped scheduled rows are selected and replaced independently. + * - Explicit `plan_filter.custom: true` opts custom scheduled rows into version updates. + * - Active and scheduled rows for the same plan can be migrated together. + * Side effects: + * - Scheduled replacements do not leave expired scheduled rows. + * - Coupled migrations keep Stripe subscriptions/schedules consistent with Autumn. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { CusProductStatus, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductCanceling, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; +import { + expectNoCustomerProductRow, + getCustomerProductFeatureIds, + getCustomerProductRows, + getPhaseCustomerProductIds, + getRequiredStripeScheduleId, + getScheduledCustomerProductRow, + getScheduledCustomerProductRows, +} from "../utils/scheduledCustomerProductTestUtils"; + +const stripeScheduleSignature = (schedule: Stripe.SubscriptionSchedule) => ({ + status: schedule.status, + currentPhase: schedule.current_phase, + phases: schedule.phases.map((phase) => ({ + startDate: phase.start_date, + endDate: phase.end_date, + items: phase.items.map((item) => ({ + price: typeof item.price === "string" ? item.price : item.price.id, + quantity: item.quantity, + })), + })), +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: scheduled downgrade is selected and replaced")}`, async () => { + const customerId = "migration-update-scheduled-version"; + const pro = products.pro({ + id: "scheduled-version-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + id: "scheduled-version-premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.billing.attach({ productId: premium.id }), + s.billing.attach({ productId: pro.id }), + ], + }); + + const beforeCustomer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: beforeCustomer, productId: premium.id }); + await expectProductScheduled({ customer: beforeCustomer, productId: pro.id }); + const invoiceCountBefore = beforeCustomer.invoices?.length ?? 0; + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + }); + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 600 }), + ], + }); + + const expectScheduledReplacement = async () => { + await expectNoCustomerProductRow({ + ctx, + customerProductId: scheduledBefore.id, + }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledAfter.id).not.toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(2); + expect(scheduledAfter.startsAt).toBe(scheduledBefore.startsAt); + expect(scheduledAfter.scheduledIds ?? []).toHaveLength(1); + }; + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + version: 2, + }, + ], + }, + waitFor: expectScheduledReplacement, + runOnServer: false, + timeoutMs: 60_000, + }); + await expectScheduledReplacement(); + + const afterCustomer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer: afterCustomer, productId: premium.id }); + await expectProductScheduled({ customer: afterCustomer, productId: pro.id }); + await expectCustomerInvoiceCorrect({ customer: afterCustomer, count: invoiceCountBefore }); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: entity-scoped scheduled rows are replaced")}`, async () => { + const customerId = "migration-update-scheduled-entity-version"; + const pro = products.pro({ + id: "scheduled-entity-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + const premium = products.premium({ + id: "scheduled-entity-premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: premium.id, entityIndex: 0 }), + s.billing.attach({ productId: premium.id, entityIndex: 1 }), + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const scheduledBefore = await getScheduledCustomerProductRows({ + ctx, + customerId, + productId: pro.id, + }); + expect(scheduledBefore.map((row) => row.entityId).sort()).toEqual( + entities.map((entity) => entity.id).sort(), + ); + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 700 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + version: 2, + }, + ], + }, + }); + + for (const row of scheduledBefore) { + await expectNoCustomerProductRow({ ctx, customerProductId: row.id }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: pro.id, + entityId: row.entityId, + }); + expect(scheduledAfter.id).not.toBe(row.id); + expect(scheduledAfter.version).toBe(2); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledAfter.id })).toEqual([ + TestFeature.Messages, + ]); + } + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: custom scheduled plan can be explicitly updated")}`, async () => { + const customerId = "migration-update-scheduled-custom-override"; + const regular = products.base({ + id: "scheduled-custom-override-regular", + items: [ + items.monthlyPrice({ price: 10 }), + items.monthlyMessages({ includedUsage: 100 }), + ], + }); + const customFuture = products.base({ + id: "scheduled-custom-override-future", + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 100 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [regular, customFuture] }), + ], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: regular.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: customFuture.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 25 }), + items: [itemsV2.monthlyWords({ included: 250 })], + }, + }, + ], + }, + ], + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: customFuture.id, + }); + expect(scheduledBefore.isCustom).toBe(true); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledBefore.id })).toEqual([ + TestFeature.Words, + ]); + + await autumnV1.products.update(customFuture.id, { + items: [ + items.monthlyPrice({ price: 30 }), + items.monthlyMessages({ includedUsage: 500 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + filter: { customer: { plan: { plan_id: customFuture.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: customFuture.id, custom: true }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + await expectNoCustomerProductRow({ ctx, customerProductId: scheduledBefore.id }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: customFuture.id, + }); + expect(scheduledAfter.id).not.toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(2); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledAfter.id })).toEqual([ + TestFeature.Messages, + ]); + await expectNoExpiredCustomerProducts({ ctx, customerId, productId: customFuture.id }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: custom scheduled plan is skipped by default")}`, async () => { + const customerId = "migration-update-scheduled-custom-skip"; + const regular = products.base({ + id: "scheduled-custom-skip-regular", + items: [ + items.monthlyPrice({ price: 10 }), + items.monthlyMessages({ includedUsage: 100 }), + ], + }); + const customFuture = products.base({ + id: "scheduled-custom-skip-future", + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 100 }), + ], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [regular, customFuture] }), + ], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: regular.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: customFuture.id, + customize: { + items: [itemsV2.monthlyWords({ included: 250 })], + }, + }, + ], + }, + ], + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: customFuture.id, + }); + expect(scheduledBefore.isCustom).toBe(true); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledBefore.id })).toEqual([ + TestFeature.Words, + ]); + + await autumnV1.products.update(customFuture.id, { + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + filter: { customer: { plan: { plan_id: customFuture.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: customFuture.id }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: customFuture.id, + }); + expect(scheduledAfter.id).toBe(scheduledBefore.id); + expect(scheduledAfter.version).toBe(1); + expect(scheduledAfter.isCustom).toBe(true); + expect(await getCustomerProductFeatureIds({ ctx, customerProductId: scheduledAfter.id })).toEqual([ + TestFeature.Words, + ]); + await expectNoExpiredCustomerProducts({ + ctx, + customerId, + productId: customFuture.id, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test(`${chalk.yellowBright("migrations update_plan scheduled version: mixed active and scheduled rows for same plan update together")}`, async () => { + const customerId = "migration-update-scheduled-mixed-same-plan"; + const plan = products.pro({ + id: "scheduled-mixed-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [plan] })], + actions: [], + }); + + const now = Date.now(); + await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: plan.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: plan.id }], + }, + ], + }); + const activeBefore = await getCustomerProductRows({ + ctx, + customerId, + productId: plan.id, + status: CusProductStatus.Active, + }); + const scheduledBefore = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: plan.id, + }); + expect(activeBefore).toHaveLength(1); + const stripeScheduleId = getRequiredStripeScheduleId({ + scheduledIds: scheduledBefore.scheduledIds, + }); + const stripeScheduleBefore = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + const stripeSignatureBefore = stripeScheduleSignature( + stripeScheduleBefore as Stripe.SubscriptionSchedule, + ); + + await autumnV1.products.update(plan.id, { + items: [items.monthlyMessages({ includedUsage: 250 })], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig-${Date.now()}`, + customerId, + noBillingChanges: true, + filter: { customer: { plan: { plan_id: plan.id, version: 1 } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: plan.id, version: 1 }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + await expectNoCustomerProductRow({ ctx, customerProductId: scheduledBefore.id }); + const activeAfter = await getCustomerProductRows({ + ctx, + customerId, + productId: plan.id, + status: CusProductStatus.Active, + }); + const scheduledAfter = await getScheduledCustomerProductRow({ + ctx, + customerId, + productId: plan.id, + }); + expect(activeAfter).toHaveLength(1); + expect(activeAfter[0]!.version).toBe(2); + expect(scheduledAfter.version).toBe(2); + expect( + await getPhaseCustomerProductIds({ + ctx, + customerProductId: scheduledAfter.id, + }), + ).toEqual([scheduledAfter.id]); + const stripeScheduleAfter = + await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); + expect(stripeScheduleSignature(stripeScheduleAfter as Stripe.SubscriptionSchedule)).toEqual( + stripeSignatureBefore, + ); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-version.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/versioning/update-plan-op-version.test.ts similarity index 98% rename from server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-version.test.ts rename to server/tests/integration/billing/migrations-v2/update-plan-operation/versioning/update-plan-op-version.test.ts index 5ac2ce0bd..0a3322a2a 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-version.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/versioning/update-plan-op-version.test.ts @@ -18,7 +18,7 @@ import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; +import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration"; test.concurrent(`${chalk.yellowBright("migrations update_plan: free version update carries usage")}`, async () => { const customerId = "migration-update-free-version"; diff --git a/server/tests/integration/billing/migrations-v2/update-plan-version/migration-free-trial-carryover.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-version/migration-free-trial-carryover.test.ts new file mode 100644 index 000000000..b7735a3f0 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-version/migration-free-trial-carryover.test.ts @@ -0,0 +1,137 @@ +/** + * Regression: update_plan version migrations must carry active free-product trial_ends_at. + * Pre-fix replacements became active without a trial; post-fix they keep the trial isolated from paid subscriptions. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; + +test.concurrent( + `${chalk.yellowBright("migrations update_plan: free trial v1->v2 carries trial without trialing paid subscription")}`, + async () => { + const customerId = "mig-free-trial-carryover-paid-guard"; + const freeTrial = products.baseWithTrial({ + id: "mig-free-trial-carryover", + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 14, + cardRequired: false, + }); + const paidAddon = products.recurringAddOn({ + id: "mig-free-trial-paid-addon", + items: [items.monthlyCredits({ includedUsage: 50 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [freeTrial, paidAddon] }), + ], + actions: [ + s.billing.attach({ productId: freeTrial.id }), + s.billing.attach({ productId: paidAddon.id }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + const trialEndsAt = await expectProductTrialing({ + customer: customerBefore, + productId: freeTrial.id, + }); + expect(trialEndsAt).toBeDefined(); + await expectProductNotTrialing({ + customer: customerBefore, + productId: paidAddon.id, + }); + + const stripeCustomerId = customerBefore.stripe_id; + expect(stripeCustomerId).toBeDefined(); + const subsBefore = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId as string, + status: "all", + }); + const paidSubBefore = subsBefore.data.find( + (sub) => sub.status === "active" || sub.status === "trialing", + ); + expect(paidSubBefore).toBeDefined(); + expect(paidSubBefore!.status).not.toBe("trialing"); + + await autumnV1.products.update(freeTrial.id, { + items: [ + items.monthlyMessages({ includedUsage: 200 }), + items.monthlyUsers({ includedUsage: 10 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: freeTrial.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: freeTrial.id }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + const customerAfter = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfter, + active: [freeTrial.id, paidAddon.id], + }); + await expectProductTrialing({ + customer: customerAfter, + productId: freeTrial.id, + trialEndsAt: trialEndsAt!, + }); + await expectProductNotTrialing({ + customer: customerAfter, + productId: paidAddon.id, + }); + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Users, + includedUsage: 10, + balance: 10, + usage: 0, + }); + + const paidSubAfter = await ctx.stripeCli.subscriptions.retrieve( + paidSubBefore!.id, + ); + expect(paidSubAfter.status).not.toBe("trialing"); + expectStripeSubscriptionUnchanged({ + before: paidSubBefore!, + after: paidSubAfter, + }); + }, +); diff --git a/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts b/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts index 1ede7395c..80312d49b 100644 --- a/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts +++ b/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts @@ -12,6 +12,7 @@ type MigrationClient = { id: string; filter?: MigrationFilter | null; operations?: Operations | null; + no_billing_changes?: boolean; }) => Promise; run: (params: { id: string; dry_run?: boolean }) => Promise<{ migration_id: string; @@ -60,6 +61,7 @@ export const runUpdatePlanMigration = async ({ customerId, filter, operations, + noBillingChanges, runOnServer = true, waitFor, timeoutMs = 30_000, @@ -71,6 +73,7 @@ export const runUpdatePlanMigration = async ({ customerId: string; filter: MigrationFilter; operations: Operations; + noBillingChanges?: boolean; runOnServer?: boolean; waitFor?: () => Promise; timeoutMs?: number; @@ -80,6 +83,7 @@ export const runUpdatePlanMigration = async ({ id: migrationId, filter, operations, + no_billing_changes: noBillingChanges, }); if (runOnServer) { diff --git a/server/tests/integration/billing/multi-attach/scheduled-switch/multi-attach-prepaid-cancel-renewal.test.ts b/server/tests/integration/billing/multi-attach/scheduled-switch/multi-attach-prepaid-cancel-renewal.test.ts index 51668b534..c999834a6 100644 --- a/server/tests/integration/billing/multi-attach/scheduled-switch/multi-attach-prepaid-cancel-renewal.test.ts +++ b/server/tests/integration/billing/multi-attach/scheduled-switch/multi-attach-prepaid-cancel-renewal.test.ts @@ -179,7 +179,6 @@ test.concurrent( cancel_action: "cancel_end_of_cycle" as const, }); - return; const subscriptionAfterCancel = await ctx.stripeCli.subscriptions.retrieve(stripeSubscriptionId); const workflowSubscriptionItemIdsAfterCancel = diff --git a/server/tests/integration/billing/preview/preview-update-subscription-tax-and-credits.test.ts b/server/tests/integration/billing/preview/preview-update-subscription-tax-and-credits.test.ts index 0f8a66ccc..6e1191242 100644 --- a/server/tests/integration/billing/preview/preview-update-subscription-tax-and-credits.test.ts +++ b/server/tests/integration/billing/preview/preview-update-subscription-tax-and-credits.test.ts @@ -24,9 +24,11 @@ import { expect, test } from "bun:test"; import type { ApiCustomerV3, PreviewUpdateSubscriptionResponse, + UpdateSubscriptionV1ParamsInput, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2.js"; import { products } from "@tests/utils/fixtures/products.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; @@ -86,8 +88,7 @@ test.concurrent( // Set credit balance AFTER initial attach so Stripe doesn't consume // it on the first invoice. We want the credit on file at the moment // the previewUpdate runs. - const customer = - await autumnV1.customers.get(customerId); + const customer = await autumnV1.customers.get(customerId); const stripeCustomerId = customer.stripe_id; expect(stripeCustomerId).toBeDefined(); await ctx.stripeCli.customers.update(stripeCustomerId!, { @@ -251,3 +252,61 @@ test.concurrent( }, 300_000, ); + +test.concurrent( + `${chalk.yellowBright("preview-update-subscription-tax-rate-id (exclusive 10%): custom tax rate returns exact tax and total")}`, + async () => { + const customerId = "preview-update-tax-rate-id"; + const proProd = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { ctx, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proProd] }), + ], + actions: [], + }); + + const taxRate = await ctx.stripeCli.taxRates.create({ + display_name: "Preview Update Tax Rate", + percentage: 10, + inclusive: false, + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: proProd.id, + tax_rate_id: taxRate.id, + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: proProd.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 40 }), + }, + }; + + const preview = + (await autumnV2_2.subscriptions.previewUpdate( + params, + )) as PreviewUpdateSubscriptionResponse; + + expect(preview.subtotal).toBe(20); + expect(preview.tax).toBeDefined(); + expect(preview.tax?.status).toBe("complete"); + expect(preview.tax?.currency).toBe(preview.currency); + expect(preview.tax?.amount_exclusive).toBe(2); + expect(preview.tax?.amount_inclusive).toBe(0); + expect(preview.tax?.total).toBe(2); + expect(preview.total).toBe(22); + }, + 300_000, +); diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts index 58e43395d..a1117e113 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts @@ -95,6 +95,7 @@ test.concurrent(`${chalk.yellowBright("invoice.created multi-interval: monthly + let currentEpochMs = await advanceToNextInvoice({ stripeCli: ctx.stripeCli, testClockId: testClockId!, + withPause: true, }); const customerMonth1 = @@ -140,6 +141,7 @@ test.concurrent(`${chalk.yellowBright("invoice.created multi-interval: monthly + stripeCli: ctx.stripeCli, testClockId: testClockId!, currentEpochMs, + withPause: true, }); const customerMonth2 = @@ -190,6 +192,7 @@ test.concurrent(`${chalk.yellowBright("invoice.created multi-interval: monthly + stripeCli: ctx.stripeCli, testClockId: testClockId!, currentEpochMs, + withPause: true, }); const customerMonth3 = diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-rollover-expiry.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-rollover-expiry.test.ts new file mode 100644 index 000000000..ef73c3341 --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-rollover-expiry.test.ts @@ -0,0 +1,125 @@ +// Red: usage-based rollovers expired from wall-clock time. +// Green: prepaid and usage-based one-month rollovers expire at next_reset_at. + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type RolloverConfig, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { + constructArrearItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { expectBalanceCorrect } from "../../../utils/expectBalanceCorrect.js"; + +const rolloverConfig: RolloverConfig = { + max: null, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; + +const expectOneMonthRolloverExpiresAtNextReset = ({ + customer, +}: { + customer: ApiCustomerV5; +}) => { + const balance = customer.balances[TestFeature.Messages]; + expect(balance).toBeDefined(); + expect(balance.next_reset_at).not.toBeNull(); + expect(balance.rollovers?.length ?? 0).toBeGreaterThan(0); + + const nextResetAt = balance.next_reset_at!; + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + nextResetAt, + positiveRolloverCount: 1, + }); + + const positiveRollovers = balance.rollovers!.filter( + (item) => item.balance > 0, + ); + const rollover = positiveRollovers[0]; + const expectedExpiry = nextResetAt; + const actualExpiry = rollover.expires_at; + const diff = Math.abs(actualExpiry - expectedExpiry); + + expect( + diff, + `Expected rollover to expire at ${new Date(expectedExpiry).toISOString()}, got ${new Date(actualExpiry).toISOString()}`, + ).toBeLessThanOrEqual(10 * 60 * 1000); +}; + +test.concurrent( + `${chalk.yellowBright("invoice.created rollover expiry: prepaid uses next reset boundary")}`, + async () => { + const prepaidItem = constructPrepaidItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + billingUnits: 100, + price: 10, + rolloverConfig, + }); + const pro = products.pro({ + id: "pro-prepaid-rollover-expiry", + items: [prepaidItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "invoice-created-prepaid-rollover-expiry", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + s.track({ featureId: TestFeature.Messages, value: 50, timeout: 2000 }), + s.advanceToNextInvoice({ withPause: true }), + ], + }); + + const after = await autumnV2_2.customers.get(customerId); + expectOneMonthRolloverExpiresAtNextReset({ customer: after }); + }, +); + +test.concurrent( + `${chalk.yellowBright("invoice.created rollover expiry: usage-based uses next reset boundary")}`, + async () => { + const consumableItem = constructArrearItem({ + featureId: TestFeature.Messages, + includedUsage: 200, + price: 0.1, + billingUnits: 1, + rolloverConfig, + }); + const pro = products.pro({ + id: "pro-consumable-rollover-expiry", + items: [consumableItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "invoice-created-consumable-rollover-expiry", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.track({ featureId: TestFeature.Messages, value: 50, timeout: 2000 }), + s.advanceToNextInvoice({ withPause: true }), + ], + }); + + const after = await autumnV2_2.customers.get(customerId); + expectOneMonthRolloverExpiresAtNextReset({ customer: after }); + }, +); diff --git a/server/tests/integration/billing/tax/attach-tax-rates/preview-attach-tax-rate-id.test.ts b/server/tests/integration/billing/tax/attach-tax-rates/preview-attach-tax-rate-id.test.ts index 3f4b01715..dd6e12792 100644 --- a/server/tests/integration/billing/tax/attach-tax-rates/preview-attach-tax-rate-id.test.ts +++ b/server/tests/integration/billing/tax/attach-tax-rates/preview-attach-tax-rate-id.test.ts @@ -15,9 +15,12 @@ import { expect, test } from "bun:test"; import type { AttachPreviewResponse } from "@autumn/shared"; +import { getStripeSubscription } from "@tests/integration/billing/utils/stripeSubscriptionUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; +import { createPercentCoupon } from "../../utils/discounts/discountTestUtils.js"; test.concurrent( `${chalk.yellowBright("preview-attach-tax-rate-id (exclusive 10%): preview returns tax.status=complete and inflates total")}`, @@ -105,6 +108,118 @@ test.concurrent( 300_000, ); +test.concurrent( + `${chalk.yellowBright("preview-attach-tax-rate-id (stripe checkout): explicit tax_rate_id still returns tax")}`, + async () => { + const customerId = "preview-tax-rate-stripe-checkout"; + const proProd = products.pro({ id: "pro", items: [] }); + + const { ctx, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [proProd] }), + ], + actions: [], + }); + + const taxRate = await ctx.stripeCli.taxRates.create({ + display_name: "Test Tax Checkout", + percentage: 10, + inclusive: false, + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: proProd.id, + tax_rate_id: taxRate.id, + })) as AttachPreviewResponse; + + expect(preview.checkout_type).toBe("stripe_checkout"); + expect(preview.tax).toBeDefined(); + expect(preview.tax?.status).toBe("complete"); + expect(preview.tax?.amount_exclusive).toBe(2); + expect(preview.total).toBe(22); + }, +); + +test.concurrent( + `${chalk.yellowBright("preview-attach-tax-rate-id (discounted switch): preview tax matches Stripe invoice tax")}`, + async () => { + const customerId = "preview-tax-rate-discount-switch"; + const group = "preview-tax-rate-discount-switch"; + const basicProd = products.base({ + id: "basic-tax-preview", + group, + items: [items.monthlyPrice({ price: 14.9 })], + }); + const proProd = products.base({ + id: "pro-tax-preview", + group, + items: [items.monthlyPrice({ price: 34.9 })], + }); + + const { ctx, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [basicProd, proProd] }), + ], + actions: [], + }); + + const taxRate = await ctx.stripeCli.taxRates.create({ + display_name: "Test Tax Discount Switch", + percentage: 20, + inclusive: false, + }); + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 50, + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: basicProd.id, + tax_rate_id: taxRate.id, + discounts: [{ reward_id: coupon.id }], + }); + + const switchParams = { + customer_id: customerId, + plan_id: proProd.id, + tax_rate_id: taxRate.id, + discounts: [{ reward_id: coupon.id }], + plan_schedule: "immediate" as const, + proration_behavior: "prorate_immediately" as const, + billing_cycle_anchor: "now" as const, + }; + + const preview = (await autumnV2_2.billing.previewAttach( + switchParams, + )) as AttachPreviewResponse; + + await autumnV2_2.billing.attach(switchParams); + + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); + const latestInvoiceId = + typeof subscription.latest_invoice === "string" + ? subscription.latest_invoice + : subscription.latest_invoice?.id; + + expect(latestInvoiceId).toBeDefined(); + + const invoice = await stripeCli.invoices.retrieve(latestInvoiceId!); + expect(invoice.total_excluding_tax).not.toBeNull(); + const invoiceTax = invoice.total - invoice.total_excluding_tax!; + + expect(preview.tax?.total).toBe(invoiceTax / 100); + expect(preview.total).toBe(invoice.total / 100); + }, +); + test.concurrent( `${chalk.yellowBright("preview-attach-tax-rate-id (no tax_rate_id, auto_tax off): preview omits tax field")}`, async () => { @@ -127,5 +242,4 @@ test.concurrent( expect(preview.tax).toBeUndefined(); }, - 300_000, ); diff --git a/server/tests/integration/billing/update-subscription/errors/update-foreign-stripe-subscription.test.ts b/server/tests/integration/billing/update-subscription/errors/update-foreign-stripe-subscription.test.ts new file mode 100644 index 000000000..d09efbaf6 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/errors/update-foreign-stripe-subscription.test.ts @@ -0,0 +1,250 @@ +/** + * TDD test for Connect subscriptions created by another application. + * + * Red-failure mode (current behavior): + * - Autumn can create and pay a manual update invoice before Stripe rejects the subscription update. + * + * Green-success criteria (after fix): + * - The update throws before creating another invoice. + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, BillingInterval, ErrCode } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import { billingActions } from "@/internal/billing/v2/actions"; +import { CusService } from "@/internal/customers/CusService"; + +test.concurrent( + `${chalk.yellowBright("error: mismatched Connect application rejects before invoice")}`, + async () => { + const customerId = "foreign-connect-sub-update"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const stripeCustomer = (await ctx.stripeCli.customers.retrieve( + fullCustomer.processor.id!, + )) as Stripe.Customer; + const stripeSubscriptions = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomer.id, + status: "all", + limit: 1, + }); + const stripeSubscription = stripeSubscriptions.data[0]; + expect(stripeSubscription).toBeDefined(); + + const paymentMethods = await ctx.stripeCli.paymentMethods.list({ + customer: stripeCustomer.id, + type: "card", + limit: 1, + }); + + const originalClientId = process.env.STRIPE_SANDBOX_CLIENT_ID; + process.env.STRIPE_SANDBOX_CLIENT_ID = "ca_autumn_app"; + + let thrown: unknown; + try { + await billingActions.updateSubscription({ + ctx, + params: { + customer_id: customerId, + plan_id: pro.id, + customize: { + price: { + amount: 30, + interval: BillingInterval.Month, + }, + }, + }, + contextOverride: { + stripeBillingContext: { + stripeCustomer, + stripeSubscription: { + ...stripeSubscription, + application: "ca_foreign_app", + }, + stripeDiscounts: [], + paymentMethod: paymentMethods.data[0], + }, + }, + }); + } catch (error) { + thrown = error; + } finally { + process.env.STRIPE_SANDBOX_CLIENT_ID = originalClientId; + } + + expect(thrown).toBeDefined(); + expect((thrown as { code?: string }).code).toBe(ErrCode.InvalidRequest); + expect((thrown as Error).message).toContain( + "Cannot update subscription because it was not created by Autumn", + ); + + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("Connect subscription ownership: null application is allowed")}`, + async () => { + const customerId = "connect-null-application-sub"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const stripeCustomer = (await ctx.stripeCli.customers.retrieve( + fullCustomer.processor.id!, + )) as Stripe.Customer; + const stripeSubscriptions = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomer.id, + status: "all", + limit: 1, + }); + const stripeSubscription = stripeSubscriptions.data[0]; + expect(stripeSubscription).toBeDefined(); + + await expect( + billingActions.updateSubscription({ + ctx, + preview: true, + params: { + customer_id: customerId, + plan_id: pro.id, + customize: { + price: { + amount: 30, + interval: BillingInterval.Month, + }, + }, + }, + contextOverride: { + stripeBillingContext: { + stripeCustomer, + stripeSubscription: { + ...stripeSubscription, + application: null, + }, + stripeDiscounts: [], + }, + }, + }), + ).resolves.toBeDefined(); + }, +); + +test.concurrent( + `${chalk.yellowBright("secret-key subscription ownership: null application is allowed")}`, + async () => { + const customerId = "secret-key-sub-ownership"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const stripeCustomer = (await ctx.stripeCli.customers.retrieve( + fullCustomer.processor.id!, + )) as Stripe.Customer; + const stripeSubscriptions = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomer.id, + status: "all", + limit: 1, + }); + const stripeSubscription = stripeSubscriptions.data[0]; + expect(stripeSubscription).toBeDefined(); + + const secretKeyCtx = { + ...ctx, + org: { + ...ctx.org, + stripe_config: { + ...ctx.org.stripe_config, + test_api_key: "present-for-ownership-check", + }, + }, + }; + + await expect( + billingActions.updateSubscription({ + ctx: secretKeyCtx, + preview: true, + params: { + customer_id: customerId, + plan_id: pro.id, + customize: { + price: { + amount: 30, + interval: BillingInterval.Month, + }, + }, + }, + contextOverride: { + stripeBillingContext: { + stripeCustomer, + stripeSubscription: { + ...stripeSubscription, + application: null, + }, + stripeDiscounts: [], + }, + }, + }), + ).resolves.toBeDefined(); + }, +); diff --git a/server/tests/integration/billing/update-subscription/free-trial/update-free-with-trial.test.ts b/server/tests/integration/billing/update-subscription/free-trial/update-free-with-trial.test.ts index bbadab2b3..ca75972db 100644 --- a/server/tests/integration/billing/update-subscription/free-trial/update-free-with-trial.test.ts +++ b/server/tests/integration/billing/update-subscription/free-trial/update-free-with-trial.test.ts @@ -239,15 +239,11 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-tr const customer = await autumnV1.customers.get(customerId); - // Trial should be extended to 30 days from advancedTo (test clock time) const newTrialEnd = await expectProductTrialing({ customer, productId: freeWithTrial.id, - trialEndsAt: advancedTo! + ms.days(30), // advancedTo + 30 day new trial - toleranceMs: ms.days(1), }); - // New trial end should be later than original expect(newTrialEnd!).toBeGreaterThan(initialTrialEnd!); // Feature updated diff --git a/server/tests/integration/billing/update-subscription/free-trial/update-trial-edge-cases.test.ts b/server/tests/integration/billing/update-subscription/free-trial/update-trial-edge-cases.test.ts index b2fd2a7fd..ba1180556 100644 --- a/server/tests/integration/billing/update-subscription/free-trial/update-trial-edge-cases.test.ts +++ b/server/tests/integration/billing/update-subscription/free-trial/update-trial-edge-cases.test.ts @@ -174,9 +174,9 @@ test.concurrent(`${chalk.yellowBright("trial-edge-cases: start with users, add t const removeTrialPreview = await autumnV1.subscriptions.previewUpdate(removeTrialParams); - // Should charge full price since trial is being removed (base price + 5 seats) + // The preview total reflects the existing credit balance from entering trial. const finalTotal = priceItem.price! + updatedSeatsPrice; - expect(removeTrialPreview.total).toEqual(finalTotal); + expect(removeTrialPreview.total).toEqual(finalTotal - initialTotal); // When trial is removed, next_cycle should not be defined (billing starts now) expectPreviewNextCycleCorrect({ diff --git a/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts b/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts index 39e0ea717..718e8b1a3 100644 --- a/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts +++ b/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts @@ -1,14 +1,18 @@ import { expect, test } from "bun:test"; import { + findActiveCustomerProductById, CusProductStatus, type UpdateSubscriptionV1ParamsInput, } from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectCustomerProductStatuses } from "@tests/integration/billing/utils/expectCustomerProductStatuses"; import { items } from "@tests/utils/fixtures/items"; import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import { CusService } from "@/internal/customers/CusService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; test(`${chalk.yellowBright("processor_subscription_id: attach with existing stripe subscription anchors reset cycle")}`, async () => {}); @@ -35,10 +39,10 @@ test(`${chalk.yellowBright("update no_billing_changes: customize preserves subsc ctx, idOrInternalId: customerId, }); - const cusProductBefore = fullCustomerBefore.customer_products.find( - (cp) => - cp.product_id === pro.id && cp.status === CusProductStatus.Active, - ); + const cusProductBefore = findActiveCustomerProductById({ + fullCus: fullCustomerBefore, + productId: pro.id, + }); expect(cusProductBefore).toBeDefined(); const originalSubIds = cusProductBefore?.subscription_ids ?? []; expect(originalSubIds.length).toBeGreaterThan(0); @@ -54,16 +58,85 @@ test(`${chalk.yellowBright("update no_billing_changes: customize preserves subsc }, }); - const fullCustomerAfter = await CusService.getFull({ + await expectCustomerProducts({ + customer: await autumnV2.customers.get(customerId), + active: [pro.id], + }); + + const { byStatus } = await expectCustomerProductStatuses({ + ctx, + customerId, + productId: pro.id, + expected: { + [CusProductStatus.Active]: 1, + }, + }); + expect(byStatus[CusProductStatus.Active]?.[0]?.subscription_ids).toEqual( + originalSubIds, + ); +}); + +// Red: replacement-style updates reset a past_due cusProduct to active. +// Green: the replacement inherits status and keeps the subscription link. +test(`${chalk.yellowBright("update no_billing_changes: replacement customize preserves past_due status")}`, async () => { + const customerId = "update-no-billing-preserves-past-due"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); + + const { autumnV1, autumnV2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const fullCustomerBefore = await CusService.getFull({ ctx, idOrInternalId: customerId, }); - const activeProRows = fullCustomerAfter.customer_products.filter( - (cp) => - cp.product_id === pro.id && cp.status === CusProductStatus.Active, - ); - expect(activeProRows.length).toBe(1); - const cusProductAfter = activeProRows[0]; + const cusProductBefore = findActiveCustomerProductById({ + fullCus: fullCustomerBefore, + productId: pro.id, + }); + expect(cusProductBefore).toBeDefined(); + const originalSubIds = cusProductBefore?.subscription_ids ?? []; + expect(originalSubIds.length).toBeGreaterThan(0); - expect(cusProductAfter.subscription_ids).toEqual(originalSubIds); + await CusProductService.update({ + ctx, + cusProductId: cusProductBefore!.id, + updates: { status: CusProductStatus.PastDue }, + }); + + await autumnV2.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + no_billing_changes: true, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [itemsV2.monthlyMessages({ included: 250 })], + }, + }); + + await expectCustomerProducts({ + customer: await autumnV1.customers.get(customerId), + pastDue: [pro.id], + }); + + const { byStatus } = await expectCustomerProductStatuses({ + ctx, + customerId, + productId: pro.id, + expected: { + [CusProductStatus.PastDue]: 1, + [CusProductStatus.Expired]: 1, + }, + }); + expect(byStatus[CusProductStatus.PastDue]?.[0]?.subscription_ids).toEqual( + originalSubIds, + ); }); diff --git a/server/tests/integration/billing/utils/annualMonthlyMessagesTestUtils.ts b/server/tests/integration/billing/utils/annualMonthlyMessagesTestUtils.ts new file mode 100644 index 000000000..56d461fc5 --- /dev/null +++ b/server/tests/integration/billing/utils/annualMonthlyMessagesTestUtils.ts @@ -0,0 +1,312 @@ +import { expect } from "bun:test"; +import { + addInterval, + type AttachPreviewResponse, + BillingInterval, + type BillingPreviewResponse, + formatMsToDate, + ms, + type ProductV2, +} from "@autumn/shared"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { Decimal } from "decimal.js"; +import type Stripe from "stripe"; + +export const ANNUAL_MONTHLY_MESSAGES_PHASES = [ + { annualAmount: 240, prepaidQuantity: 100 }, + { annualAmount: 360, prepaidQuantity: 200 }, + { annualAmount: 480, prepaidQuantity: 300 }, +] as const; + +export const PREPAID_MESSAGE_BILLING_UNITS = 100; +export const PREPAID_MESSAGE_PACK_PRICE = 10; +export const CONSUMABLE_MESSAGE_UNIT_PRICE = 0.1; + +export const annualMonthlyMessagesPlan = ({ + id = "annual-monthly-messages", +}: { + id?: string; +} = {}): ProductV2 => + products.base({ + id, + items: [ + items.annualPrice({ price: 1 }), + items.prepaidMessages({ + includedUsage: 0, + billingUnits: PREPAID_MESSAGE_BILLING_UNITS, + price: PREPAID_MESSAGE_PACK_PRICE, + }), + items.consumableMessages({ + includedUsage: 0, + price: CONSUMABLE_MESSAGE_UNIT_PRICE, + }), + ], + }); + +export const annualMonthlyPhasePlan = ({ + planId, + annualAmount, + prepaidQuantity, +}: { + planId: string; + annualAmount: number; + prepaidQuantity: number; +}) => ({ + plan_id: planId, + customize: { + price: itemsV2.annualPrice({ amount: annualAmount }), + }, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], +}); + +export const prepaidMessagesAmount = ({ quantity }: { quantity: number }) => + new Decimal(quantity) + .div(PREPAID_MESSAGE_BILLING_UNITS) + .mul(PREPAID_MESSAGE_PACK_PRICE) + .toDecimalPlaces(2) + .toNumber(); + +export const consumableMessagesAmount = ({ usage }: { usage: number }) => + new Decimal(usage) + .mul(CONSUMABLE_MESSAGE_UNIT_PRICE) + .toDecimalPlaces(2) + .toNumber(); + +export const countMonthlyPeriods = ({ + startsAt, + currentEpochMs, +}: { + startsAt: number; + currentEpochMs: number; +}) => { + let periodStart = startsAt; + let periods = 0; + + while (periodStart < currentEpochMs) { + periods += 1; + periodStart = addInterval({ + from: periodStart, + interval: BillingInterval.Month, + }); + } + + return Math.max(periods, 1); +}; + +export const nextMonthlyBoundary = ({ + startsAt, + currentEpochMs, +}: { + startsAt: number; + currentEpochMs: number; +}) => + addInterval({ + from: startsAt, + interval: BillingInterval.Month, + intervalCount: countMonthlyPeriods({ startsAt, currentEpochMs }), + }); + +export const expectedAnnualMonthlyImmediateTotal = ({ + annualAmount, + prepaidQuantity, + startsAt, + currentEpochMs, +}: { + annualAmount: number; + prepaidQuantity: number; + startsAt: number; + currentEpochMs: number; +}) => + new Decimal(annualAmount) + .plus( + new Decimal(prepaidMessagesAmount({ quantity: prepaidQuantity })).mul( + countMonthlyPeriods({ startsAt, currentEpochMs }), + ), + ) + .toDecimalPlaces(2) + .toNumber(); + +const expectPeriod = ({ + actual, + expected, + toleranceMs = ms.seconds(10), +}: { + actual?: { start: number; end: number }; + expected: { start: number; end: number }; + toleranceMs?: number; +}) => { + expect(actual).toBeDefined(); + expect(Math.abs(actual!.start - expected.start)).toBeLessThan(toleranceMs); + expect(Math.abs(actual!.end - expected.end)).toBeLessThan(toleranceMs); +}; + +const expectDescriptionPeriod = ({ + description, + period, +}: { + description: string; + period: { start: number; end: number }; +}) => { + expect(description).toContain(`from ${formatMsToDate(period.start)}`); + expect(description).toContain(`to ${formatMsToDate(period.end)}`); +}; + +export const expectAnnualMonthlyPreviewCorrect = ({ + preview, + annualAmount, + prepaidQuantity, + startsAt, + currentEpochMs, +}: { + preview: BillingPreviewResponse | AttachPreviewResponse; + annualAmount: number; + prepaidQuantity: number; + startsAt: number; + currentEpochMs: number; +}) => { + const monthlyCycles = countMonthlyPeriods({ startsAt, currentEpochMs }); + const prepaidTotal = prepaidMessagesAmount({ quantity: prepaidQuantity }); + const nextCycleStart = nextMonthlyBoundary({ startsAt, currentEpochMs }); + const expectedTotal = expectedAnnualMonthlyImmediateTotal({ + annualAmount, + prepaidQuantity, + startsAt, + currentEpochMs, + }); + + expect(preview.subtotal).toBe(expectedTotal); + expect(preview.total).toBe(expectedTotal); + expect(preview.line_items.reduce((sum, item) => sum + item.total, 0)).toBe( + preview.total, + ); + + const annualLine = preview.line_items.find((item) => item.feature_id === null); + const prepaidLine = preview.line_items.find( + (item) => item.feature_id === TestFeature.Messages, + ); + expect(annualLine).toBeDefined(); + expect(prepaidLine).toBeDefined(); + + const annualPeriod = { + start: startsAt, + end: addInterval({ from: startsAt, interval: BillingInterval.Year }), + }; + const prepaidPeriod = { start: startsAt, end: nextCycleStart }; + + expect(annualLine!.total).toBe(annualAmount); + expect(annualLine!.subtotal).toBe(annualAmount); + expectPeriod({ actual: annualLine!.period, expected: annualPeriod }); + expectDescriptionPeriod({ + description: annualLine!.description, + period: annualPeriod, + }); + + expect(prepaidLine!.total).toBe( + new Decimal(prepaidTotal).mul(monthlyCycles).toNumber(), + ); + expect(prepaidLine!.quantity).toBe(prepaidQuantity); + expectPeriod({ actual: prepaidLine!.period, expected: prepaidPeriod }); + expectDescriptionPeriod({ + description: prepaidLine!.description, + period: prepaidPeriod, + }); + + const nextCycle = expectPreviewNextCycleCorrect({ + preview, + startsAt: nextCycleStart, + total: prepaidTotal, + }); + expect(nextCycle?.subtotal).toBe(prepaidTotal); + expect(nextCycle?.line_items.length).toBe(1); + expect(nextCycle?.usage_line_items.length).toBe(1); + expect(nextCycle?.usage_line_items[0]?.feature_id).toBe(TestFeature.Messages); +}; + +const lineAmount = (line: Stripe.InvoiceLineItem) => + new Decimal(line.amount).div(100).toDecimalPlaces(2).toNumber(); + +const linePeriod = (line: Stripe.InvoiceLineItem) => ({ + start: line.period.start * 1000, + end: line.period.end * 1000, +}); + +const linesWithDuration = ({ + invoice, + interval, +}: { + invoice: Stripe.Invoice; + interval: "month" | "year"; +}) => + invoice.lines.data.filter((line) => { + const duration = linePeriod(line).end - linePeriod(line).start; + if (interval === "year") return duration > ms.days(300); + return duration > ms.days(20) && duration <= ms.days(45); + }); + +export const expectAnnualMonthlyStripeInvoiceCorrect = ({ + invoice, + annualAmount, + monthlyAmount, + monthlyPeriods, + expectedTotal, +}: { + invoice: Stripe.Invoice; + annualAmount?: number; + monthlyAmount: number; + monthlyPeriods: { start: number; end: number }[]; + expectedTotal: number; +}) => { + expect(new Decimal(invoice.total).div(100).toNumber()).toBe(expectedTotal); + + if (annualAmount !== undefined) { + const annualLines = linesWithDuration({ invoice, interval: "year" }); + expect(annualLines).toHaveLength(1); + expect(lineAmount(annualLines[0]!)).toBe(annualAmount); + } + + const monthlyLines = linesWithDuration({ invoice, interval: "month" }).filter( + (line) => line.amount > 0, + ); + expect(monthlyLines).toHaveLength(monthlyPeriods.length); + + for (const expectedPeriod of monthlyPeriods) { + const line = monthlyLines.find((line) => { + const period = linePeriod(line); + return ( + Math.abs(period.start - expectedPeriod.start) < ms.minutes(1) && + Math.abs(period.end - expectedPeriod.end) < ms.minutes(1) + ); + }); + + expect(line).toBeDefined(); + expect(lineAmount(line!)).toBe(monthlyAmount); + } +}; + +export const monthlyPeriodsFrom = ({ + startsAt, + count, +}: { + startsAt: number; + count: number; +}) => + Array.from({ length: count }, (_, index) => { + const start = addInterval({ + from: startsAt, + interval: BillingInterval.Month, + intervalCount: index, + }); + return { + start, + end: addInterval({ from: start, interval: BillingInterval.Month }), + }; + }); diff --git a/server/tests/integration/billing/utils/expectBackdatedStripeSubscriptionCorrect.ts b/server/tests/integration/billing/utils/expectBackdatedStripeSubscriptionCorrect.ts new file mode 100644 index 000000000..ca4e91354 --- /dev/null +++ b/server/tests/integration/billing/utils/expectBackdatedStripeSubscriptionCorrect.ts @@ -0,0 +1,84 @@ +import { expect } from "bun:test"; +import { ms } from "@autumn/shared"; +import type { initScenario } from "@tests/utils/testInitUtils/initScenario"; +import type Stripe from "stripe"; + +type Ctx = Awaited>["ctx"]; + +export const expectTimestampClose = ({ + actualSeconds, + expectedMs, + toleranceMs = ms.minutes(2), +}: { + actualSeconds: number; + expectedMs: number; + toleranceMs?: number; +}) => { + expect(Math.abs(actualSeconds * 1000 - expectedMs)).toBeLessThan(toleranceMs); +}; + +export const expectBackdatedStripeSubscriptionCorrect = async ({ + ctx, + stripeSubscriptionId, + startsAt, + stripeInvoiceId, + minInvoiceTotal, + minInvoiceLineCount, + expandSchedule = false, +}: { + ctx: Ctx; + stripeSubscriptionId: string; + startsAt: number; + stripeInvoiceId?: string; + minInvoiceTotal?: number; + minInvoiceLineCount?: number; + expandSchedule?: boolean; +}): Promise<{ + stripeSubscription: Stripe.Subscription; + stripeInvoice: Stripe.Invoice; + stripeSchedule?: Stripe.SubscriptionSchedule; +}> => { + const stripeSubscription = await ctx.stripeCli.subscriptions.retrieve( + stripeSubscriptionId, + { + expand: ["latest_invoice", ...(expandSchedule ? ["schedule"] : [])], + }, + ); + + expectTimestampClose({ + actualSeconds: stripeSubscription.start_date, + expectedMs: startsAt, + }); + + const latestInvoice = stripeSubscription.latest_invoice as Stripe.Invoice; + expect(latestInvoice).toBeDefined(); + + if (stripeInvoiceId !== undefined) { + expect(latestInvoice.id).toBe(stripeInvoiceId); + } + + if (minInvoiceTotal !== undefined) { + expect(latestInvoice.total).toBeGreaterThan(minInvoiceTotal); + } + + const stripeInvoice = await ctx.stripeCli.invoices.retrieve( + latestInvoice.id, + { + expand: ["lines"], + }, + ); + + if (minInvoiceLineCount !== undefined) { + expect(stripeInvoice.lines.data.length).toBeGreaterThanOrEqual( + minInvoiceLineCount, + ); + } + + return { + stripeSubscription, + stripeInvoice, + stripeSchedule: expandSchedule + ? (stripeSubscription.schedule as Stripe.SubscriptionSchedule) + : undefined, + }; +}; diff --git a/server/tests/integration/billing/utils/expectCustomerProductStatuses.ts b/server/tests/integration/billing/utils/expectCustomerProductStatuses.ts new file mode 100644 index 000000000..93688283c --- /dev/null +++ b/server/tests/integration/billing/utils/expectCustomerProductStatuses.ts @@ -0,0 +1,73 @@ +import { expect } from "bun:test"; +import { + CusProductStatus, + type FullCusProduct, + type CusProductStatus as CusProductStatusType, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { CusService } from "@/internal/customers/CusService"; + +type CustomerProductStatusesResult = { + customerProducts: FullCusProduct[]; + byStatus: Partial>; +}; + +export const expectCustomerProductStatuses = async ({ + ctx, + customerId, + productId, + entityId, + expected, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; + entityId?: string; + expected: Partial>; +}): Promise => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.PastDue, + CusProductStatus.Scheduled, + CusProductStatus.Expired, + ], + withEntities: true, + }); + + const customerProducts = fullCustomer.customer_products.filter( + (customerProduct) => + customerProduct.product_id === productId && + (entityId ? customerProduct.entity_id === entityId : true), + ); + + const byStatus = customerProducts.reduce< + Partial> + >((acc, customerProduct) => { + acc[customerProduct.status] = [ + ...(acc[customerProduct.status] ?? []), + customerProduct, + ]; + return acc; + }, {}); + + for (const [status, count] of Object.entries(expected)) { + const matchingCustomerProducts = + byStatus[status as CusProductStatusType] ?? []; + + expect( + matchingCustomerProducts.length, + `Expected ${count} ${status} rows for ${productId}; got ${JSON.stringify( + customerProducts.map((customerProduct) => ({ + id: customerProduct.id, + status: customerProduct.status, + version: customerProduct.product.version, + })), + )}`, + ).toBe(count); + } + + return { customerProducts, byStatus }; +}; diff --git a/server/tests/integration/billing/utils/expectPreviewNextCycleCorrect.ts b/server/tests/integration/billing/utils/expectPreviewNextCycleCorrect.ts index b79abd377..dead4ef1f 100644 --- a/server/tests/integration/billing/utils/expectPreviewNextCycleCorrect.ts +++ b/server/tests/integration/billing/utils/expectPreviewNextCycleCorrect.ts @@ -1,5 +1,9 @@ import { expect } from "bun:test"; -import { type BillingPreviewResponse, formatMs } from "@autumn/shared"; +import { + type AttachPreviewResponse, + type BillingPreviewResponse, + formatMs, +} from "@autumn/shared"; const ONE_DAY_MS = 24 * 60 * 60 * 1000; @@ -17,7 +21,7 @@ export const expectPreviewNextCycleCorrect = ({ total, toleranceMs = ONE_DAY_MS, }: { - preview: BillingPreviewResponse; + preview: BillingPreviewResponse | AttachPreviewResponse; /** Whether next_cycle should be defined (default: true) */ expectDefined?: boolean; /** Expected starts_at as absolute Unix timestamp (ms) */ diff --git a/server/tests/integration/crud/customers/create-customer-existing-stripe.test.ts b/server/tests/integration/crud/customers/create-customer-existing-stripe.test.ts new file mode 100644 index 000000000..000710b4c --- /dev/null +++ b/server/tests/integration/crud/customers/create-customer-existing-stripe.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; + +const uniqueSuffix = () => Math.random().toString(36).slice(2, 10); + +// Contract: create_in_stripe on an existing Autumn customer ensures one Stripe processor. +test.concurrent( + `${chalk.yellowBright("customers: existing customer create_in_stripe creates Stripe customer")}`, + async () => { + const suffix = uniqueSuffix(); + const customerId = `existing-stripe-${suffix}`; + const email = `${customerId}@example.com`; + + const { autumnV1, autumnV2_1, ctx } = await initScenario({ + setup: [s.deleteCustomer({ customerId })], + actions: [], + }); + + const initial = await autumnV1.customers.create({ + id: customerId, + name: "Existing Stripe", + email, + internalOptions: { disable_defaults: true }, + }); + expect(initial.stripe_id).toBeNull(); + + const createdInStripe = await autumnV1.customers.create({ + id: customerId, + name: "Existing Stripe", + email, + create_in_stripe: true, + internalOptions: { disable_defaults: true }, + }); + + expect(createdInStripe.stripe_id).toMatch(/^cus_/); + + const apiCustomer = + await autumnV2_1.customers.get(customerId); + expect(apiCustomer.processors?.stripe?.id).toBe(createdInStripe.stripe_id); + + const fromDb = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + expect(fromDb.processor?.id).toBe(createdInStripe.stripe_id); + + const second = await autumnV1.customers.create({ + id: customerId, + name: "Existing Stripe", + email, + create_in_stripe: true, + internalOptions: { disable_defaults: true }, + }); + expect(second.stripe_id).toBe(createdInStripe.stripe_id); + + const stripeCustomers = await ctx.stripeCli.customers.list({ email }); + expect(stripeCustomers.data.map((customer) => customer.id)).toContain( + createdInStripe.stripe_id, + ); + expect( + stripeCustomers.data.filter( + (customer) => customer.id === second.stripe_id, + ), + ).toHaveLength(1); + }, +); + +test.concurrent( + `${chalk.yellowBright("customers: concurrent existing create_in_stripe with paid default is idempotent")}`, + async () => { + const suffix = uniqueSuffix(); + const customerId = `existing-stripe-race-${suffix}`; + const email = `${customerId}@example.com`; + const paidDefault = products.defaultTrial({ + id: "existing-stripe-race-default", + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 14, + cardRequired: false, + }); + + const { autumnV1, ctx } = await initScenario({ + setup: [ + s.deleteCustomer({ customerId }), + s.products({ list: [paidDefault], prefix: customerId }), + ], + actions: [], + }); + + await autumnV1.customers.create({ + id: customerId, + name: "Existing Stripe Race", + email, + internalOptions: { disable_defaults: true }, + }); + + const results = await Promise.all( + Array.from({ length: 5 }, () => + autumnV1.customers.create({ + id: customerId, + name: "Existing Stripe Race", + email, + create_in_stripe: true, + internalOptions: { default_group: customerId }, + }), + ), + ); + + const stripeIds = results.map((result) => result.stripe_id); + expect(new Set(stripeIds).size).toBe(1); + expect(stripeIds[0]).toMatch(/^cus_/); + + const fromDb = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + expect(fromDb.processor?.id).toBe(stripeIds[0]); + expect(fromDb.customer_products).toHaveLength(0); + + const subscriptions = await ctx.stripeCli.subscriptions.list({ + customer: stripeIds[0], + status: "all", + }); + expect(subscriptions.data).toHaveLength(0); + }, +); diff --git a/server/tests/integration/crud/customers/customer-scope.test.ts b/server/tests/integration/crud/customers/customer-scope.test.ts new file mode 100644 index 000000000..8ed9db1cc --- /dev/null +++ b/server/tests/integration/crud/customers/customer-scope.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + ApiCustomerV5Schema, +} from "@shared/api/customers/apiCustomerV5"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// CUSTOMER SCOPE — `scope` field on subscriptions and purchases +// +// Contract under test: +// New types/fields: +// - ApiSubscriptionV1.scope: "customer" | "entity" +// - ApiPurchaseV0.scope: "customer" | "entity" +// New behaviors: +// - Customer-level product (internal_entity_id === null) → "customer" +// - Entity-level product (internal_entity_id !== null) → "entity" +// Side effects: none (pure projection from existing FullCusProduct). +// +// Pre-impl red: scope field is undefined, schema parse rejects. +// Post-impl green: all assertions pass. +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("customer scope: customer-level subscription has scope=customer, entity-level has scope=entity")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const creditsItem = items.monthlyCredits({ includedUsage: 200 }); + + const cusLevelProd = products.pro({ + id: "cus-lvl-scope", + items: [messagesItem], + }); + const entityProd = products.base({ + id: "ent-prod-scope", + items: [creditsItem], + }); + + const customerId = "customer-scope-test"; + + const { autumnV2_2, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [cusLevelProd, entityProd] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: cusLevelProd.id }), + s.attach({ + productId: entityProd.id, + entityIndex: 0, + }), + ], + }); + + const customer = await autumnV2_2.customers.get(customerId, { + keepInternalFields: true, + }); + ApiCustomerV5Schema.parse(customer); + + // ── Customer-level subscription ── + const cusSub = customer.subscriptions.find( + (s) => s.plan_id === cusLevelProd.id, + ); + expect(cusSub).toBeDefined(); + expect(cusSub!.scope).toBe("customer"); + + // ── Entity-level subscription ── + const entSub = customer.subscriptions.find( + (s) => s.plan_id === entityProd.id, + ); + expect(entSub).toBeDefined(); + expect(entSub!.scope).toBe("entity"); + }, +); + +test.concurrent( + `${chalk.yellowBright("customer scope: purchase (one-off) has scope=customer")}`, + async () => { + const oneOffItem = items.oneOffMessages({ includedUsage: 50 }); + const oneOffProd = products.oneOff({ + id: "one-off-scope", + items: [oneOffItem], + }); + + const customerId = "customer-scope-one-off"; + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [s.billing.attach({ productId: oneOffProd.id })], + }); + + const customer = await autumnV2_2.customers.get(customerId, { + keepInternalFields: true, + }); + ApiCustomerV5Schema.parse(customer); + + expect(customer.purchases.length).toBe(1); + expect(customer.purchases[0].scope).toBe("customer"); + }, +); + +test.concurrent( + `${chalk.yellowBright("customer scope: cached and uncached reads match")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "scope-cache", items: [messagesItem] }); + + const customerId = "customer-scope-cache"; + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const cached = await autumnV2_2.customers.get(customerId); + expect(cached.subscriptions[0].scope).toBe("customer"); + + const uncached = await autumnV2_2.customers.get(customerId, { + skip_cache: "true", + }); + expect(uncached.subscriptions[0].scope).toBe("customer"); + }, +); diff --git a/server/tests/integration/crud/customers/get-customer-entity-rollover-granted.test.ts b/server/tests/integration/crud/customers/get-customer-entity-rollover-granted.test.ts new file mode 100644 index 000000000..ad97b2f88 --- /dev/null +++ b/server/tests/integration/crud/customers/get-customer-entity-rollover-granted.test.ts @@ -0,0 +1,77 @@ +// Red: customer aggregation omitted rollover grant from entity-scoped products. +// Green: customer granted includes active entity rollover balance and usage. + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type LimitedItem, + ProductItemInterval, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireAllCusEntsForReset } from "@tests/utils/cusProductUtils/resetTestUtils.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; + +test.concurrent( + `${chalk.yellowBright("get-customer: entity product rollovers contribute to granted")}`, + async () => { + const customerId = "get-customer-entity-rollover-granted"; + const rolloverConfig = { + max: 500, + length: 1, + duration: RolloverExpiryDurationType.Month, + }; + const creditsItem = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 100, + interval: ProductItemInterval.Month, + rolloverConfig, + }) as LimitedItem; + const base = products.base({ + id: "entity-product-rollover-granted", + items: [creditsItem], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.deleteCustomer({ customerId }), + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [base] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: base.id, entityIndex: 0 })], + }); + + await autumnV1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Credits, + value: 40, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await expireAllCusEntsForReset({ + ctx, + customerId, + featureId: TestFeature.Credits, + }); + await autumnV2_2.entities.get(customerId, entities[0].id); + + const after = await autumnV2_2.customers.get(customerId, { + skip_cache: "true", + }); + + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Credits, + remaining: 160, + usage: 0, + }); + expect(after.balances[TestFeature.Credits].granted).toBe(160); + }, +); diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.firecrawl.fixtures.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.firecrawl.fixtures.ts new file mode 100644 index 000000000..502c262b8 --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.firecrawl.fixtures.ts @@ -0,0 +1,55 @@ +import { type ApiPlanV1 } from "@autumn/shared"; +import { findById } from "./utils/findById.js"; + +// Bun-native JSON import (module: Preserve + bundler resolution). +import firecrawlDump from "./firecrawl-plans.json" with { type: "json" }; + +const items = firecrawlDump.items as ApiPlanV1[]; + +// Group 1 — Scale (base = scale_tier_1) +export const scaleTier1Base = findById(items, "scale_tier_1"); +export const scaleVariants: ApiPlanV1[] = [ + findById(items, "scale_tier_2"), + findById(items, "scale_tier_3"), + findById(items, "scale_tier_4"), + findById(items, "scale_tier_1_quarterly"), + findById(items, "scale_tier_2_quarterly"), + findById(items, "scale_tier_3_quarterly"), + findById(items, "scale_tier_4_quarterly"), + findById(items, "scale_monthly"), +]; + +// Group 2 — Hobby (base = hobby) +export const hobbyBase = findById(items, "hobby"); +export const hobbyVariants: ApiPlanV1[] = [ + findById(items, "hobby_yearly"), + findById(items, "hobby_monthly_5k"), + findById(items, "hobby_monthly_6_5k"), + findById(items, "hobby_monthly_8k"), + findById(items, "hobby_yearly_5k"), + findById(items, "hobby_yearly_6_5k"), + findById(items, "hobby_yearly_8k"), +]; + +// Group 3 — Standard (base = standard) +export const standardBase = findById(items, "standard"); +export const standardVariants: ApiPlanV1[] = [ + findById(items, "standard_yearly"), + findById(items, "standard_monthly_100k"), + findById(items, "standard_monthly_130k"), + findById(items, "standard_monthly_160k"), + findById(items, "standard_yearly_100k"), + findById(items, "standard_yearly_130k"), + findById(items, "standard_yearly_160k"), +]; + +// Group 4 — Growth (base = growth) +export const growthBase = findById(items, "growth"); +export const growthVariants: ApiPlanV1[] = [ + findById(items, "growth_yearly"), + findById(items, "growth_monthly_500k"), + findById(items, "growth_monthly_650k"), + findById(items, "growth_monthly_800k"), + findById(items, "growth_yearly_500k"), + findById(items, "growth_yearly_650k"), +]; diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.firecrawl.test.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.firecrawl.test.ts new file mode 100644 index 000000000..ec3fd3175 --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.firecrawl.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import type { ApiPlanV1 } from "@autumn/shared"; +import { + applyDiff, + type ApplyDiffOutput, +} from "@autumn/shared/utils/planV1Utils/diff/applyDiff.js"; +import { + growthBase, + growthVariants, + hobbyBase, + hobbyVariants, + scaleTier1Base, + scaleVariants, + standardBase, + standardVariants, +} from "./diffPlanV1.firecrawl.fixtures.js"; +import { diffPlanV1 } from "@autumn/shared/utils/planV1Utils/diff/diffPlanV1.js"; +import { normalizePlan } from "./utils/normalizePlan.js"; + +// --- test matrix --- +const groups = [ + { name: "scale", base: scaleTier1Base, variants: scaleVariants }, + { name: "hobby", base: hobbyBase, variants: hobbyVariants }, + { name: "standard", base: standardBase, variants: standardVariants }, + { name: "growth", base: growthBase, variants: growthVariants }, +]; + +for (const { name, base, variants } of groups) { + describe(`firecrawl ${name} group — diff/apply round-trip`, () => { + for (const variant of variants) { + test(`${variant.id} reconstructs from ${base.id} + diff`, () => { + const diff = diffPlanV1({ from: base, to: variant }); + const reconstructed = applyDiff({ base, diff }); + expect(normalizePlan(reconstructed)).toEqual(normalizePlan(variant)); + }); + } + }); +} + +describe("filter precision — same-feature-id siblings", () => { + test("mutating the priced CREDITS leaves the price-null CREDITS intact", () => { + const base = growthBase; + const pricedCredits = base.items.find( + (i) => i.feature_id === "CREDITS" && i.price != null, + )!; + const mutated: ApiPlanV1 = { + ...base, + items: base.items.map((item) => + item === pricedCredits + ? { ...item, included: item.included + 1 } + : item, + ), + }; + + const diff = diffPlanV1({ from: base, to: mutated }); + const reconstructed = applyDiff({ base, diff }); + + const stillHasPriceNullCredits = reconstructed.items.some( + (i) => + i.feature_id === "CREDITS" && + i.price == null && + i.reset?.interval === "month", + ); + expect(stillHasPriceNullCredits).toBe(true); + + const mutatedCredits = reconstructed.items.find( + (i) => i.feature_id === "CREDITS" && i.price != null, + ); + expect(mutatedCredits?.included).toBe(pricedCredits.included + 1); + }); +}); diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.fixtures.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.fixtures.ts new file mode 100644 index 000000000..23b869856 --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.fixtures.ts @@ -0,0 +1,695 @@ +import type { ApiPlanV1 } from "@autumn/shared"; + +export const popflyStart = { + "id": "start", + "name": "Run", + "description": null, + "group": null, + "version": 8, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 499, + "interval": "month", + "display": { + "primary_text": "$499", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "adventures", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Adventures" + } + }, + { + "feature_id": "adventures_visible", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited adventures visible" + } + }, + { + "feature_id": "affiliate_programs", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited affiliate programs" + } + }, + { + "feature_id": "affiliates_csv_export", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Affiliates CSV Export" + } + }, + { + "feature_id": "affiliates_per_program", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited affiliates per program" + } + }, + { + "feature_id": "affiliates_reporting", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Affiliates Reporting" + } + }, + { + "feature_id": "campaign_progress_management", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Campaign Progress Management" + } + }, + { + "feature_id": "campaigns", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Campaigns" + } + }, + { + "feature_id": "connections_limit_company_with_company", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited company connections limits" + } + }, + { + "feature_id": "company_members", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited company members" + } + }, + { + "feature_id": "content", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Content" + } + }, + { + "feature_id": "content_storage", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited content storages" + } + }, + { + "feature_id": "connections_limit_company_with_creators", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited creator connections limits" + } + }, + { + "feature_id": "creator_discovery_advanced", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Creator Discovery Advanced" + } + }, + { + "feature_id": "gifting_invitations", + "included": 200, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "200 gifting invitations" + } + }, + { + "feature_id": "gifting_products", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited gifting products" + } + }, + { + "feature_id": "invite_through_popfly_advanced", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Invite Through Popfly Advanced" + } + }, + { + "feature_id": "invoice_fee", + "included": 390, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "390 invoice fees" + } + }, + { + "feature_id": "campaigns_private_monthly", + "included": 0, + "unlimited": true, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "Unlimited monthly private campaigns" + } + }, + { + "feature_id": "campaigns_public_monthly", + "included": 0, + "unlimited": true, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "Unlimited monthly public campaigns" + } + }, + { + "feature_id": "campaigns_unlisted_monthly", + "included": 0, + "unlimited": true, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "Unlimited Monthly Unlisted Campaigns" + } + }, + { + "feature_id": "packs", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Packs" + } + }, + { + "feature_id": "playbook", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Playbook" + } + }, + { + "feature_id": "popfly_platform", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Popfly Platform" + } + }, + { + "feature_id": "affiliate_programs_public", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited public affiliate programs" + } + }, + { + "feature_id": "social_listening", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Social Listening" + } + }, + { + "feature_id": "social_listening_mention_results", + "included": 25, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "25 social listening mention results" + } + }, + { + "feature_id": "social_listening_platforms", + "included": 1, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "1 Social Listening Platform" + } + }, + { + "feature_id": "social_listening_refresh_frequency_in_hours", + "included": 168, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "168 social listening refresh frequencies (hours)" + } + }, + { + "feature_id": "social_listening_terms", + "included": 1, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "1 social listening term" + } + }, + { + "feature_id": "social_listening_topics", + "included": 1, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "1 social listening topic" + } + } + ], + "created_at": 1777460151677, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } +} as ApiPlanV1; + +export const popflyStartAnnual = { + "id": "start_annual", + "name": "Run - annual", + "description": null, + "group": null, + "version": 9, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 5988, + "interval": "year", + "display": { + "primary_text": "$5,988", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "adventures", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Adventures" + } + }, + { + "feature_id": "adventures_visible", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited adventures visible" + } + }, + { + "feature_id": "affiliate_programs", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited affiliate programs" + } + }, + { + "feature_id": "affiliates_csv_export", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Affiliates CSV Export" + } + }, + { + "feature_id": "affiliates_per_program", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited affiliates per program" + } + }, + { + "feature_id": "affiliates_reporting", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Affiliates Reporting" + } + }, + { + "feature_id": "campaign_progress_management", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Campaign Progress Management" + } + }, + { + "feature_id": "campaigns", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Campaigns" + } + }, + { + "feature_id": "connections_limit_company_with_company", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited company connections limits" + } + }, + { + "feature_id": "company_members", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited company members" + } + }, + { + "feature_id": "content", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Content" + } + }, + { + "feature_id": "content_storage", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited content storages" + } + }, + { + "feature_id": "connections_limit_company_with_creators", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited creator connections limits" + } + }, + { + "feature_id": "creator_discovery_advanced", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Creator Discovery Advanced" + } + }, + { + "feature_id": "gifting_invitations", + "included": 200, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "200 gifting invitations" + } + }, + { + "feature_id": "gifting_products", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited gifting products" + } + }, + { + "feature_id": "invite_through_popfly_advanced", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Invite Through Popfly Advanced" + } + }, + { + "feature_id": "invoice_fee", + "included": 390, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "390 invoice fees" + } + }, + { + "feature_id": "campaigns_private_monthly", + "included": 0, + "unlimited": true, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "Unlimited monthly private campaigns" + } + }, + { + "feature_id": "campaigns_public_monthly", + "included": 0, + "unlimited": true, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "Unlimited monthly public campaigns" + } + }, + { + "feature_id": "campaigns_unlisted_monthly", + "included": 0, + "unlimited": true, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "Unlimited Monthly Unlisted Campaigns" + } + }, + { + "feature_id": "packs", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Packs" + } + }, + { + "feature_id": "playbook", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Playbook" + } + }, + { + "feature_id": "popfly_platform", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Popfly Platform" + } + }, + { + "feature_id": "affiliate_programs_public", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited public affiliate programs" + } + }, + { + "feature_id": "social_listening", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Social Listening" + } + }, + { + "feature_id": "social_listening_mention_results", + "included": 25, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "25 social listening mention results" + } + }, + { + "feature_id": "social_listening_platforms", + "included": 1, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "1 Social Listening Platform" + } + }, + { + "feature_id": "social_listening_refresh_frequency_in_hours", + "included": 168, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "168 social listening refresh frequencies (hours)" + } + }, + { + "feature_id": "social_listening_terms", + "included": 1, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "1 social listening term" + } + }, + { + "feature_id": "social_listening_topics", + "included": 1, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "1 social listening topic" + } + } + ], + "created_at": 1777460152188, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } +} as ApiPlanV1; diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.freeTrial.test.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.freeTrial.test.ts new file mode 100644 index 000000000..8ce6a0e4a --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.freeTrial.test.ts @@ -0,0 +1,102 @@ +import { AppEnv, type ApiPlanV1, FreeTrialDuration } from "@autumn/shared"; +import { describe, expect, test } from "bun:test"; +import { applyDiff } from "@autumn/shared/utils/planV1Utils/diff/applyDiff.js"; +import { diffPlanV1 } from "@autumn/shared/utils/planV1Utils/diff/diffPlanV1.js"; + +const makePlan = (overrides?: Partial): ApiPlanV1 => ({ + id: "test-plan", + name: "Test Plan", + description: null, + group: null, + version: 1, + add_on: false, + auto_enable: false, + price: null, + items: [ + { + feature_id: "messages", + included: 0, + unlimited: false, + reset: null, + price: null, + }, + ], + created_at: 0, + env: AppEnv.Sandbox, + archived: false, + base_variant_id: null, + config: { ignore_past_due: false }, + ...overrides, +}); + +describe("diffPlanV1 + applyDiff — free_trial branch", () => { + test("adding a trial produces a diff and apply reconstructs it", () => { + const trial = { + duration_length: 14, + duration_type: FreeTrialDuration.Day, + card_required: false, + }; + const from = makePlan({ free_trial: undefined }); + const to = makePlan({ free_trial: trial }); + + const diff = diffPlanV1({ from, to }); + expect(diff.free_trial).toEqual(trial); + + const result = applyDiff({ base: from, diff }); + expect(result.free_trial).toEqual(trial); + }); + + test("removing a trial produces a null diff and apply drops it", () => { + const trial = { + duration_length: 7, + duration_type: FreeTrialDuration.Day, + card_required: true, + }; + const from = makePlan({ free_trial: trial }); + const to = makePlan({ free_trial: undefined }); + + const diff = diffPlanV1({ from, to }); + expect(diff.free_trial).toBeNull(); + + const result = applyDiff({ base: from, diff }); + expect(result.free_trial).toBeUndefined(); + }); + + test("changing a trial duration produces a diff and apply updates it", () => { + const fromTrial = { + duration_length: 7, + duration_type: FreeTrialDuration.Day, + card_required: true, + }; + const toTrial = { + duration_length: 30, + duration_type: FreeTrialDuration.Day, + card_required: true, + }; + const from = makePlan({ free_trial: fromTrial }); + const to = makePlan({ free_trial: toTrial }); + + const diff = diffPlanV1({ from, to }); + expect(diff.free_trial).toEqual(toTrial); + + const result = applyDiff({ base: from, diff }); + expect(result.free_trial).toEqual(toTrial); + }); + + test("identical trials produce no diff and apply preserves the base", () => { + const trial = { + duration_length: 14, + duration_type: FreeTrialDuration.Day, + card_required: false, + on_end: "bill" as const, + }; + const from = makePlan({ free_trial: trial }); + const to = makePlan({ free_trial: trial }); + + const diff = diffPlanV1({ from, to }); + expect(diff.free_trial).toBeUndefined(); + + const result = applyDiff({ base: from, diff }); + expect(result.free_trial).toEqual(trial); + }); +}); diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.oneprep.fixtures.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.oneprep.fixtures.ts new file mode 100644 index 000000000..59e6c4f72 --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.oneprep.fixtures.ts @@ -0,0 +1,15 @@ +import { type ApiPlanV1 } from "@autumn/shared"; +import { findById } from "./utils/findById.js"; + +import oneprepDump from "./oneprep-plans.json" with { type: "json" }; + +const items = oneprepDump.items as ApiPlanV1[]; + +export const proBase = findById(items, "pro_1m"); +export const proVariants: ApiPlanV1[] = [ + findById(items, "pro_1w"), + findById(items, "pro_3m"), + findById(items, "pro_6m"), + findById(items, "pro_12m"), + findById(items, "pro_june_2026"), +]; diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.oneprep.test.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.oneprep.test.ts new file mode 100644 index 000000000..5970ddf9c --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.oneprep.test.ts @@ -0,0 +1,20 @@ +import { type ApiPlanV1 } from "@autumn/shared"; +import { describe, expect, test } from "bun:test"; +import { diffPlanV1 } from "@autumn/shared/utils/planV1Utils/diff/diffPlanV1.js"; +import { applyDiff } from "@autumn/shared/utils/planV1Utils/diff/applyDiff.js"; +import { proBase, proVariants } from "./diffPlanV1.oneprep.fixtures.js"; +import { normalizePlan } from "./utils/normalizePlan.js"; + +const groups = [{ name: "pro", base: proBase, variants: proVariants }]; + +for (const { name, base, variants } of groups) { + describe(`oneprep ${name} group — diff/apply round-trip`, () => { + for (const variant of variants) { + test(`${variant.id} reconstructs from ${base.id} + diff`, () => { + const diff = diffPlanV1({ from: base, to: variant }); + const reconstructed = applyDiff({ base, diff }); + expect(normalizePlan(reconstructed)).toEqual(normalizePlan(variant)); + }); + } + }); +} diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.revisiondojo.fixtures.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.revisiondojo.fixtures.ts new file mode 100644 index 000000000..df4934b38 --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.revisiondojo.fixtures.ts @@ -0,0 +1,70 @@ +import { type ApiPlanV1 } from "@autumn/shared"; +import { findById } from "./utils/findById.js"; + +import revisiondojoDump from "./revisiondojo-plans.json" with { type: "json" }; + +const items = revisiondojoDump.items as ApiPlanV1[]; + +// Group 1 — Pro (base = pro_1m) +export const proBase = findById(items, "pro_1m"); +export const proVariants: ApiPlanV1[] = [ + findById(items, "pro_free_grant"), + findById(items, "pro_1m_new"), + findById(items, "pro_1_month_mobile"), + findById(items, "pro_1w"), + findById(items, "pro_2m"), + findById(items, "pro_3m"), + findById(items, "pro_3m_special"), + findById(items, "pro_4m"), + findById(items, "pro_6m"), + findById(items, "pro_6m_oneoff"), + findById(items, "pro_12m"), + findById(items, "pro_15m"), + findById(items, "pro_18m"), + findById(items, "pro_24m"), + findById(items, "pro_m26"), + findById(items, "pro_2m_m26"), + findById(items, "pro_n26"), + findById(items, "pro_8m_n26"), + findById(items, "pro_m27"), + findById(items, "pro_12m_oneoff"), + findById(items, "pro_14m_m27"), + findById(items, "pro_18m_oneoff"), + findById(items, "pro_n27"), + findById(items, "pro_20m_n27"), + findById(items, "pro_24m_oneoff"), + findById(items, "pro_26m_m28"), + findById(items, "pro_m28"), +]; + +// Group 2 — Plus (base = plus_1m) +export const plusBase = findById(items, "plus_1m"); +export const plusVariants: ApiPlanV1[] = [ + findById(items, "plus_free_grant"), + findById(items, "plus_1m_new"), + findById(items, "plus_1w"), + findById(items, "plus_2m"), + findById(items, "plus_3m"), + findById(items, "plus_4m"), + findById(items, "plus_6m"), + findById(items, "plus_12m"), + findById(items, "plus_15m"), + findById(items, "plus_18m"), + findById(items, "plus_24m"), + findById(items, "plus_2m_m26"), + findById(items, "plus_6m_oneoff"), + findById(items, "plus_8m_n26"), + findById(items, "plus_12m_oneoff"), + findById(items, "plus_14m_m27"), + findById(items, "plus_18m_oneoff"), + findById(items, "plus_24m_oneoff"), + findById(items, "plus_26m_m28"), + findById(items, "plus_20m_n27"), +]; + +// Group 3 — Teacher Pro (base = pro_teacher_1m) +export const teacherProBase = findById(items, "pro_teacher_1m"); +export const teacherProVariants: ApiPlanV1[] = [ + findById(items, "pro_teacher"), + findById(items, "pro_teacher_24m"), +]; diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.revisiondojo.test.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.revisiondojo.test.ts new file mode 100644 index 000000000..406648880 --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.revisiondojo.test.ts @@ -0,0 +1,32 @@ +import { type ApiPlanV1 } from "@autumn/shared"; +import { describe, expect, test } from "bun:test"; +import { diffPlanV1 } from "@autumn/shared/utils/planV1Utils/diff/diffPlanV1.js"; +import { applyDiff } from "@autumn/shared/utils/planV1Utils/diff/applyDiff.js"; +import { + proBase, + proVariants, + plusBase, + plusVariants, + teacherProBase, + teacherProVariants, +} from "./diffPlanV1.revisiondojo.fixtures.js"; +import { normalizePlan } from "./utils/normalizePlan.js"; + +// --- test matrix --- +const groups = [ + { name: "pro", base: proBase, variants: proVariants }, + { name: "plus", base: plusBase, variants: plusVariants }, + { name: "teacher_pro", base: teacherProBase, variants: teacherProVariants }, +]; + +for (const { name, base, variants } of groups) { + describe(`revisiondojo ${name} group — diff/apply round-trip`, () => { + for (const variant of variants) { + test(`${variant.id} reconstructs from ${base.id} + diff`, () => { + const diff = diffPlanV1({ from: base, to: variant }); + const reconstructed = applyDiff({ base, diff }); + expect(normalizePlan(reconstructed)).toEqual(normalizePlan(variant)); + }); + } + }); +} diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.runable.fixtures.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.runable.fixtures.ts new file mode 100644 index 000000000..d0132d05a --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.runable.fixtures.ts @@ -0,0 +1,64 @@ +import { type ApiPlanV1 } from "@autumn/shared"; +import { findById } from "./utils/findById.js"; + +import runableDump from "./runable-plans.json" with { type: "json" }; + +const items = runableDump.items as ApiPlanV1[]; + +// Group 1 — Credit packs (base = runable_pro_25_monthly) +export const creditPackBase = findById(items, "runable_pro_25_monthly"); +export const creditPackVariants: ApiPlanV1[] = [ + findById(items, "runable_pro_50_monthly"), + findById(items, "runable_pro_75_monthly"), + findById(items, "runable_pro_100_monthly"), + findById(items, "runable_pro_200_monthly"), + findById(items, "runable_pro_300_monthly"), + findById(items, "runable_pro_400_monthly"), + findById(items, "runable_pro_500_monthly"), + findById(items, "runable_pro_750_monthly"), + findById(items, "runable_pro_1000_monthly"), + findById(items, "runable_pro_1500_monthly"), + findById(items, "runable_pro_2000_monthly"), + findById(items, "runable_pro_5000_monthly"), + findById(items, "runable_pro_10000_monthly"), + findById(items, "runable_pro_20000_monthly"), + findById(items, "runable_pro_25_yearly"), + findById(items, "runable_pro_50_yearly"), + findById(items, "runable_pro_75_yearly"), + findById(items, "runable_pro_100_yearly"), + findById(items, "runable_pro_200_yearly"), + findById(items, "runable_pro_300_yearly"), + findById(items, "runable_pro_400_yearly"), + findById(items, "runable_pro_500_yearly"), + findById(items, "runable_pro_750_yearly"), + findById(items, "runable_pro_1000_yearly"), + findById(items, "runable_pro_1500_yearly"), + findById(items, "runable_pro_2000_yearly"), + findById(items, "runable_pro_5000_yearly"), + findById(items, "runable_pro_10000_yearly"), + findById(items, "runable_pro_20000_yearly"), +]; + +// Group 2 — Plus tier +export const plusBase = findById(items, "runable_plus_monthly"); +export const plusVariants: ApiPlanV1[] = [findById(items, "runable_plus_yearly")]; + +// Group 3 — Pro tier +export const proBase = findById(items, "runable_pro_monthly"); +export const proVariants: ApiPlanV1[] = [findById(items, "runable_pro_yearly")]; + +// Group 4 — Unlimited tier +export const unlimitedBase = findById(items, "runable_unlimited_monthly"); +export const unlimitedVariants: ApiPlanV1[] = [findById(items, "runable_unlimited_yearly")]; + +// Group 5 — Free/starter +export const freeStarterBase = findById(items, "runable_go"); +export const freeStarterVariants: ApiPlanV1[] = [ + findById(items, "runable_basic"), + findById(items, "runable_starter_monthly"), + findById(items, "runable_starter_yearly"), +]; + +// Group 6 — Max tier +export const maxBase = findById(items, "runable_max_monthly"); +export const maxVariants: ApiPlanV1[] = [findById(items, "runable_max_yearly")]; diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.runable.test.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.runable.test.ts new file mode 100644 index 000000000..b5f7db55b --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.runable.test.ts @@ -0,0 +1,41 @@ +import { type ApiPlanV1 } from "@autumn/shared"; +import { describe, expect, test } from "bun:test"; +import { diffPlanV1 } from "@autumn/shared/utils/planV1Utils/diff/diffPlanV1.js"; +import { applyDiff } from "@autumn/shared/utils/planV1Utils/diff/applyDiff.js"; +import { + creditPackBase, + creditPackVariants, + plusBase, + plusVariants, + proBase, + proVariants, + unlimitedBase, + unlimitedVariants, + freeStarterBase, + freeStarterVariants, + maxBase, + maxVariants, +} from "./diffPlanV1.runable.fixtures.js"; +import { normalizePlan } from "./utils/normalizePlan.js"; + +// --- test matrix --- +const groups = [ + { name: "credit_pack", base: creditPackBase, variants: creditPackVariants }, + { name: "plus", base: plusBase, variants: plusVariants }, + { name: "pro", base: proBase, variants: proVariants }, + { name: "unlimited", base: unlimitedBase, variants: unlimitedVariants }, + { name: "free_starter", base: freeStarterBase, variants: freeStarterVariants }, + { name: "max", base: maxBase, variants: maxVariants }, +]; + +for (const { name, base, variants } of groups) { + describe(`runable ${name} group — diff/apply round-trip`, () => { + for (const variant of variants) { + test(`${variant.id} reconstructs from ${base.id} + diff`, () => { + const diff = diffPlanV1({ from: base, to: variant }); + const reconstructed = applyDiff({ base, diff }); + expect(normalizePlan(reconstructed)).toEqual(normalizePlan(variant)); + }); + } + }); +} diff --git a/server/tests/integration/crud/plans/diffing/diffPlanV1.test.ts b/server/tests/integration/crud/plans/diffing/diffPlanV1.test.ts new file mode 100644 index 000000000..5f1f59728 --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/diffPlanV1.test.ts @@ -0,0 +1,53 @@ +import { type ApiPlanV1, BillingInterval } from "@autumn/shared"; +import { describe, expect, test } from "bun:test"; +import { diffPlanV1 } from "@autumn/shared/utils/planV1Utils/diff/diffPlanV1.js"; +import { popflyStart, popflyStartAnnual } from "./diffPlanV1.fixtures.js"; + +describe("diffPlanV1 — popfly start vs start_annual", () => { + test("start → start_annual: only price diffs (annual price)", () => { + const diff = diffPlanV1({ from: popflyStart, to: popflyStartAnnual }); + + expect(diff.price).toEqual({ amount: 5988, interval: BillingInterval.Year }); + expect(diff.add_items).toBeUndefined(); + expect(diff.remove_items).toBeUndefined(); + expect(diff.free_trial).toBeUndefined(); + }); + + test("start → start: empty diff (no fields set)", () => { + const diff = diffPlanV1({ from: popflyStart, to: popflyStart }); + + expect(diff).toEqual({}); + }); + + test("start_annual → start (reverse): price is the monthly price", () => { + const diff = diffPlanV1({ from: popflyStartAnnual, to: popflyStart }); + + expect(diff.price).toEqual({ amount: 499, interval: BillingInterval.Month }); + expect(diff.add_items).toBeUndefined(); + expect(diff.remove_items).toBeUndefined(); + expect(diff.free_trial).toBeUndefined(); + }); + + test("modify-in-place: same feature_id with different included → remove + add", () => { + const modified: ApiPlanV1 = { + ...popflyStart, + items: popflyStart.items.map((item) => + item.feature_id === "social_listening_terms" + ? { ...item, included: 999 } + : item, + ), + }; + + const diff = diffPlanV1({ from: popflyStart, to: modified }); + + expect(diff.remove_items).toEqual([ + { feature_id: "social_listening_terms" }, + ]); + expect(diff.add_items).toHaveLength(1); + expect(diff.add_items?.[0]).toMatchObject({ + feature_id: "social_listening_terms", + included: 999, + }); + expect(diff.price).toBeUndefined(); + }); +}); diff --git a/server/tests/integration/crud/plans/diffing/firecrawl-plans.json b/server/tests/integration/crud/plans/diffing/firecrawl-plans.json new file mode 100644 index 000000000..4961f679d --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/firecrawl-plans.json @@ -0,0 +1,2463 @@ +{ + "items": [ + { + "id": "concurrent_browser", + "name": "Concurrent Browser", + "description": null, + "group": null, + "version": 2, + "add_on": true, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 0, + "unlimited": false, + "reset": null, + "price": { + "amount": 96, + "interval": "year", + "billing_units": 1, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$96 per concurrency" + } + } + ], + "created_at": 1777978939457, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "credit_pack_1k", + "name": "Credit Pack 1k", + "description": null, + "group": null, + "version": 1, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 9, + "interval": "month", + "display": { + "primary_text": "$9", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,000 credits" + } + } + ], + "created_at": 1773854694042, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "enterprise", + "name": "Enterprise", + "description": null, + "group": null, + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 38976, + "interval": "year", + "display": { + "primary_text": "$38,976", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 2137, + "interval": "one_off", + "billing_units": 2450000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$2,137 per 2,450,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 200, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "200 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 7000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "7,000,000 credits" + }, + "rollover": { + "max": null, + "max_percentage": null, + "expiry_duration_type": "month", + "expiry_duration_length": 2 + } + } + ], + "created_at": 1775778953524, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": true + } + }, + { + "id": "enterprise_expansion", + "name": "Enterprise Metered Expansion", + "description": null, + "group": null, + "version": 2, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 5520, + "interval": "year", + "display": { + "primary_text": "$5,520", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 12000000, + "unlimited": false, + "reset": { + "interval": "year" + }, + "price": null, + "display": { + "primary_text": "12,000,000 credits" + } + } + ], + "created_at": 1778251701842, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "enterprise_metered", + "name": "Enterprise Metered", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 27830, + "interval": "year", + "display": { + "primary_text": "$27,830", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 5000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5,000,000 credits" + }, + "rollover": { + "max": 0, + "max_percentage": null, + "expiry_duration_type": "month", + "expiry_duration_length": 2 + } + } + ], + "created_at": 1773854702144, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": true + } + }, + { + "id": "extract_explorer_monthly", + "name": "Extract Explorer", + "description": null, + "group": "extract", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 399, + "interval": "month", + "display": { + "primary_text": "$399", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 466667, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "466,667 credits" + } + } + ], + "created_at": 1773854704525, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "extract_explorer_yearly", + "name": "Extract Explorer (Yearly)", + "description": null, + "group": "extract", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 4308, + "interval": "year", + "display": { + "primary_text": "$4,308", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 5600000, + "unlimited": false, + "reset": { + "interval": "year" + }, + "price": null, + "display": { + "primary_text": "5,600,000 credits" + } + } + ], + "created_at": 1773854706910, + "env": "live", + "archived": false, + "base_variant_id": "extract_explorer_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "extract_pro", + "name": "Extract Pro", + "description": null, + "group": "extract", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 899, + "interval": "month", + "display": { + "primary_text": "$899", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 1333333, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,333,333 credits" + } + } + ], + "created_at": 1773854709451, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "extract_pro_yearly", + "name": "Extract Pro (Yearly)", + "description": null, + "group": "extract", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 8628, + "interval": "year", + "display": { + "primary_text": "$8,628", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 8000000, + "unlimited": false, + "reset": { + "interval": "year" + }, + "price": null, + "display": { + "primary_text": "8,000,000 credits" + } + } + ], + "created_at": 1773854833713, + "env": "live", + "archived": false, + "base_variant_id": "extract_pro", + "config": { + "ignore_past_due": false + } + }, + { + "id": "extract_starter", + "name": "Extract Starter", + "description": null, + "group": "extract", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 99, + "interval": "month", + "display": { + "primary_text": "$99", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 100000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "100,000 credits" + } + } + ], + "created_at": 1773854837037, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "extract_starter_yearly", + "name": "Extract Starter (Yearly)", + "description": null, + "group": "extract", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 1068, + "interval": "year", + "display": { + "primary_text": "$1,068", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 1200000, + "unlimited": false, + "reset": { + "interval": "year" + }, + "price": null, + "display": { + "primary_text": "1,200,000 credits" + } + } + ], + "created_at": 1773854839364, + "env": "live", + "archived": false, + "base_variant_id": "extract_starter", + "config": { + "ignore_past_due": false + } + }, + { + "id": "free", + "name": "Free", + "description": null, + "group": null, + "version": 2, + "add_on": false, + "auto_enable": true, + "price": null, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 2, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "2 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,000 credits" + } + } + ], + "created_at": 1778072155064, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "growth", + "name": "Growth", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 399, + "interval": "month", + "display": { + "primary_text": "$399", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 217, + "interval": "one_off", + "billing_units": 150000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$217 per 150,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 100, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "100 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 500000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "500,000 credits" + } + } + ], + "created_at": 1773317212882, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "growth_monthly_500k", + "name": "Growth (500k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 399, + "interval": "month", + "display": { + "primary_text": "$399", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 100, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "100 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 500000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "500,000 credits" + } + } + ], + "created_at": 1778644941752, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "growth_monthly_650k", + "name": "Growth (650k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 616, + "interval": "month", + "display": { + "primary_text": "$616", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 100, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "100 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 650000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "650,000 credits" + } + } + ], + "created_at": 1778644945443, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "growth_monthly_800k", + "name": "Growth (800k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 833, + "interval": "month", + "display": { + "primary_text": "$833", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 100, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "100 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 800000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "800,000 credits" + } + } + ], + "created_at": 1778644948949, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "growth_yearly", + "name": "Growth (Yearly)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 3990, + "interval": "year", + "display": { + "primary_text": "$3,990", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 177, + "interval": "one_off", + "billing_units": 175000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$177 per 175,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 100, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "100 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 500000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "500,000 credits" + } + } + ], + "created_at": 1773317215521, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "growth_yearly_500k", + "name": "Growth Yearly (500k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 3990, + "interval": "year", + "display": { + "primary_text": "$3,990", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 100, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "100 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 500000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "500,000 credits" + } + } + ], + "created_at": 1778645211284, + "env": "live", + "archived": false, + "base_variant_id": "growth_monthly_500k", + "config": { + "ignore_past_due": false + } + }, + { + "id": "growth_yearly_650k", + "name": "Growth Yearly (650k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 6160, + "interval": "year", + "display": { + "primary_text": "$6,160", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 100, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "100 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 650000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "650,000 credits" + } + } + ], + "created_at": 1778645212029, + "env": "live", + "archived": false, + "base_variant_id": "growth_monthly_650k", + "config": { + "ignore_past_due": false + } + }, + { + "id": "hobby", + "name": "Hobby", + "description": null, + "group": null, + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 19, + "interval": "month", + "display": { + "primary_text": "$19", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 9, + "interval": "one_off", + "billing_units": 1500, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$9 per 1,500 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 5, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 5000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5,000 credits" + } + } + ], + "created_at": 1778072203300, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "hobby_monthly_5k", + "name": "Hobby (5k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 19, + "interval": "month", + "display": { + "primary_text": "$19", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 5, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 5000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5,000 credits" + } + } + ], + "created_at": 1778643662039, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "hobby_monthly_6_5k", + "name": "Hobby (6.5k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 28, + "interval": "month", + "display": { + "primary_text": "$28", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 5, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 6500, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "6,500 credits" + } + } + ], + "created_at": 1778644675386, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "hobby_monthly_8k", + "name": "Hobby (8k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 37, + "interval": "month", + "display": { + "primary_text": "$37", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 5, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 8000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "8,000 credits" + } + } + ], + "created_at": 1778644679445, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "hobby_yearly", + "name": "Hobby (Yearly)", + "description": null, + "group": null, + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 190, + "interval": "year", + "display": { + "primary_text": "$190", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 9, + "interval": "one_off", + "billing_units": 1500, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$9 per 1,500 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 5, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 5000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5,000 credits" + } + } + ], + "created_at": 1778072255911, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "hobby_yearly_5k", + "name": "Hobby Yearly (5k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 190, + "interval": "year", + "display": { + "primary_text": "$190", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 5, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 5000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5,000 credits" + } + } + ], + "created_at": 1778645197783, + "env": "live", + "archived": false, + "base_variant_id": "hobby_monthly_5k", + "config": { + "ignore_past_due": false + } + }, + { + "id": "hobby_yearly_6_5k", + "name": "Hobby Yearly (6.5k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 290, + "interval": "year", + "display": { + "primary_text": "$290", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 5, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 6500, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "6,500 credits" + } + } + ], + "created_at": 1778645199336, + "env": "live", + "archived": false, + "base_variant_id": "hobby_monthly_6_5k", + "config": { + "ignore_past_due": false + } + }, + { + "id": "hobby_yearly_8k", + "name": "Hobby Yearly (8k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 390, + "interval": "year", + "display": { + "primary_text": "$390", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 5, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 8000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "8,000 credits" + } + } + ], + "created_at": 1778645201202, + "env": "live", + "archived": false, + "base_variant_id": "hobby_monthly_8k", + "config": { + "ignore_past_due": false + } + }, + { + "id": "legacy_scale_enterprise", + "name": "Legacy Scale/Enterprise", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 1000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,000,000 credits" + } + } + ], + "created_at": 1774028248100, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "legacy_standard", + "name": "Legacy Standard", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 500000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "500,000 credits" + } + } + ], + "created_at": 1773855640751, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "legacy_starter", + "name": "Legacy Starter", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 50000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "50,000 credits" + } + } + ], + "created_at": 1773855644561, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "scale_monthly", + "name": "Scale (Monthly)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 749, + "interval": "month", + "display": { + "primary_text": "$749", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 397, + "interval": "one_off", + "billing_units": 300000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$397 per 300,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 100, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "100 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 1000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,000,000 credits" + } + } + ], + "created_at": 1773317235829, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "scale_tier_1", + "name": "Scale Tier 1", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 7190, + "interval": "year", + "display": { + "primary_text": "$7,190", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 407, + "interval": "one_off", + "billing_units": 350000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$407 per 350,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 1000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,000,000 credits" + }, + "rollover": { + "max": null, + "max_percentage": null, + "expiry_duration_type": "month", + "expiry_duration_length": 1 + } + } + ], + "created_at": 1773317241022, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "scale_tier_1_quarterly", + "name": "Scale Tier 1 (Quarterly)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 2022, + "interval": "quarter", + "display": { + "primary_text": "$2,022", + "secondary_text": "per quarter" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 407, + "interval": "one_off", + "billing_units": 350000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$407 per 350,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 1000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,000,000 credits" + } + } + ], + "created_at": 1773855648989, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "scale_tier_2", + "name": "Scale Tier 2", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 13430, + "interval": "year", + "display": { + "primary_text": "$13,430", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 737, + "interval": "one_off", + "billing_units": 700000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$737 per 700,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 2000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "2,000,000 credits" + }, + "rollover": { + "max": null, + "max_percentage": null, + "expiry_duration_type": "month", + "expiry_duration_length": 1 + } + } + ], + "created_at": 1773317249023, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": true + } + }, + { + "id": "scale_tier_2_quarterly", + "name": "Scale Tier 2 (Quarterly)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 3777, + "interval": "quarter", + "display": { + "primary_text": "$3,777", + "secondary_text": "per quarter" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 737, + "interval": "one_off", + "billing_units": 700000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$737 per 700,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 2000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "2,000,000 credits" + } + } + ], + "created_at": 1773855652530, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": true + } + }, + { + "id": "scale_tier_3", + "name": "Scale Tier 3", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 18710, + "interval": "year", + "display": { + "primary_text": "$18,710", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 977, + "interval": "one_off", + "billing_units": 1000000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$977 per 1,000,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 3000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "3,000,000 credits" + }, + "rollover": { + "max": null, + "max_percentage": null, + "expiry_duration_type": "month", + "expiry_duration_length": 1 + } + } + ], + "created_at": 1773317256818, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": true + } + }, + { + "id": "scale_tier_3_quarterly", + "name": "Scale Tier 3 (Quarterly)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 5262, + "interval": "quarter", + "display": { + "primary_text": "$5,262", + "secondary_text": "per quarter" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 977, + "interval": "one_off", + "billing_units": 1000000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$977 per 1,000,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 3000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "3,000,000 credits" + } + } + ], + "created_at": 1773855656092, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": true + } + }, + { + "id": "scale_tier_4", + "name": "Scale Tier 4", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 23030, + "interval": "year", + "display": { + "primary_text": "$23,030", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 1257, + "interval": "one_off", + "billing_units": 1400000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$1,257 per 1,400,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 4000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "4,000,000 credits" + }, + "rollover": { + "max": null, + "max_percentage": null, + "expiry_duration_type": "month", + "expiry_duration_length": 1 + } + } + ], + "created_at": 1773317267120, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": true + } + }, + { + "id": "scale_tier_4_quarterly", + "name": "Scale Tier 4 (Quarterly)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 6477, + "interval": "quarter", + "display": { + "primary_text": "$6,477", + "secondary_text": "per quarter" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 1257, + "interval": "one_off", + "billing_units": 1400000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$1,257 per 1,400,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 150, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "150 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 4000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "4,000,000 credits" + } + } + ], + "created_at": 1773855659690, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": true + } + }, + { + "id": "standard", + "name": "Standard", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 99, + "interval": "month", + "display": { + "primary_text": "$99", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 57, + "interval": "one_off", + "billing_units": 30000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$57 per 30,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 100000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "100,000 credits" + } + } + ], + "created_at": 1773317274820, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "standard_monthly_100k", + "name": "Standard (100k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 99, + "interval": "month", + "display": { + "primary_text": "$99", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 100000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "100,000 credits" + } + } + ], + "created_at": 1778644930456, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "standard_monthly_130k", + "name": "Standard (130k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 156, + "interval": "month", + "display": { + "primary_text": "$156", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 130000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "130,000 credits" + } + } + ], + "created_at": 1778644934131, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "standard_monthly_160k", + "name": "Standard (160k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 213, + "interval": "month", + "display": { + "primary_text": "$213", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 160000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "160,000 credits" + } + } + ], + "created_at": 1778644938206, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "standard_yearly", + "name": "Standard (Yearly)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 990, + "interval": "year", + "display": { + "primary_text": "$990", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CREDITS", + "included": 0, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": { + "amount": 47, + "interval": "one_off", + "billing_units": 35000, + "billing_method": "prepaid", + "max_purchase": null + }, + "display": { + "primary_text": "$47 per 35,000 credits" + } + }, + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 100000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "100,000 credits" + } + } + ], + "created_at": 1773317277420, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "standard_yearly_100k", + "name": "Standard Yearly (100k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 990, + "interval": "year", + "display": { + "primary_text": "$990", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 100000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "100,000 credits" + } + } + ], + "created_at": 1778645202781, + "env": "live", + "archived": false, + "base_variant_id": "standard_monthly_100k", + "config": { + "ignore_past_due": false + } + }, + { + "id": "standard_yearly_130k", + "name": "Standard Yearly (130k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 1590, + "interval": "year", + "display": { + "primary_text": "$1,590", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 130000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "130,000 credits" + } + } + ], + "created_at": 1778645209666, + "env": "live", + "archived": false, + "base_variant_id": "standard_monthly_130k", + "config": { + "ignore_past_due": false + } + }, + { + "id": "standard_yearly_160k", + "name": "Standard Yearly (160k credits/month)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 2190, + "interval": "year", + "display": { + "primary_text": "$2,190", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "CONCURRENCY", + "included": 50, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "50 concurrencies" + } + }, + { + "feature_id": "CREDITS", + "included": 160000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "160,000 credits" + } + } + ], + "created_at": 1778645210511, + "env": "live", + "archived": false, + "base_variant_id": "standard_monthly_160k", + "config": { + "ignore_past_due": false + } + } + ] +} diff --git a/server/tests/integration/crud/plans/diffing/oneprep-plans.json b/server/tests/integration/crud/plans/diffing/oneprep-plans.json new file mode 100644 index 000000000..dbeda6dda --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/oneprep-plans.json @@ -0,0 +1,771 @@ +{ + "items": [ + { + "id": "free", + "name": "Free", + "description": null, + "group": null, + "version": 8, + "add_on": false, + "auto_enable": true, + "price": null, + "items": [ + { + "feature_id": "orbs_credit", + "included": 10, + "unlimited": false, + "reset": { + "interval": "week" + }, + "price": null, + "display": { + "primary_text": "10 orbs credits" + }, + "rollover": { + "max": 20, + "max_percentage": null, + "expiry_duration_type": "forever", + "expiry_duration_length": 1 + } + }, + { + "feature_id": "orbs_credit", + "included": 10, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "10 orbs credits" + } + }, + { + "feature_id": "read_lesson", + "included": 3, + "unlimited": false, + "reset": { + "interval": "week" + }, + "price": null, + "display": { + "primary_text": "3 read lessons" + } + }, + { + "feature_id": "read_note", + "included": 3, + "unlimited": false, + "reset": { + "interval": "week" + }, + "price": null, + "display": { + "primary_text": "3 read notes" + } + }, + { + "feature_id": "remix_question_new", + "included": 1, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "1 remix question" + } + } + ], + "created_at": 1778484988714, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus", + "name": "Plus", + "description": null, + "group": null, + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 29, + "interval": "month", + "display": { + "primary_text": "$29", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "all_diagnostic_tests", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All diagnostic tests" + } + }, + { + "feature_id": "all_predicted_papers", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All predicted papers" + } + }, + { + "feature_id": "orbs_credit", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited orbs credits" + } + }, + { + "feature_id": "premium_questions", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Questions" + } + } + ], + "created_at": 1776913987190, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_12m", + "name": "Pro (12 months)", + "description": null, + "group": null, + "version": 4, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 216, + "interval": "month", + "interval_count": 12, + "display": { + "primary_text": "$216", + "secondary_text": "per 12 months" + } + }, + "items": [ + { + "feature_id": "all_diagnostic_tests", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All diagnostic tests" + } + }, + { + "feature_id": "all_predicted_papers", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All predicted papers" + } + }, + { + "feature_id": "error_analytics", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Error analytics" + } + }, + { + "feature_id": "orbs_credit", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited orbs credits" + } + }, + { + "feature_id": "premium_cheatsheets", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Cheatsheets" + } + }, + { + "feature_id": "premium_questions", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Questions" + } + }, + { + "feature_id": "remix_question_new", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited remix questions" + } + } + ], + "created_at": 1778656068706, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_1m", + "name": "Pro (1 month)", + "description": null, + "group": null, + "version": 5, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 43.5, + "interval": "month", + "display": { + "primary_text": "$43.5", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "all_diagnostic_tests", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All diagnostic tests" + } + }, + { + "feature_id": "all_predicted_papers", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All predicted papers" + } + }, + { + "feature_id": "error_analytics", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Error analytics" + } + }, + { + "feature_id": "orbs_credit", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited orbs credits" + } + }, + { + "feature_id": "premium_cheatsheets", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Cheatsheets" + } + }, + { + "feature_id": "premium_questions", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Questions" + } + }, + { + "feature_id": "remix_question_new", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited remix questions" + } + } + ], + "created_at": 1778656038341, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_1w", + "name": "Pro (1 week)", + "description": null, + "group": null, + "version": 4, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 28.5, + "interval": "week", + "display": { + "primary_text": "$28.5", + "secondary_text": "per week" + } + }, + "items": [ + { + "feature_id": "all_diagnostic_tests", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All diagnostic tests" + } + }, + { + "feature_id": "all_predicted_papers", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All predicted papers" + } + }, + { + "feature_id": "error_analytics", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Error analytics" + } + }, + { + "feature_id": "orbs_credit", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited orbs credits" + } + }, + { + "feature_id": "premium_cheatsheets", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Cheatsheets" + } + }, + { + "feature_id": "premium_questions", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Questions" + } + }, + { + "feature_id": "remix_question_new", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited remix questions" + } + } + ], + "created_at": 1778656024331, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_3m", + "name": "Pro (3 months)", + "description": null, + "group": null, + "version": 4, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 103.5, + "interval": "month", + "interval_count": 3, + "display": { + "primary_text": "$103.5", + "secondary_text": "per 3 months" + } + }, + "items": [ + { + "feature_id": "all_diagnostic_tests", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All diagnostic tests" + } + }, + { + "feature_id": "all_predicted_papers", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All predicted papers" + } + }, + { + "feature_id": "error_analytics", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Error analytics" + } + }, + { + "feature_id": "orbs_credit", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited orbs credits" + } + }, + { + "feature_id": "premium_cheatsheets", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Cheatsheets" + } + }, + { + "feature_id": "premium_questions", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Questions" + } + }, + { + "feature_id": "remix_question_new", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited remix questions" + } + } + ], + "created_at": 1778655992720, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_6m", + "name": "Pro (6 months)", + "description": null, + "group": null, + "version": 4, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 144, + "interval": "month", + "interval_count": 6, + "display": { + "primary_text": "$144", + "secondary_text": "per 6 months" + } + }, + "items": [ + { + "feature_id": "all_diagnostic_tests", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All diagnostic tests" + } + }, + { + "feature_id": "all_predicted_papers", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All predicted papers" + } + }, + { + "feature_id": "error_analytics", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Error analytics" + } + }, + { + "feature_id": "orbs_credit", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited orbs credits" + } + }, + { + "feature_id": "premium_cheatsheets", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Cheatsheets" + } + }, + { + "feature_id": "premium_questions", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Questions" + } + }, + { + "feature_id": "remix_question_new", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited remix questions" + } + } + ], + "created_at": 1778655965941, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_june_2026", + "name": "Pro (June 2026)", + "description": null, + "group": null, + "version": 3, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 35.99, + "interval": "one_off", + "display": { + "primary_text": "$35.99" + } + }, + "items": [ + { + "feature_id": "all_diagnostic_tests", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All diagnostic tests" + } + }, + { + "feature_id": "all_predicted_papers", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "All predicted papers" + } + }, + { + "feature_id": "error_analytics", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Error analytics" + } + }, + { + "feature_id": "orbs_credit", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited orbs credits" + } + }, + { + "feature_id": "premium_cheatsheets", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Cheatsheets" + } + }, + { + "feature_id": "premium_questions", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Premium Questions" + } + }, + { + "feature_id": "question_remix", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited question remixes" + } + }, + { + "feature_id": "remix_question_new", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited remix questions" + } + } + ], + "created_at": 1779075195611, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + } + ] +} \ No newline at end of file diff --git a/server/tests/integration/crud/plans/diffing/revisiondojo-plans.json b/server/tests/integration/crud/plans/diffing/revisiondojo-plans.json new file mode 100644 index 000000000..09d1149ba --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/revisiondojo-plans.json @@ -0,0 +1,5642 @@ +{ + "items": [ + { + "id": "free", + "name": "Free", + "description": null, + "group": null, + "version": 6, + "add_on": false, + "auto_enable": true, + "price": null, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "1,000 AI checker words" + } + }, + { + "feature_id": "create_lesson", + "included": 3, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "3 create lessons" + } + }, + { + "feature_id": "create_test", + "included": 3, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "3 create tests" + } + }, + { + "feature_id": "energy", + "included": 10, + "unlimited": false, + "reset": { + "interval": "week" + }, + "price": null, + "display": { + "primary_text": "10 energies" + } + }, + { + "feature_id": "energy", + "included": 10, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "10 energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 5, + "unlimited": false, + "reset": { + "interval": "week" + }, + "price": null, + "display": { + "primary_text": "5 read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 3, + "unlimited": false, + "reset": { + "interval": "week" + }, + "price": null, + "display": { + "primary_text": "3 read lessons" + } + }, + { + "feature_id": "read_note", + "included": 3, + "unlimited": false, + "reset": { + "interval": "week" + }, + "price": null, + "display": { + "primary_text": "3 read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 10, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "10 read walkthroughs" + } + }, + { + "feature_id": "teach_jojo_session", + "included": 3, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "3 teach jojo sessions" + } + }, + { + "feature_id": "teacher_coursework_grader", + "included": 3, + "unlimited": false, + "reset": { + "interval": "week" + }, + "price": null, + "display": { + "primary_text": "3 teacher coursework graders" + } + } + ], + "created_at": 1774838343324, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_12m", + "name": "Plus (12 months)", + "description": null, + "group": "personal_sub", + "version": 13, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 732, + "interval": "year", + "display": { + "primary_text": "$732", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770403301468, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_12m_oneoff", + "name": "Plus", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 696, + "interval": "one_off", + "interval_count": 3, + "display": { + "primary_text": "$696" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770740067183, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_14m_m27", + "name": "Plus", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 756, + "interval": "one_off", + "interval_count": 3, + "display": { + "primary_text": "$756" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770740067183, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_15m", + "name": "Plus (15 months)", + "description": null, + "group": "personal_sub", + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 180, + "interval": "month", + "interval_count": 15, + "display": { + "primary_text": "$180", + "secondary_text": "per 15 months" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770402863094, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_18m", + "name": "Plus (18 months)", + "description": null, + "group": "personal_sub", + "version": 6, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 810, + "interval": "month", + "interval_count": 18, + "display": { + "primary_text": "$810", + "secondary_text": "per 18 months" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770389954591, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_18m_oneoff", + "name": "Plus", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 756, + "interval": "one_off", + "interval_count": 3, + "display": { + "primary_text": "$756" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770740067183, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_1m", + "name": "Plus (1 month)", + "description": null, + "group": "personal_sub", + "version": 9, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 129, + "interval": "month", + "display": { + "primary_text": "$129", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1772764131640, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_1m_new", + "name": "Plus (1 month)", + "description": null, + "group": "personal_sub", + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 230, + "interval": "month", + "display": { + "primary_text": "$230", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1776656856472, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_1w", + "name": "Plus (1 week)", + "description": null, + "group": "personal_sub", + "version": 3, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 130, + "interval": "week", + "display": { + "primary_text": "$130", + "secondary_text": "per week" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1776656945134, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_20m_n27", + "name": "Plus", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 840, + "interval": "one_off", + "interval_count": 3, + "display": { + "primary_text": "$840" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770740067183, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_24m", + "name": "Plus (24 months)", + "description": null, + "group": "personal_sub", + "version": 9, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 840, + "interval": "year", + "interval_count": 2, + "display": { + "primary_text": "$840", + "secondary_text": "per 2 years" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770389963914, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_24m_oneoff", + "name": "Plus", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 816, + "interval": "one_off", + "interval_count": 3, + "display": { + "primary_text": "$816" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770740067183, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_26m_m28", + "name": "Plus", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 816, + "interval": "one_off", + "interval_count": 3, + "display": { + "primary_text": "$816" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770740067183, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_2m", + "name": "Plus (2 months)", + "description": null, + "group": "personal_sub", + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 348, + "interval": "month", + "interval_count": 2, + "display": { + "primary_text": "$348", + "secondary_text": "per 2 months" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1772262541789, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_2m_m26", + "name": "Plus", + "description": null, + "group": "personal_oneoff", + "version": 3, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 350, + "interval": "one_off", + "interval_count": 3, + "display": { + "primary_text": "$350" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1776656791638, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_3m", + "name": "Plus (3 months)", + "description": null, + "group": "personal_sub", + "version": 11, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 462, + "interval": "month", + "interval_count": 3, + "display": { + "primary_text": "$462", + "secondary_text": "per 3 months" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770740067183, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_4m", + "name": "Plus (4 months)", + "description": null, + "group": "personal_sub", + "version": 5, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 536, + "interval": "month", + "interval_count": 4, + "display": { + "primary_text": "$536", + "secondary_text": "per 4 months" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770389934153, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_6m", + "name": "Plus (6 months)", + "description": null, + "group": "personal_sub", + "version": 12, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 570, + "interval": "month", + "interval_count": 6, + "display": { + "primary_text": "$570", + "secondary_text": "per 6 months" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770351892772, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_6m_oneoff", + "name": "Plus", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 540, + "interval": "one_off", + "interval_count": 3, + "display": { + "primary_text": "$540" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770740067183, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_8m_n26", + "name": "Plus", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 656, + "interval": "one_off", + "interval_count": 3, + "display": { + "primary_text": "$656" + } + }, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770740067183, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "plus_free_grant", + "name": "Plus (Free Grant)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + } + ], + "created_at": 1770452484251, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_12m", + "name": "Pro (12 months)", + "description": null, + "group": "personal_sub", + "version": 9, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 780, + "interval": "year", + "display": { + "primary_text": "$780", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1770389949158, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_12m_oneoff", + "name": "Pro", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 798, + "interval": "one_off", + "display": { + "primary_text": "$798" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772244766802, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_14m_m27", + "name": "Pro", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 812, + "interval": "one_off", + "display": { + "primary_text": "$812" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772244766802, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_15m", + "name": "Pro (15 months)", + "description": null, + "group": "personal_sub", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 285, + "interval": "month", + "interval_count": 15, + "display": { + "primary_text": "$285", + "secondary_text": "per 15 months" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1770203599491, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_18m", + "name": "Pro (18 months)", + "description": null, + "group": "personal_sub", + "version": 5, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 882, + "interval": "month", + "interval_count": 18, + "display": { + "primary_text": "$882", + "secondary_text": "per 18 months" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1770389958694, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_18m_oneoff", + "name": "Pro", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 828, + "interval": "one_off", + "display": { + "primary_text": "$828" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772244766802, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_1_month_mobile", + "name": "Pro (1 month)", + "description": null, + "group": "personal_sub", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 149, + "interval": "month", + "display": { + "primary_text": "$149", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1774951020658, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_1m", + "name": "Pro (1 month)", + "description": null, + "group": "personal_sub", + "version": 7, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 139, + "interval": "month", + "display": { + "primary_text": "$139", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + }, + { + "feature_id": "teacher_coursework_grader", + "included": 5, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5 teacher coursework graders" + } + } + ], + "created_at": 1773193560245, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_1m_new", + "name": "Pro (1 month)", + "description": null, + "group": "personal_sub", + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 238, + "interval": "month", + "display": { + "primary_text": "$238", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + }, + { + "feature_id": "teacher_coursework_grader", + "included": 5, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5 teacher coursework graders" + } + } + ], + "created_at": 1776656842190, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_1w", + "name": "Pro (1 week)", + "description": null, + "group": "personal_sub", + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 138, + "interval": "week", + "display": { + "primary_text": "$138", + "secondary_text": "per week" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + }, + { + "feature_id": "teacher_coursework_grader", + "included": 5, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5 teacher coursework graders" + } + } + ], + "created_at": 1776656898626, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_20m_n27", + "name": "Pro", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 920, + "interval": "one_off", + "display": { + "primary_text": "$920" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772244766802, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_24m", + "name": "Pro (24 months)", + "description": null, + "group": "personal_sub", + "version": 6, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 936, + "interval": "year", + "interval_count": 2, + "display": { + "primary_text": "$936", + "secondary_text": "per 2 years" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1770389969621, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_24m_oneoff", + "name": "Pro", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 912, + "interval": "one_off", + "display": { + "primary_text": "$912" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772244766802, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_26m_m28", + "name": "Pro", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 988, + "interval": "one_off", + "display": { + "primary_text": "$988" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772244766802, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_2m", + "name": "Pro (2 months)", + "description": null, + "group": "personal_sub", + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 356, + "interval": "month", + "interval_count": 2, + "display": { + "primary_text": "$356", + "secondary_text": "per 2 months" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772262524130, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_2m_m26", + "name": "Pro", + "description": null, + "group": "personal_oneoff", + "version": 5, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 358, + "interval": "one_off", + "display": { + "primary_text": "$358" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1777451713406, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_3m", + "name": "Pro (3 months)", + "description": null, + "group": "personal_sub", + "version": 7, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 474, + "interval": "month", + "interval_count": 3, + "display": { + "primary_text": "$474", + "secondary_text": "per 3 months" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1770740063500, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_3m_special", + "name": "Pro (3 months)", + "description": null, + "group": "personal_sub", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 299, + "interval": "month", + "interval_count": 3, + "display": { + "primary_text": "$299", + "secondary_text": "per 3 months" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1770740063500, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_4m", + "name": "Pro (4 months)", + "description": null, + "group": "personal_sub", + "version": 5, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 552, + "interval": "month", + "interval_count": 4, + "display": { + "primary_text": "$552", + "secondary_text": "per 4 months" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1770389937913, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_6m", + "name": "Pro (6 months)", + "description": null, + "group": "personal_sub", + "version": 8, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 594, + "interval": "month", + "interval_count": 6, + "display": { + "primary_text": "$594", + "secondary_text": "per 6 months" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1770389942710, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_6m_oneoff", + "name": "Pro", + "description": null, + "group": "personal_oneoff", + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 588, + "interval": "one_off", + "display": { + "primary_text": "$588" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1778823513623, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_8m_n26", + "name": "Pro", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 688, + "interval": "one_off", + "display": { + "primary_text": "$688" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772244766802, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_free_grant", + "name": "Pro (Free Grant)", + "description": null, + "group": null, + "version": 4, + "add_on": false, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "free_trial": { + "duration_length": 180, + "duration_type": "day", + "card_required": true + }, + "created_at": 1773220491531, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_m26", + "name": "Pro (M26)", + "description": null, + "group": "personal_oneoff", + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 138, + "interval": "one_off", + "display": { + "primary_text": "$138" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1778157609759, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_m27", + "name": "Pro (M27)", + "description": null, + "group": "personal_oneoff", + "version": 3, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 798, + "interval": "one_off", + "display": { + "primary_text": "$798" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1778334816203, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_m28", + "name": "Pro (M28)", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 998, + "interval": "one_off", + "display": { + "primary_text": "$998" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772244766802, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_n26", + "name": "Pro (N26)", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 498, + "interval": "one_off", + "display": { + "primary_text": "$498" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772257336344, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_n27", + "name": "Pro (N27)", + "description": null, + "group": "personal_oneoff", + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 898, + "interval": "one_off", + "display": { + "primary_text": "$898" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + } + ], + "created_at": 1772257458795, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_teacher", + "name": "Teacher Pro", + "description": null, + "group": null, + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 399, + "interval": "year", + "display": { + "primary_text": "$399", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "create_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create lessons" + } + }, + { + "feature_id": "create_test", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create tests" + } + }, + { + "feature_id": "enabled_subject", + "included": 3, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "3 enabled subjects" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + }, + { + "feature_id": "teacher_coursework_grader", + "included": 150, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "150 teacher coursework graders" + } + } + ], + "created_at": 1773221106920, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_teacher_1m", + "name": "Teacher Pro (1m)", + "description": null, + "group": null, + "version": 2, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 149, + "interval": "month", + "display": { + "primary_text": "$149", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "create_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create lessons" + } + }, + { + "feature_id": "create_test", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create tests" + } + }, + { + "feature_id": "enabled_subject", + "included": 3, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "3 enabled subjects" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + }, + { + "feature_id": "teacher_coursework_grader", + "included": 150, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "150 teacher coursework graders" + } + } + ], + "created_at": 1773821622225, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_teacher_24m", + "name": "Teacher Pro (2 years)", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 549, + "interval": "year", + "interval_count": 2, + "display": { + "primary_text": "$549", + "secondary_text": "per 2 years" + } + }, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "create_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create lessons" + } + }, + { + "feature_id": "create_test", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create tests" + } + }, + { + "feature_id": "enabled_subject", + "included": 3, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "3 enabled subjects" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + }, + { + "feature_id": "teacher_coursework_grader", + "included": 150, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "150 teacher coursework graders" + } + } + ], + "created_at": 1773221106920, + "env": "live", + "archived": false, + "base_variant_id": "pro_teacher_1m", + "config": { + "ignore_past_due": false + } + }, + { + "id": "pro_teacher_trial", + "name": "Teacher Pro (Grant)", + "description": null, + "group": null, + "version": 5, + "add_on": false, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "create_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create lessons" + } + }, + { + "feature_id": "create_test", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create tests" + } + }, + { + "feature_id": "enabled_subject", + "included": 3, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "3 enabled subjects" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 5, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5 grader daily quotas" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + }, + { + "feature_id": "school_student", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited student seats" + } + }, + { + "feature_id": "teacher_coursework_grader", + "included": 150, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "150 teacher coursework graders" + } + }, + { + "feature_id": "school_teacher", + "included": 0, + "unlimited": true, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited teacher seats" + } + } + ], + "created_at": 1778143401133, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "school_pro", + "name": "Classroom Pro", + "description": null, + "group": null, + "version": 11, + "add_on": false, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "ai_checker_word", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 AI checker words" + } + }, + { + "feature_id": "assign_tests", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Assign tests" + } + }, + { + "feature_id": "can_add_students", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Can add students" + } + }, + { + "feature_id": "classroom_analytics", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Classroom analytics" + } + }, + { + "feature_id": "create_homework", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Create homework" + } + }, + { + "feature_id": "create_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create lessons" + } + }, + { + "feature_id": "create_test", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited create tests" + } + }, + { + "feature_id": "energy", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited energies" + } + }, + { + "feature_id": "grader_daily_quota", + "included": 50, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "50 grader daily quotas" + } + }, + { + "feature_id": "publish_lessons", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Publish lessons" + } + }, + { + "feature_id": "read_flashcard", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read flashcards" + } + }, + { + "feature_id": "read_lesson", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read lessons" + } + }, + { + "feature_id": "read_note", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read notes" + } + }, + { + "feature_id": "read_walkthrough", + "included": 0, + "unlimited": true, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "Unlimited read walkthroughs" + } + }, + { + "feature_id": "school_student", + "included": 500, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "500 student seats" + } + }, + { + "feature_id": "teacher_coursework_grader", + "included": 500, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "500 teacher coursework graders" + } + }, + { + "feature_id": "school_teacher", + "included": 500, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "500 teacher seats" + } + } + ], + "created_at": 1777018263619, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + } + ], + "count": 55, + "next_cursor": null +} diff --git a/server/tests/integration/crud/plans/diffing/runable-plans.json b/server/tests/integration/crud/plans/diffing/runable-plans.json new file mode 100644 index 000000000..53ca28580 --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/runable-plans.json @@ -0,0 +1,3518 @@ +{ + "items": [ + { + "id": "add-on_credits", + "name": "Add-on Credits", + "description": null, + "group": null, + "version": 2, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 10, + "interval": "one_off", + "display": { + "primary_text": "$10" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 10000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "10,000 runable credits" + } + } + ], + "created_at": 1762650993066, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "add-on_credits-1", + "name": "Add-on Credits-1", + "description": null, + "group": null, + "version": 1, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 30, + "interval": "one_off", + "display": { + "primary_text": "$30" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 30000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "30,000 runable credits" + } + } + ], + "created_at": 1762650946543, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "add-on_credits-1_max", + "name": "Add-on Credits-1 Max", + "description": null, + "group": null, + "version": 1, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 30, + "interval": "one_off", + "display": { + "primary_text": "$30" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 34500, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "34,500 runable credits" + } + } + ], + "created_at": 1762650946543, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "add-on_credits-2", + "name": "Add-on Credits-2", + "description": null, + "group": null, + "version": 1, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 50, + "interval": "one_off", + "display": { + "primary_text": "$50" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 50000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "50,000 runable credits" + } + } + ], + "created_at": 1762651011800, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "add-on_credits-2_max", + "name": "Add-on Credits-2 Max", + "description": null, + "group": null, + "version": 1, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 50, + "interval": "one_off", + "display": { + "primary_text": "$50" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 57500, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "57,500 runable credits" + } + } + ], + "created_at": 1762651011800, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "add-on_credits_max", + "name": "Add-on Credits Max", + "description": null, + "group": null, + "version": 1, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 10, + "interval": "one_off", + "display": { + "primary_text": "$10" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 11500, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "11,500 runable credits" + } + } + ], + "created_at": 1762650993066, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "referral_credit_pack", + "name": "Referral Credit Pack", + "description": null, + "group": null, + "version": 3, + "add_on": true, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1763483233988, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_basic", + "name": "Runable Basic", + "description": null, + "group": null, + "version": 21, + "add_on": false, + "auto_enable": true, + "price": null, + "items": [ + { + "feature_id": "1_concurrent_task", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "1 Concurrent Task" + } + }, + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "limited_connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Limited Connectors" + } + }, + { + "feature_id": "no_data_export", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "No Data Export" + } + }, + { + "feature_id": "runable_credits", + "included": 0, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "0 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 0, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "0 runable credits" + } + } + ], + "created_at": 1774641015395, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_go", + "name": "Runable Go", + "description": null, + "group": null, + "version": 2, + "add_on": false, + "auto_enable": false, + "price": null, + "items": [ + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "limited_connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Limited Connectors" + } + }, + { + "feature_id": "no_data_export", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "No Data Export" + } + }, + { + "feature_id": "runable_credits", + "included": 0, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "0 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 0, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "0 runable credits" + } + } + ], + "created_at": 1767955606988, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_max_monthly", + "name": "Runable Max Monthly", + "description": null, + "group": null, + "version": 8, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 249, + "interval": "month", + "display": { + "primary_text": "$249", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "15_more_credits_on_add-on", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "15% more credits on add-on" + } + }, + { + "feature_id": "image_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Image Generation" + } + }, + { + "feature_id": "podcast_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Podcast Generation" + } + }, + { + "feature_id": "ai_report_generation_with_wide_research", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Report Generation with wide research" + } + }, + { + "feature_id": "ai_slides_generation_with_better_design", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation with better design" + } + }, + { + "feature_id": "video_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Video Generation" + } + }, + { + "feature_id": "ai_webapp_builder_with_data_analytics", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder with Data Analytics" + } + }, + { + "feature_id": "connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Connectors" + } + }, + { + "feature_id": "export_outside_runable", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Export Outside Runable" + } + }, + { + "feature_id": "multi-model_ai_chat", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Multi-Model AI Chat" + } + }, + { + "feature_id": "runable_credits", + "included": 249000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "249,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 5000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5,000 runable credits" + } + }, + { + "feature_id": "10_concurrent_tasks", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited Concurrent Tasks" + } + }, + { + "feature_id": "unlimited_context", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited Context" + } + } + ], + "created_at": 1767734700778, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_max_yearly", + "name": "Runable Max Yearly", + "description": null, + "group": null, + "version": 3, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 1200, + "interval": "year", + "display": { + "primary_text": "$1,200", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "15_more_credits_on_add-on", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "15% more credits on add-on" + } + }, + { + "feature_id": "image_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Image Generation" + } + }, + { + "feature_id": "podcast_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Podcast Generation" + } + }, + { + "feature_id": "ai_report_generation_with_wide_research", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Report Generation with wide research" + } + }, + { + "feature_id": "ai_slides_generation_with_better_design", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation with better design" + } + }, + { + "feature_id": "video_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Video Generation" + } + }, + { + "feature_id": "ai_webapp_builder_with_data_analytics", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder with Data Analytics" + } + }, + { + "feature_id": "connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Connectors" + } + }, + { + "feature_id": "export_outside_runable", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Export Outside Runable" + } + }, + { + "feature_id": "multi-model_ai_chat", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Multi-Model AI Chat" + } + }, + { + "feature_id": "runable_credits", + "included": 5000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "5,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 249000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "249,000 runable credits" + } + }, + { + "feature_id": "10_concurrent_tasks", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited Concurrent Tasks" + } + }, + { + "feature_id": "unlimited_context", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Unlimited Context" + } + } + ], + "created_at": 1767735407388, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_plus_monthly", + "name": "Runable Plus Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 9, + "interval": "month", + "display": { + "primary_text": "$9", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "2_concurrent_tasks", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "2 Concurrent Tasks" + } + }, + { + "feature_id": "image_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Image Generation" + } + }, + { + "feature_id": "podcast_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Podcast Generation" + } + }, + { + "feature_id": "advanced_report_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Report Generation" + } + }, + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "video_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Video Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Connectors" + } + }, + { + "feature_id": "export_outside_runable", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Export Outside Runable" + } + }, + { + "feature_id": "runable_credits", + "included": 500, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "500 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 9000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "9,000 runable credits" + } + } + ], + "created_at": 1767721337889, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_plus_yearly", + "name": "Runable Plus Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 108, + "interval": "year", + "display": { + "primary_text": "$108", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "2_concurrent_tasks", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "2 Concurrent Tasks" + } + }, + { + "feature_id": "image_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Image Generation" + } + }, + { + "feature_id": "podcast_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Podcast Generation" + } + }, + { + "feature_id": "advanced_report_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Report Generation" + } + }, + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "video_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Video Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Connectors" + } + }, + { + "feature_id": "export_outside_runable", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Export Outside Runable" + } + }, + { + "feature_id": "runable_credits", + "included": 500, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "500 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 9000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "9,000 runable credits" + } + } + ], + "created_at": 1767721708902, + "env": "live", + "archived": false, + "base_variant_id": "runable_plus_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_10000_monthly", + "name": "Runable Pro 10000 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 10000, + "interval": "month", + "display": { + "primary_text": "$10,000", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 10000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "10,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666021812, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_10000_yearly", + "name": "Runable Pro 10000 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 120000, + "interval": "year", + "display": { + "primary_text": "$120,000", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 10000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "10,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666022532, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_10000_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_1000_monthly", + "name": "Runable Pro 1000 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 1000, + "interval": "month", + "display": { + "primary_text": "$1,000", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 1000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666015571, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_1000_yearly", + "name": "Runable Pro 1000 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 12000, + "interval": "year", + "display": { + "primary_text": "$12,000", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 1000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666016318, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_1000_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_100_monthly", + "name": "Runable Pro 100 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 100, + "interval": "month", + "display": { + "primary_text": "$100", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 100000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "100,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666006252, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_100_yearly", + "name": "Runable Pro 100 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 1200, + "interval": "year", + "display": { + "primary_text": "$1,200", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 100000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "100,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666006994, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_100_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_1500_monthly", + "name": "Runable Pro 1500 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 1500, + "interval": "month", + "display": { + "primary_text": "$1,500", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 1500000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,500,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666017078, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_1500_yearly", + "name": "Runable Pro 1500 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 18000, + "interval": "year", + "display": { + "primary_text": "$18,000", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 1500000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "1,500,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666017818, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_1500_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_20000_monthly", + "name": "Runable Pro 20000 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 20000, + "interval": "month", + "display": { + "primary_text": "$20,000", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 20000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "20,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666023353, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_20000_yearly", + "name": "Runable Pro 20000 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 240000, + "interval": "year", + "display": { + "primary_text": "$240,000", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 20000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "20,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666024166, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_20000_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_2000_monthly", + "name": "Runable Pro 2000 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 2000, + "interval": "month", + "display": { + "primary_text": "$2,000", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 2000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "2,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666018641, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_2000_yearly", + "name": "Runable Pro 2000 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 24000, + "interval": "year", + "display": { + "primary_text": "$24,000", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 2000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "2,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666019365, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_2000_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_200_monthly", + "name": "Runable Pro 200 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 200, + "interval": "month", + "display": { + "primary_text": "$200", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 200000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "200,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666007768, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_200_yearly", + "name": "Runable Pro 200 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 2400, + "interval": "year", + "display": { + "primary_text": "$2,400", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 200000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "200,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666008603, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_200_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_25_monthly", + "name": "Runable Pro 25 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 25, + "interval": "month", + "display": { + "primary_text": "$25", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 500, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "500 runable credits" + } + } + ], + "created_at": 1772666001793, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_25_yearly", + "name": "Runable Pro 25 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 300, + "interval": "year", + "display": { + "primary_text": "$300", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "25,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 500, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "500 runable credits" + } + } + ], + "created_at": 1772666002567, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_25_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_300_monthly", + "name": "Runable Pro 300 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 300, + "interval": "month", + "display": { + "primary_text": "$300", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 300000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "300,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666009335, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_300_yearly", + "name": "Runable Pro 300 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 3600, + "interval": "year", + "display": { + "primary_text": "$3,600", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 300000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "300,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666010136, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_300_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_400_monthly", + "name": "Runable Pro 400 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 400, + "interval": "month", + "display": { + "primary_text": "$400", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 400000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "400,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666010964, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_400_yearly", + "name": "Runable Pro 400 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 4800, + "interval": "year", + "display": { + "primary_text": "$4,800", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 400000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "400,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666011698, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_400_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_5000_monthly", + "name": "Runable Pro 5000 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 5000, + "interval": "month", + "display": { + "primary_text": "$5,000", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 5000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666020174, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_5000_yearly", + "name": "Runable Pro 5000 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 60000, + "interval": "year", + "display": { + "primary_text": "$60,000", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 5000000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "5,000,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666020995, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_5000_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_500_monthly", + "name": "Runable Pro 500 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 500, + "interval": "month", + "display": { + "primary_text": "$500", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 500000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "500,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666012427, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_500_yearly", + "name": "Runable Pro 500 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 6000, + "interval": "year", + "display": { + "primary_text": "$6,000", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 500000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "500,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666013212, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_500_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_50_monthly", + "name": "Runable Pro 50 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 50, + "interval": "month", + "display": { + "primary_text": "$50", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 50000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "50,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666003303, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_50_yearly", + "name": "Runable Pro 50 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 600, + "interval": "year", + "display": { + "primary_text": "$600", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 50000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "50,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666004047, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_50_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_750_monthly", + "name": "Runable Pro 750 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 750, + "interval": "month", + "display": { + "primary_text": "$750", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 750000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "750,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666014035, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_750_yearly", + "name": "Runable Pro 750 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 9000, + "interval": "year", + "display": { + "primary_text": "$9,000", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 750000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "750,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666014759, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_750_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_75_monthly", + "name": "Runable Pro 75 Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 75, + "interval": "month", + "display": { + "primary_text": "$75", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 75000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "75,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666004781, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_75_yearly", + "name": "Runable Pro 75 Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 900, + "interval": "year", + "display": { + "primary_text": "$900", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 75000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "75,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + } + ], + "created_at": 1772666005523, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_75_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_monthly", + "name": "Runable Pro Monthly", + "description": null, + "group": null, + "version": 13, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 29, + "interval": "month", + "display": { + "primary_text": "$29", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "3_concurrent_tasks", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "3 Concurrent Tasks" + } + }, + { + "feature_id": "image_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Image Generation" + } + }, + { + "feature_id": "podcast_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Podcast Generation" + } + }, + { + "feature_id": "advanced_report_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Report Generation" + } + }, + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "video_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Video Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Connectors" + } + }, + { + "feature_id": "export_outside_runable", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Export Outside Runable" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 29000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "29,000 runable credits" + } + } + ], + "created_at": 1767734351586, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_pro_yearly", + "name": "Runable Pro Yearly", + "description": null, + "group": null, + "version": 8, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 216, + "interval": "year", + "display": { + "primary_text": "$216", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "3_concurrent_tasks", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "3 Concurrent Tasks" + } + }, + { + "feature_id": "image_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Image Generation" + } + }, + { + "feature_id": "podcast_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Podcast Generation" + } + }, + { + "feature_id": "advanced_report_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Report Generation" + } + }, + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "video_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Video Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Connectors" + } + }, + { + "feature_id": "export_outside_runable", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Export Outside Runable" + } + }, + { + "feature_id": "runable_credits", + "included": 1000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 29000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "29,000 runable credits" + } + } + ], + "created_at": 1767735180143, + "env": "live", + "archived": false, + "base_variant_id": "runable_pro_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_starter_monthly", + "name": "Runable Starter Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 10, + "interval": "month", + "display": { + "primary_text": "$10", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "1_concurrent_task", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "1 Concurrent Task" + } + }, + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "limited_connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Limited Connectors" + } + }, + { + "feature_id": "no_data_export", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "No Data Export" + } + }, + { + "feature_id": "runable_credits", + "included": 1, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1 runable credit" + } + }, + { + "feature_id": "runable_credits", + "included": 10000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "10,000 runable credits" + } + } + ], + "created_at": 1762594036620, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_starter_yearly", + "name": "Runable Starter Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 120, + "interval": "year", + "display": { + "primary_text": "$120", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "1_concurrent_task", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "1 Concurrent Task" + } + }, + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "limited_connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Limited Connectors" + } + }, + { + "feature_id": "no_data_export", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "No Data Export" + } + }, + { + "feature_id": "runable_credits", + "included": 1, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "1 runable credit" + } + }, + { + "feature_id": "runable_credits", + "included": 10000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "10,000 runable credits" + } + } + ], + "created_at": 1762594036620, + "env": "live", + "archived": false, + "base_variant_id": "runable_starter_monthly", + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_topup_100", + "name": "Runable Topup 100", + "description": null, + "group": null, + "version": 2, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 100, + "interval": "one_off", + "display": { + "primary_text": "$100" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 130000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "130,000 runable credits" + } + } + ], + "created_at": 1776204727618, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_topup_15", + "name": "Runable Topup 15", + "description": null, + "group": null, + "version": 1, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 15, + "interval": "one_off", + "display": { + "primary_text": "$15" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 12000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "12,000 runable credits" + } + } + ], + "created_at": 1776204724848, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_topup_25", + "name": "Runable Topup 25", + "description": null, + "group": null, + "version": 1, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 25, + "interval": "one_off", + "display": { + "primary_text": "$25" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 25000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "25,000 runable credits" + } + } + ], + "created_at": 1775590211705, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_topup_50", + "name": "Runable Topup 50", + "description": null, + "group": null, + "version": 2, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 50, + "interval": "one_off", + "display": { + "primary_text": "$50" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 60000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "60,000 runable credits" + } + } + ], + "created_at": 1776204726608, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_topup_75", + "name": "Runable Topup 75", + "description": null, + "group": null, + "version": 1, + "add_on": true, + "auto_enable": false, + "price": { + "amount": 75, + "interval": "one_off", + "display": { + "primary_text": "$75" + } + }, + "items": [ + { + "feature_id": "runable_credits", + "included": 75000, + "unlimited": false, + "reset": { + "interval": "one_off" + }, + "price": null, + "display": { + "primary_text": "75,000 runable credits" + } + } + ], + "created_at": 1775590213327, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_unlimited_monthly", + "name": "Runable Unlimited Monthly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 49, + "interval": "month", + "display": { + "primary_text": "$49", + "secondary_text": "per month" + } + }, + "items": [ + { + "feature_id": "5_concurrent_tasks", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 Concurrent Tasks" + } + }, + { + "feature_id": "image_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Image Generation" + } + }, + { + "feature_id": "podcast_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Podcast Generation" + } + }, + { + "feature_id": "advanced_report_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Report Generation" + } + }, + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "video_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Video Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Connectors" + } + }, + { + "feature_id": "export_outside_runable", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Export Outside Runable" + } + }, + { + "feature_id": "multi-model_ai_chat", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Multi-Model AI Chat" + } + }, + { + "feature_id": "runable_credits", + "included": 2000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "2,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 49000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "49,000 runable credits" + } + } + ], + "created_at": 1767721337889, + "env": "live", + "archived": false, + "base_variant_id": null, + "config": { + "ignore_past_due": false + } + }, + { + "id": "runable_unlimited_yearly", + "name": "Runable Unlimited Yearly", + "description": null, + "group": null, + "version": 1, + "add_on": false, + "auto_enable": false, + "price": { + "amount": 300, + "interval": "year", + "display": { + "primary_text": "$300", + "secondary_text": "per year" + } + }, + "items": [ + { + "feature_id": "5_concurrent_tasks", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "5 Concurrent Tasks" + } + }, + { + "feature_id": "image_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Image Generation" + } + }, + { + "feature_id": "podcast_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Podcast Generation" + } + }, + { + "feature_id": "advanced_report_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Report Generation" + } + }, + { + "feature_id": "slides_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Slides Generation" + } + }, + { + "feature_id": "video_generation", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Video Generation" + } + }, + { + "feature_id": "website_builder", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "AI Webapp Builder" + } + }, + { + "feature_id": "connectors", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Connectors" + } + }, + { + "feature_id": "export_outside_runable", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Export Outside Runable" + } + }, + { + "feature_id": "multi-model_ai_chat", + "included": 0, + "unlimited": false, + "reset": null, + "price": null, + "display": { + "primary_text": "Multi-Model AI Chat" + } + }, + { + "feature_id": "runable_credits", + "included": 2000, + "unlimited": false, + "reset": { + "interval": "day" + }, + "price": null, + "display": { + "primary_text": "2,000 runable credits" + } + }, + { + "feature_id": "runable_credits", + "included": 49000, + "unlimited": false, + "reset": { + "interval": "month" + }, + "price": null, + "display": { + "primary_text": "49,000 runable credits" + } + } + ], + "created_at": 1767721708902, + "env": "live", + "archived": false, + "base_variant_id": "runable_unlimited_monthly", + "config": { + "ignore_past_due": false + } + } + ] +} diff --git a/server/tests/integration/crud/plans/diffing/utils/findById.ts b/server/tests/integration/crud/plans/diffing/utils/findById.ts new file mode 100644 index 000000000..fef6a21c2 --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/utils/findById.ts @@ -0,0 +1,5 @@ +export const findById = (items: T[], id: string): T => { + const item = items.find((p) => p.id === id); + if (!item) throw new Error(`Item not found: ${id}`); + return item; +}; diff --git a/server/tests/integration/crud/plans/diffing/utils/normalizePlan.ts b/server/tests/integration/crud/plans/diffing/utils/normalizePlan.ts new file mode 100644 index 000000000..d606327bf --- /dev/null +++ b/server/tests/integration/crud/plans/diffing/utils/normalizePlan.ts @@ -0,0 +1,72 @@ +import type { ApiPlanV1 } from "@autumn/shared"; +import type { ApplyDiffOutput } from "@autumn/shared/utils/planV1Utils/diff/applyDiff.js"; + +export const ITEM_FIELDS = [ + "feature_id", + "included", + "unlimited", + "reset", + "price", + "rollover", +] as const; + +export type ApiPlanItem = ApiPlanV1["items"][number]; + +export type NormalizablePlan = { + price: ApiPlanV1["price"]; + items: ApiPlanV1["items"]; + free_trial?: ApiPlanV1["free_trial"]; +}; + +export const normalizeRollover = (rollover: ApiPlanItem["rollover"]) => { + if (rollover === null || rollover === undefined) return undefined; + const out: Record = { + expiry_duration_type: rollover.expiry_duration_type, + }; + if (rollover.max != null) out.max = rollover.max; + if (rollover.max_percentage != null) + out.max_percentage = rollover.max_percentage; + if (rollover.expiry_duration_length !== undefined) + out.expiry_duration_length = rollover.expiry_duration_length; + return out; +}; + +export const normalizeItem = (item: ApiPlanItem) => { + const out: Record = {}; + for (const k of ITEM_FIELDS) { + if (k === "rollover") { + const val = item.rollover; + if (val !== undefined && val !== null) out[k] = normalizeRollover(val); + } else { + const val = item[k]; + // Diff omits nullish fields in create params; treat null == absent. + if (val !== undefined && val !== null) out[k] = val; + } + } + return out; +}; + +export const normalizePrice = (price: ApiPlanV1["price"]) => { + if (price === null || price === undefined) return null; + const { display: _d, ...rest } = price; + return rest; +}; + +export const normalizeFreeTrial = (ft: ApiPlanV1["free_trial"]) => { + if (ft === null || ft === undefined) return null; + const out = { ...ft }; + if (out.on_end === null || out.on_end === undefined) delete out.on_end; + return out; +}; + +export const normalizePlan = (plan: NormalizablePlan | ApplyDiffOutput) => ({ + price: normalizePrice(plan.price), + items: [...plan.items] + .sort((a, b) => { + const byFeature = a.feature_id.localeCompare(b.feature_id); + if (byFeature !== 0) return byFeature; + return (a.included ?? 0) - (b.included ?? 0); + }) + .map(normalizeItem), + free_trial: normalizeFreeTrial(plan.free_trial), +}); diff --git a/server/tests/integration/crud/plans/update/in-place/in-place-add.test.ts b/server/tests/integration/crud/plans/update/in-place/in-place-add.test.ts new file mode 100644 index 000000000..0e57d7603 --- /dev/null +++ b/server/tests/integration/crud/plans/update/in-place/in-place-add.test.ts @@ -0,0 +1,174 @@ +/** + * In-place plan update (disable_version) — ADD entitlement-only on a plan with + * existing customers. The catalog gains the new item; existing customers are + * left untouched; future customers inherit it. + * + * Contract under test: + * C1 catalog: getFull(planId) includes the new ent (is_custom:false), version unchanged. + * C2 existing customer UNCHANGED: same customer_entitlements (entitlement_id set, + * balances) and customer_prices; no new cusEnt for the added feature. + * C3 existing customer does NOT get the new flag. + * C4 no extra invoice for the existing customer. + * C5 a NEW customer attaching after the update inherits the feature. + * C6 disable_version is reachable via the V2 plans.update RPC. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + type ApiPlanV1, + ApiVersion, + BillingInterval, + type UpdatePlanParamsV2Input, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { ProductService } from "@/internal/products/ProductService.js"; + +type UpdatePlanRpcInput = Omit; + +const getCatalogEnt = async ({ + ctx, + planId, + featureId, +}: { + ctx: Parameters[0]["ctx"]; + planId: string; + featureId: string; +}) => { + const product = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: planId, + orgId: ctx.org.id, + env: ctx.env, + }); + return { + ent: product.entitlements.find((entry) => entry.feature?.id === featureId), + version: product.version, + }; +}; + +const snapshotCustomerItems = async ({ + ctx, + customerId, +}: { + ctx: Parameters[0]["ctx"]; + customerId: string; +}) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const cusProduct = fullCustomer.customer_products[0]; + return { + entitlementIds: cusProduct.customer_entitlements + .map((entry) => entry.entitlement_id) + .sort(), + balances: cusProduct.customer_entitlements + .map((entry) => ({ + entitlement_id: entry.entitlement_id, + balance: entry.balance, + next_reset_at: entry.next_reset_at, + })) + .sort((a, b) => a.entitlement_id.localeCompare(b.entitlement_id)), + priceIds: cusProduct.customer_prices.map((entry) => entry.price_id).sort(), + }; +}; + +test(`${chalk.yellowBright("plans.update disable_version: ADD entitlement-only keeps existing customers unchanged")}`, async () => { + const customerId = "plan-in-place-add-existing"; + const newCustomerId = "plan-in-place-add-new"; + const pro = products.pro({ + id: "pro_in_place_add", + items: [itemsV2.dashboard()], + }); + const adminRights = { feature_id: TestFeature.AdminRights }; + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.otherCustomers([{ id: newCustomerId, paymentMethod: "success" }]), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const autumnRpc = new AutumnRpcCli({ + secretKey: ctx.orgSecretKey, + version: ApiVersion.V2_1, + }); + + // Pre: catalog lacks the added feature; snapshot existing customer. + expect( + ( + await getCatalogEnt({ + ctx, + planId: pro.id, + featureId: TestFeature.AdminRights, + }) + ).ent, + ).toBeUndefined(); + const before = await snapshotCustomerItems({ ctx, customerId }); + + // C6: disable_version travels through the V2 RPC body. + await autumnRpc.plans.update(pro.id, { + disable_version: true, + price: { amount: 20, interval: BillingInterval.Month }, + items: [itemsV2.dashboard(), adminRights], + }); + + // C1: catalog updated in place (new ent, same version). + const { ent: addedEnt, version: afterVersion } = await getCatalogEnt({ + ctx, + planId: pro.id, + featureId: TestFeature.AdminRights, + }); + expect(addedEnt).toBeDefined(); + expect(addedEnt?.is_custom).toBe(false); + expect(afterVersion).toBe(1); + + // C2: existing customer's rows are byte-identical. + const after = await snapshotCustomerItems({ ctx, customerId }); + expect(after.entitlementIds).toEqual(before.entitlementIds); + expect(after.balances).toEqual(before.balances); + expect(after.priceIds).toEqual(before.priceIds); + + // C3: existing customer did NOT gain the flag. + const existingCustomer = + await autumnV2_2.customers.get(customerId); + expectFlagCorrect({ + customer: existingCustomer, + featureId: TestFeature.AdminRights, + planId: pro.id, + present: false, + }); + + // C4: no extra invoice for the existing customer. + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 1, + }); + + // C5: a customer attaching AFTER the update inherits the new feature. + await autumnV2_2.billing.attach({ + customer_id: newCustomerId, + plan_id: pro.id, + }); + const newCustomer = + await autumnV2_2.customers.get(newCustomerId); + expectFlagCorrect({ + customer: newCustomer, + featureId: TestFeature.AdminRights, + planId: pro.id, + present: true, + }); +}); diff --git a/server/tests/integration/crud/plans/update/in-place/in-place-base-price.test.ts b/server/tests/integration/crud/plans/update/in-place/in-place-base-price.test.ts new file mode 100644 index 000000000..a38c69934 --- /dev/null +++ b/server/tests/integration/crud/plans/update/in-place/in-place-base-price.test.ts @@ -0,0 +1,114 @@ +/** + * In-place plan update (disable_version) — changing the BASE PRICE must NOT + * mutate the shared price row existing customers are billed on. The old base + * price is retired (is_custom:true, Stripe price frozen); a new is_custom:false + * base price (new Stripe price) is created for future customers. + * + * Guards a regression where the base price was excluded from the retire pass and + * got upserted in place, changing existing customers' billing. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type ApiPlanV1, + ApiVersion, + BillingInterval, + ResetInterval, + type UpdatePlanParamsV2Input, +} from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { snapshotCustomerState } from "./utils/snapshotCustomerState"; + +type RpcInput = Omit; + +const basePrice = async ({ + ctx, + planId, +}: { + ctx: Parameters[0]["ctx"]; + planId: string; +}) => { + const product = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: planId, + orgId: ctx.org.id, + env: ctx.env, + }); + return product.prices.find((price) => price.config?.type === "fixed"); +}; + +test(`${chalk.yellowBright("plans.update disable_version: base price change retires old price, existing customer billing unchanged")}`, async () => { + const customerId = "plan-in-place-baseprice-existing"; + const newCustomerId = "plan-in-place-baseprice-new"; + const pro = products.pro({ + id: "pro_in_place_baseprice", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.otherCustomers([{ id: newCustomerId, paymentMethod: "success" }]), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const autumnRpc = new AutumnRpcCli({ + secretKey: ctx.orgSecretKey, + version: ApiVersion.V2_1, + }); + + const oldPrice = await basePrice({ ctx, planId: pro.id }); + expect((oldPrice?.config as { amount?: number })?.amount).toBe(20); + const before = await snapshotCustomerState({ ctx, customerId }); + + // Change the base price 20 -> 30 in place. + await autumnRpc.plans.update(pro.id, { + disable_version: true, + price: { amount: 30, interval: BillingInterval.Month }, + items: [ + { + feature_id: TestFeature.Messages, + included: 100, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + // Catalog: a single is_custom:false base price with the NEW amount + a fresh id. + const newPrice = await basePrice({ ctx, planId: pro.id }); + expect((newPrice?.config as { amount?: number })?.amount).toBe(30); + expect(newPrice?.is_custom).toBe(false); + expect(newPrice?.id).not.toBe(oldPrice?.id); + + // Existing customer: byte-identical (still references the retired price), and + // their Stripe subscription is unchanged. + expect(await snapshotCustomerState({ ctx, customerId })).toBe(before); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + // New customer attaches against the new catalog (and gets the feature). + await autumnV2_2.billing.attach({ + customer_id: newCustomerId, + plan_id: pro.id, + }); + const newCustomer = + await autumnV2_2.customers.get(newCustomerId); + expectBalanceCorrect({ + customer: newCustomer, + featureId: TestFeature.Messages, + remaining: 100, + usage: 0, + planId: pro.id, + }); +}); diff --git a/server/tests/integration/crud/plans/update/in-place/in-place-delete.test.ts b/server/tests/integration/crud/plans/update/in-place/in-place-delete.test.ts new file mode 100644 index 000000000..23115e21f --- /dev/null +++ b/server/tests/integration/crud/plans/update/in-place/in-place-delete.test.ts @@ -0,0 +1,144 @@ +/** + * In-place plan update (disable_version) — DELETE an existing entitlement on a + * plan with existing customers. The old catalog ent is retired (is_custom:true) + * because customers reference it (NOT cascade-deleted); the catalog no longer + * exposes it, so future customers don't get it; existing customers keep it. + * + * Contract: + * - Catalog: deleted feature absent from getFull. + * - Existing customer: snapshot byte-identical (cusEnt NOT cascade-deleted). + * - New customer attaching after does NOT get the feature. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type ApiPlanV1, + ApiVersion, + BillingInterval, + ResetInterval, + type UpdatePlanParamsV2Input, +} from "@autumn/shared"; +import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { snapshotCustomerState } from "./utils/snapshotCustomerState"; + +const catalogEnt = async ({ + ctx, + planId, + featureId, +}: { + ctx: Parameters[0]["ctx"]; + planId: string; + featureId: string; +}) => { + const product = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: planId, + orgId: ctx.org.id, + env: ctx.env, + }); + return product.entitlements.find((ent) => ent.feature?.id === featureId); +}; + +test(`${chalk.yellowBright("plans.update disable_version: DELETE retires the ent, existing customer keeps it")}`, async () => { + const customerId = "plan-in-place-delete-existing"; + const newCustomerId = "plan-in-place-delete-new"; + const pro = products.pro({ + id: "pro_in_place_delete", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + { feature_id: TestFeature.AdminRights }, + ], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.otherCustomers([{ id: newCustomerId, paymentMethod: "success" }]), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const autumnRpc = new AutumnRpcCli({ + secretKey: ctx.orgSecretKey, + version: ApiVersion.V2_1, + }); + + // Pre: existing customer has the flag; catalog has the ent. + expect( + await catalogEnt({ + ctx, + planId: pro.id, + featureId: TestFeature.AdminRights, + }), + ).toBeDefined(); + const before = await snapshotCustomerState({ ctx, customerId }); + const existingBefore = + await autumnV2_2.customers.get(customerId); + expectFlagCorrect({ + customer: existingBefore, + featureId: TestFeature.AdminRights, + planId: pro.id, + present: true, + }); + + // DELETE the AdminRights feature in place (keep Messages). + await autumnRpc.plans.update< + ApiPlanV1, + Omit + >(pro.id, { + disable_version: true, + price: { amount: 20, interval: BillingInterval.Month }, + items: [ + { + feature_id: TestFeature.Messages, + included: 100, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + // Catalog: feature retired, gone from getFull. + expect( + await catalogEnt({ + ctx, + planId: pro.id, + featureId: TestFeature.AdminRights, + }), + ).toBeUndefined(); + + // Existing customer: byte-identical — cusEnt NOT cascade-deleted. + const after = await snapshotCustomerState({ ctx, customerId }); + expect(after).toBe(before); + const existingAfter = + await autumnV2_2.customers.get(customerId); + expectFlagCorrect({ + customer: existingAfter, + featureId: TestFeature.AdminRights, + planId: pro.id, + present: true, + }); + + // New customer does NOT get the deleted feature. + await autumnV2_2.billing.attach({ + customer_id: newCustomerId, + plan_id: pro.id, + }); + const newCustomer = + await autumnV2_2.customers.get(newCustomerId); + expectFlagCorrect({ + customer: newCustomer, + featureId: TestFeature.AdminRights, + planId: pro.id, + present: false, + }); +}); diff --git a/server/tests/integration/crud/plans/update/in-place/in-place-isolation.test.ts b/server/tests/integration/crud/plans/update/in-place/in-place-isolation.test.ts new file mode 100644 index 000000000..ae684721c --- /dev/null +++ b/server/tests/integration/crud/plans/update/in-place/in-place-isolation.test.ts @@ -0,0 +1,311 @@ +/** + * In-place plan edits must not touch ANY other customer. Each case captures the + * full state of a customer that should NOT change, performs an in-place edit on + * an UNRELATED plan/feature, and asserts that customer's snapshot is identical. + * + * Dimensions: many customers (same plan), different plans sharing a feature, + * different versions, trials, entities. + */ + +import { expect, test } from "bun:test"; +import { + type ApiPlanV1, + ApiVersion, + BillingInterval, + entitlements, + ResetInterval, + type UpdatePlanParamsV2Input, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { and, eq } from "drizzle-orm"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { snapshotCustomerState } from "./utils/snapshotCustomerState"; + +type RpcInput = Omit; + +const rpcFor = (ctx: { orgSecretKey: string }) => + new AutumnRpcCli({ secretKey: ctx.orgSecretKey, version: ApiVersion.V2_1 }); + +const messagesItems = (included: number) => [ + { + feature_id: TestFeature.Messages, + included, + reset: { interval: ResetInterval.Month }, + }, +]; + +const monthPrice = { amount: 20, interval: BillingInterval.Month }; + +test(`${chalk.yellowBright("in-place isolation: many customers on the same plan all preserved on ADD")}`, async () => { + const primary = "iso-many-primary"; + const others = ["iso-many-2", "iso-many-3", "iso-many-4"]; + const pro = products.pro({ + id: "iso_many", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { ctx } = await initScenario({ + customerId: primary, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [pro] }), + s.otherCustomers(others.map((id) => ({ id, paymentMethod: "success" }))), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + ...others.map((id) => + s.billing.attach({ productId: pro.id, customerId: id }), + ), + ], + }); + + const all = [primary, ...others]; + const before: Record = {}; + for (const id of all) + before[id] = await snapshotCustomerState({ ctx, customerId: id }); + + await rpcFor(ctx).plans.update(pro.id, { + disable_version: true, + price: monthPrice, + items: [...messagesItems(100), { feature_id: TestFeature.AdminRights }], + }); + + for (const id of all) + expect(await snapshotCustomerState({ ctx, customerId: id })).toBe( + before[id], + ); +}); + +test(`${chalk.yellowBright("in-place isolation: different plans sharing a feature do not cross-contaminate")}`, async () => { + const cusA = "iso-shared-a"; + const cusB = "iso-shared-b"; + const planA = products.pro({ + id: "iso_shared_a", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const planB = products.pro({ + id: "iso_shared_b", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { ctx } = await initScenario({ + customerId: cusA, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [planA, planB] }), + s.otherCustomers([{ id: cusB, paymentMethod: "success" }]), + ], + actions: [ + s.billing.attach({ productId: planA.id }), + s.billing.attach({ productId: planB.id, customerId: cusB }), + ], + }); + + const beforeB = await snapshotCustomerState({ ctx, customerId: cusB }); + + // UPDATE plan A's Messages allowance — plan B grants the same feature via a + // SEPARATE catalog ent, so its customer must be untouched. + await rpcFor(ctx).plans.update(planA.id, { + disable_version: true, + price: monthPrice, + items: messagesItems(200), + }); + + expect(await snapshotCustomerState({ ctx, customerId: cusB })).toBe(beforeB); +}); + +test(`${chalk.yellowBright("in-place isolation: editing latest version leaves older-version customers untouched")}`, async () => { + const cusV1 = "iso-version-v1"; + const cusV2 = "iso-version-v2"; + const pro = products.pro({ + id: "iso_version", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId: cusV1, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.otherCustomers([{ id: cusV2, paymentMethod: "success" }]), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + // Bump to v2 (cusV1 stays on v1), attach cusV2 to v2. + await autumnV1.products.update(pro.id, { + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + await autumnV2_2.billing.attach({ customer_id: cusV2, plan_id: pro.id }); + + const beforeV1 = await snapshotCustomerState({ ctx, customerId: cusV1 }); + + // In-place edit resolves to the latest (v2). v1's customer + v1's catalog + // ents are different rows → unaffected. + await rpcFor(ctx).plans.update(pro.id, { + disable_version: true, + price: monthPrice, + items: messagesItems(300), + }); + + expect(await snapshotCustomerState({ ctx, customerId: cusV1 })).toBe( + beforeV1, + ); + const v1Product = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: pro.id, + orgId: ctx.org.id, + env: ctx.env, + version: 1, + }); + expect( + v1Product.entitlements.find((e) => e.feature?.id === TestFeature.Messages) + ?.allowance, + ).toBe(100); +}); + +test(`${chalk.yellowBright("in-place isolation: a trialing customer on another plan is preserved")}`, async () => { + const trialCus = "iso-trial-cus"; + const editCus = "iso-trial-edit"; + const trialPlan = products.proWithTrial({ + id: "iso_trial_plan", + trialDays: 7, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const editPlan = products.pro({ + id: "iso_trial_edit_plan", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { ctx } = await initScenario({ + customerId: trialCus, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [trialPlan, editPlan] }), + s.otherCustomers([{ id: editCus, paymentMethod: "success" }]), + ], + actions: [ + s.billing.attach({ productId: trialPlan.id }), + s.billing.attach({ productId: editPlan.id, customerId: editCus }), + ], + }); + + const beforeTrial = await snapshotCustomerState({ + ctx, + customerId: trialCus, + }); + + await rpcFor(ctx).plans.update(editPlan.id, { + disable_version: true, + price: monthPrice, + items: messagesItems(200), + }); + + expect(await snapshotCustomerState({ ctx, customerId: trialCus })).toBe( + beforeTrial, + ); +}); + +test(`${chalk.yellowBright("in-place isolation: an entity-scoped customer on another plan is preserved")}`, async () => { + const entityCus = "iso-entity-cus"; + const editCus = "iso-entity-edit"; + const entityPlan = products.pro({ + id: "iso_entity_plan", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + const editPlan = products.pro({ + id: "iso_entity_edit_plan", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { ctx } = await initScenario({ + customerId: entityCus, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [entityPlan, editPlan] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + s.otherCustomers([{ id: editCus, paymentMethod: "success" }]), + ], + actions: [ + s.billing.attach({ productId: entityPlan.id, entityIndex: 0 }), + s.billing.attach({ productId: editPlan.id, customerId: editCus }), + ], + }); + + const beforeEntity = await snapshotCustomerState({ + ctx, + customerId: entityCus, + }); + + await rpcFor(ctx).plans.update(editPlan.id, { + disable_version: true, + price: monthPrice, + items: messagesItems(200), + }); + + expect(await snapshotCustomerState({ ctx, customerId: entityCus })).toBe( + beforeEntity, + ); +}); + +// NOTE: a "scheduled customer" isolation case is intentionally omitted — the +// downgrade/cancel path that creates a scheduled cus_product currently errors at +// setup in this environment (`malformed array literal`, also breaks +// migrate-states.test.ts), unrelated to in-place edits. Scheduled cusProducts +// carry normal customer_entitlements, so the reference check retires (not +// deletes) any ent they hold — the same guarantee the other cases prove. + +test(`${chalk.yellowBright("in-place isolation: no-customer plan mutates in place (no retired rows)")}`, async () => { + const owner = "iso-nocus-owner"; + const pro = products.pro({ + id: "iso_nocus", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { ctx } = await initScenario({ + customerId: owner, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + // No customers on the plan -> mutate in place, no is_custom:true rows left. + await rpcFor(ctx).plans.update(pro.id, { + disable_version: true, + price: monthPrice, + items: messagesItems(200), + }); + + const product = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: pro.id, + orgId: ctx.org.id, + env: ctx.env, + }); + expect( + product.entitlements.find((e) => e.feature?.id === TestFeature.Messages) + ?.allowance, + ).toBe(200); + const customEnts = await ctx.db + .select() + .from(entitlements) + .where( + and( + eq(entitlements.internal_product_id, product.internal_id), + eq(entitlements.is_custom, true), + ), + ); + expect(customEnts).toHaveLength(0); +}); diff --git a/server/tests/integration/crud/plans/update/in-place/in-place-update.test.ts b/server/tests/integration/crud/plans/update/in-place/in-place-update.test.ts new file mode 100644 index 000000000..9d5961389 --- /dev/null +++ b/server/tests/integration/crud/plans/update/in-place/in-place-update.test.ts @@ -0,0 +1,229 @@ +/** + * In-place plan update (disable_version) — UPDATE an existing entitlement + * (allowance change) on a plan with existing customers. The old catalog ent is + * retired (is_custom:true), a new is_custom:false ent carries the new + * definition; existing customers keep referencing the retired ent (unchanged); + * future customers get the new one. + * + * Contract: + * - Catalog: old ent absent from getFull, new ent present with new allowance. + * - Existing customer: snapshot byte-identical (same entitlement_id + balance), + * no extra invoice. + * - New customer attaching after gets the new allowance. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + type ApiPlanV1, + ApiVersion, + BillingInterval, + BillingMethod, + ResetInterval, + type UpdatePlanParamsV2Input, +} from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { snapshotCustomerState } from "./utils/snapshotCustomerState"; + +type RpcInput = Omit; + +const messagesEnt = async ({ + ctx, + planId, + version, +}: { + ctx: Parameters[0]["ctx"]; + planId: string; + version?: number; +}) => { + const product = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: planId, + orgId: ctx.org.id, + env: ctx.env, + version, + }); + return product.entitlements.find( + (ent) => ent.feature?.id === TestFeature.Messages, + ); +}; + +test(`${chalk.yellowBright("plans.update disable_version: UPDATE retires old ent, existing customer unchanged")}`, async () => { + const customerId = "plan-in-place-update-existing"; + const newCustomerId = "plan-in-place-update-new"; + const pro = products.pro({ + id: "pro_in_place_update", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.otherCustomers([{ id: newCustomerId, paymentMethod: "success" }]), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const autumnRpc = new AutumnRpcCli({ + secretKey: ctx.orgSecretKey, + version: ApiVersion.V2_1, + }); + + const oldEnt = await messagesEnt({ ctx, planId: pro.id }); + expect(oldEnt?.allowance).toBe(100); + const before = await snapshotCustomerState({ ctx, customerId }); + + // UPDATE allowance 100 -> 200 in place. + await autumnRpc.plans.update< + ApiPlanV1, + Omit + >(pro.id, { + disable_version: true, + price: { amount: 20, interval: BillingInterval.Month }, + items: [ + { + feature_id: TestFeature.Messages, + included: 200, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + // Catalog: a single is_custom:false Messages ent with the NEW allowance. + const newEnt = await messagesEnt({ ctx, planId: pro.id }); + expect(newEnt?.allowance).toBe(200); + expect(newEnt?.is_custom).toBe(false); + expect(newEnt?.id).not.toBe(oldEnt?.id); + + // Existing customer: byte-identical (still references the retired ent), no charge. + const after = await snapshotCustomerState({ ctx, customerId }); + expect(after).toBe(before); + await expectCustomerInvoiceCorrect({ + customer: await autumnV1.customers.get(customerId), + count: 1, + }); + + // New customer gets the new allowance. + await autumnV2_2.billing.attach({ + customer_id: newCustomerId, + plan_id: pro.id, + }); + const newCustomer = + await autumnV2_2.customers.get(newCustomerId); + expectBalanceCorrect({ + customer: newCustomer, + featureId: TestFeature.Messages, + remaining: 200, + usage: 0, + planId: pro.id, + }); +}); + +test(`${chalk.yellowBright("plans.update disable_version: respects requested version")}`, async () => { + const customerId = "plan-in-place-update-version-v1"; + const pro = products.pro({ + id: "pro_in_place_update_version", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const autumnRpc = new AutumnRpcCli({ + secretKey: ctx.orgSecretKey, + version: ApiVersion.V2_1, + }); + + await autumnV1.products.update(pro.id, { + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + expect((await messagesEnt({ ctx, planId: pro.id, version: 1 }))?.allowance).toBe( + 100, + ); + expect((await messagesEnt({ ctx, planId: pro.id, version: 2 }))?.allowance).toBe( + 200, + ); + + await autumnRpc.plans.update< + ApiPlanV1, + Omit + >(pro.id, { + version: 1, + disable_version: true, + name: pro.name, + price: { amount: 20, interval: BillingInterval.Month }, + items: [ + { + feature_id: TestFeature.Messages, + included: 150, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + expect((await messagesEnt({ ctx, planId: pro.id, version: 1 }))?.allowance).toBe( + 150, + ); + expect((await messagesEnt({ ctx, planId: pro.id, version: 2 }))?.allowance).toBe( + 200, + ); +}); + +test(`${chalk.yellowBright("plans.update disable_version: UPDATE price-linked item keeps FK order valid")}`, async () => { + const customerId = "plan-in-place-update-priced-item"; + const pro = products.pro({ + id: "pro_in_place_update_priced_item", + items: [items.consumableMessages({ includedUsage: 0, price: 10 })], + }); + + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const autumnRpc = new AutumnRpcCli({ + secretKey: ctx.orgSecretKey, + version: ApiVersion.V2_1, + }); + const before = await snapshotCustomerState({ ctx, customerId }); + + await autumnRpc.plans.update(pro.id, { + disable_version: true, + price: { amount: 20, interval: BillingInterval.Month }, + items: [ + { + feature_id: TestFeature.Messages, + price: { + amount: 12, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + }, + ], + }); + + expect(await snapshotCustomerState({ ctx, customerId })).toBe(before); +}); diff --git a/server/tests/integration/crud/plans/update/in-place/utils/snapshotCustomerState.ts b/server/tests/integration/crud/plans/update/in-place/utils/snapshotCustomerState.ts new file mode 100644 index 000000000..3ef7a2d20 --- /dev/null +++ b/server/tests/integration/crud/plans/update/in-place/utils/snapshotCustomerState.ts @@ -0,0 +1,50 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { CusService } from "@/internal/customers/CusService.js"; + +/** + * Stable JSON view of a customer's billing state for byte-for-byte before/after + * comparison. Excludes timestamps / surrogate ids that churn on any write and + * keeps only what proves an existing customer's plan was left untouched. + */ +export const snapshotCustomerState = async ({ + ctx, + customerId, +}: { + ctx: AutumnContext; + customerId: string; +}): Promise => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + + const products = fullCustomer.customer_products + .map((cusProduct) => ({ + product_id: cusProduct.product_id, + status: cusProduct.status, + entity_id: cusProduct.entity_id ?? null, + trial_ends_at: cusProduct.trial_ends_at ?? null, + canceled_at: cusProduct.canceled_at ?? null, + scheduled_ids: [...(cusProduct.scheduled_ids ?? [])].sort(), + options: cusProduct.options, + entitlements: cusProduct.customer_entitlements + .map((cusEnt) => ({ + entitlement_id: cusEnt.entitlement_id, + balance: cusEnt.balance ?? null, + unlimited: cusEnt.unlimited ?? null, + next_reset_at: cusEnt.next_reset_at ?? null, + entities: cusEnt.entities ?? null, + })) + .sort((a, b) => a.entitlement_id.localeCompare(b.entitlement_id)), + prices: cusProduct.customer_prices + .map((cusPrice) => ({ price_id: cusPrice.price_id })) + .sort((a, b) => (a.price_id ?? "").localeCompare(b.price_id ?? "")), + })) + .sort( + (a, b) => + a.product_id.localeCompare(b.product_id) || + (a.entity_id ?? "").localeCompare(b.entity_id ?? ""), + ); + + return JSON.stringify(products); +}; diff --git a/server/tests/integration/db/invoice-line-items/get-by-customer-product-ids.test.ts b/server/tests/integration/db/invoice-line-items/get-by-customer-product-ids.test.ts new file mode 100644 index 000000000..b45cfe14e --- /dev/null +++ b/server/tests/integration/db/invoice-line-items/get-by-customer-product-ids.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { invoiceLineItems } from "@autumn/shared"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { eq } from "drizzle-orm"; +import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos/index.js"; + +test.concurrent("invoice line items: get by single customer product id", async () => { + const lineItemId = "invoice_li_get_by_cus_prod_ids"; + const customerProductId = "cus_prod_3EfbA8teNA8ColQSwRemt4BevN9"; + + await ctx.db.delete(invoiceLineItems).where(eq(invoiceLineItems.id, lineItemId)); + await ctx.db.insert(invoiceLineItems).values({ + id: lineItemId, + amount: 100, + amount_after_discounts: 100, + description: "Test line item", + direction: "charge", + customer_product_ids: [customerProductId], + }); + + try { + const rows = await invoiceLineItemRepo.getByCustomerProductIds({ + db: ctx.db, + customerProductIds: [customerProductId], + }); + + expect(rows.map((row) => row.id)).toContain(lineItemId); + } finally { + await ctx.db.delete(invoiceLineItems).where(eq(invoiceLineItems.id, lineItemId)); + } +}); diff --git a/server/tests/integration/external-psps/revenuecat-product-sync.test.ts b/server/tests/integration/external-psps/revenuecat-product-sync.test.ts new file mode 100644 index 000000000..27edbb1e0 --- /dev/null +++ b/server/tests/integration/external-psps/revenuecat-product-sync.test.ts @@ -0,0 +1,375 @@ +/** + * Tests for the on-demand RevenueCat product sync (per-product layer). + * + * Run in-process against the real DB (shared ctx, for the mapping row) with RC + * fetch mocked. Covered: + * - creates an RC product per app + UNIONS the minted store id into the mapping + * - sandbox does NOT call create_in_store; live DOES (with group name + duration) + * - existing manual mapping is preserved (union, never clobbered) + * - when the RC product already exists with a different name, the name is patched + */ + +import { + BillingInterval, + type FullProduct, + type Price, + PriceType, +} from "@autumn/shared"; +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import chalk from "chalk"; +import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; +import { syncProductToRevenueCat } from "@/external/revenueCat/sync/syncRevenueCatProducts"; +import type { RevenueCatApp } from "@/external/revenueCat/revenuecatTypes"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; + +const APPS: RevenueCatApp[] = [ + { + object: "app", + id: "app_ios", + name: "iOS", + type: "app_store", + project_id: "proj_test", + created_at: 0, + }, + { + object: "app", + id: "app_android", + name: "Android", + type: "play_store", + project_id: "proj_test", + created_at: 0, + }, +]; + +type FetchCall = { method: string; url: string; body: unknown }; +let fetchCalls: FetchCall[] = []; +let existingProducts: Array<{ + id: string; + app_id: string; + store_identifier: string; + display_name: string; +}> = []; +let productCounter = 0; +let mcpError = false; +let originalFetch: typeof fetch; + +const json = (b: unknown, status = 200) => + new Response(JSON.stringify(b), { + status, + headers: { "Content-Type": "application/json" }, + }); + +beforeEach(() => { + originalFetch = globalThis.fetch; + fetchCalls = []; + productCounter = 0; + mcpError = false; + existingProducts = []; + globalThis.fetch = mock(async (input: unknown, init?: RequestInit) => { + const url = input?.toString() ?? ""; + const path = url.split("?")[0]; + const method = (init?.method ?? "GET").toUpperCase(); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + fetchCalls.push({ method, url, body }); + + if (method === "GET" && path.endsWith("/products")) { + return json({ object: "list", items: existingProducts, next_page: null }); + } + if (method === "POST" && path.endsWith("/products")) { + productCounter += 1; + return json({ object: "product", id: `prod_${productCounter}` }, 201); + } + if (method === "POST" && path.includes("/create_in_store")) { + return json({ created_product: { id: "1" } }, 201); + } + if (path.startsWith("https://mcp.revenuecat.ai")) { + return json({ result: { isError: mcpError, content: [] } }); + } + if (method === "POST" && path.includes("/products/")) { + return json({ object: "product", id: "prod_x" }); + } + return json({}); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const rcCli = () => + initRevenuecatCli({ projectId: "proj_test", accessToken: "tok" }); + +const price = (interval: BillingInterval, amount = 15): Price => + ({ + config: { type: PriceType.Fixed, amount, interval, interval_count: 1 }, + }) as unknown as Price; + +const buildProduct = ( + id: string, + name: string, + group?: string, + amount = 15, +): FullProduct => + ({ + id, + name, + group: group ?? "", + prices: [price(BillingInterval.Month, amount)], + entitlements: [], + free_trial: null, + }) as unknown as FullProduct; + +const storeId = (planId: string) => `autumn.${ctx.env}.${ctx.org.id}.${planId}`; + +const getMappingIds = async (planId: string) => { + const rows = await RCMappingService.get({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + autumnProductId: planId, + }); + return rows[0]?.revenuecat_product_ids ?? []; +}; + +const cleanup = (planId: string) => + RCMappingService.delete({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + autumnProductId: planId, + }); + +test(`${chalk.yellowBright("rc sync: creates a product per app, unions the minted id, no create_in_store in sandbox")}`, async () => { + const planId = `rc-sync-create-${Date.now()}`; + await cleanup(planId); + + const result = await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: APPS, + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + const creates = fetchCalls.filter( + (c) => c.method === "POST" && c.url.split("?")[0].endsWith("/products"), + ); + expect(creates).toHaveLength(APPS.length); + for (const c of creates) { + expect(c.body).toMatchObject({ + store_identifier: storeId(planId), + type: "subscription", + display_name: "Pro", + }); + // real store apps: subscription params are NOT sent on create (RC rejects them) + expect((c.body as { subscription?: unknown }).subscription).toBeUndefined(); + } + expect(fetchCalls.some((c) => c.url.includes("/create_in_store"))).toBe(false); + // real stores own their prices — never call the MCP price tool + expect(fetchCalls.some((c) => c.url.startsWith("https://mcp.revenuecat.ai"))).toBe( + false, + ); + + expect(result.status).toBe("synced"); + expect(await getMappingIds(planId)).toContain(storeId(planId)); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: test_store app gets subscription params on create and no store push")}`, async () => { + const planId = `rc-sync-teststore-${Date.now()}`; + await cleanup(planId); + + const testStoreApps: RevenueCatApp[] = [ + { + object: "app", + id: "app_test", + name: "Test Store", + type: "test_store", + project_id: "proj_test", + created_at: 0, + }, + ]; + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: testStoreApps, + isLive: true, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + const create = fetchCalls.find( + (c) => c.method === "POST" && c.url.split("?")[0].endsWith("/products"), + ); + expect(create?.body).toMatchObject({ + type: "subscription", + subscription: { duration: "P1M" }, + }); + // simulated store is already usable — no create_in_store even on live + expect(fetchCalls.some((c) => c.url.includes("/create_in_store"))).toBe(false); + + // test-store price IS set via the RC MCP server (create-product-prices) + const priceCall = fetchCalls.find((c) => + c.url.startsWith("https://mcp.revenuecat.ai"), + ); + expect(priceCall).toBeDefined(); + const params = (priceCall?.body as { params?: { name?: string; arguments?: any } }) + ?.params; + expect(params?.name).toBe("create-product-prices"); + expect(params?.arguments).toMatchObject({ + project_id: "proj_test", + product_id: "prod_1", + prices: [{ amount_micros: 15_000_000 }], + }); + expect(params?.arguments.prices[0].currency).toMatch(/^[A-Z]{3}$/); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: live env pushes to the store with group name + duration enum")}`, async () => { + const planId = `rc-sync-live-${Date.now()}`; + await cleanup(planId); + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: APPS, + isLive: true, + projectId: "proj_test", + product: buildProduct(planId, "Pro", "Premium"), + }); + + const storePushes = fetchCalls.filter((c) => + c.url.includes("/create_in_store"), + ); + expect(storePushes).toHaveLength(APPS.length); + expect(storePushes[0].body).toEqual({ + store_information: { + duration: "ONE_MONTH", + subscription_group_name: "Autumn - Premium Group", + }, + }); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: unions into an existing manual mapping without clobbering it")}`, async () => { + const planId = `rc-sync-union-${Date.now()}`; + await cleanup(planId); + + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: ctx.env, + autumn_product_id: planId, + revenuecat_product_ids: ["com.legacy.manual.id"], + }, + }); + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: APPS, + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + const ids = await getMappingIds(planId); + expect(ids).toContain("com.legacy.manual.id"); + expect(ids).toContain(storeId(planId)); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: patches name when the RC product already exists with a different name")}`, async () => { + const planId = `rc-sync-rename-${Date.now()}`; + await cleanup(planId); + + existingProducts = APPS.map((app, i) => ({ + id: `existing_${i}`, + app_id: app.id, + store_identifier: storeId(planId), + display_name: "Old Name", + })); + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: APPS, + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + expect( + fetchCalls.filter( + (c) => c.method === "POST" && c.url.split("?")[0].endsWith("/products"), + ), + ).toHaveLength(0); + const updates = fetchCalls.filter( + (c) => c.method === "POST" && /\/products\/existing_\d+$/.test(c.url), + ); + expect(updates).toHaveLength(APPS.length); + expect(updates[0].body).toEqual({ display_name: "Pro" }); + + await cleanup(planId); +}); + +const testStoreApp: RevenueCatApp = { + object: "app", + id: "app_test", + name: "Test Store", + type: "test_store", + project_id: "proj_test", + created_at: 0, +}; + +test(`${chalk.yellowBright("rc sync: test_store plan with no base price (free) sets no MCP price")}`, async () => { + const planId = `rc-sync-noprice-${Date.now()}`; + await cleanup(planId); + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: [testStoreApp], + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Free", undefined, 0), // amount 0 → no base price + }); + + expect(fetchCalls.some((c) => c.url.startsWith("https://mcp.revenuecat.ai"))).toBe( + false, + ); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: MCP price failure is best-effort — sync still succeeds")}`, async () => { + const planId = `rc-sync-pricefail-${Date.now()}`; + await cleanup(planId); + mcpError = true; + + const result = await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: [testStoreApp], + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + // the MCP call was attempted, but a failure doesn't fail the sync + expect(fetchCalls.some((c) => c.url.startsWith("https://mcp.revenuecat.ai"))).toBe( + true, + ); + expect(result.status).toBe("synced"); + expect(result.apps?.[0].price).toBe("failed"); + + await cleanup(planId); +}); diff --git a/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts index 2e0be2e2e..20ae3a27e 100644 --- a/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts +++ b/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts @@ -62,6 +62,24 @@ type CustomerProductsUpdatedPayload = { const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345"; +const rcProMonthly = ({ id = "pro-monthly" }: { id?: string } = {}) => + products.base({ + id, + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 10 }), + ], + }); + +const rcProYearly = ({ id = "pro-yearly" }: { id?: string } = {}) => + products.base({ + id, + items: [ + items.monthlyMessages({ includedUsage: 1000 }), + items.annualPrice({ price: 1000 }), + ], + }); + // ─── Helpers ───────────────────────────────────────────────────────────────── const setupRevenueCatOrg = async () => { @@ -117,11 +135,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: initial purchase → scenario const customerId = "rc-webhook-initial-purchase"; const RC_PRO_MONTHLY_ID = "com.app.rcwh1_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -178,11 +192,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: renewal → scenario: renew") const customerId = "rc-webhook-renewal"; const RC_PRO_MONTHLY_ID = "com.app.rcwh2_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -258,15 +268,8 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: upgrade (monthly → yearly) const RC_PRO_MONTHLY_ID = "com.app.rcwh3_pro_monthly"; const RC_PRO_YEARLY_ID = "com.app.rcwh3_pro_yearly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); - const proYearly = products.proAnnual({ - id: "pro-yearly", - items: [items.monthlyMessages({ includedUsage: 1000 })], - }); + const proMonthly = rcProMonthly(); + const proYearly = rcProYearly(); await initScenario({ customerId, @@ -344,30 +347,26 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: upgrade (monthly → yearly) }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 4: Downgrade → scenario: downgrade -// Uses premium ($50/mo) → pro ($20/mo) so the price decrease is a genuine downgrade +// TEST 4: Downgrade (yearly → monthly) applies immediately for RevenueCat. +// RC is the payment source-of-truth, so the transition is forced immediate +// (plan_schedule: "immediate"): yearly is expired now and monthly is inserted +// active. The inserted product is cheaper than the expired one, so the insert +// scenario is "new" (not "upgrade"), and updated_product is the new monthly. // ═══════════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) → scenario: downgrade")}`, async () => { +test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (yearly → monthly) applies immediately → scenario: new")}`, async () => { const customerId = "rc-webhook-downgrade"; - const RC_PRO_ID = "com.app.rcwh4_pro"; - const RC_PREMIUM_ID = "com.app.rcwh4_premium"; + const RC_PRO_MONTHLY_ID = "com.app.rcwh4_pro_monthly"; + const RC_PRO_YEARLY_ID = "com.app.rcwh4_pro_yearly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - const premium = products.premium({ - id: "premium", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); + const proYearly = rcProYearly(); await initScenario({ customerId, setup: [ s.customer({ testClock: false, skipWebhooks: true }), - s.products({ list: [pro, premium] }), + s.products({ list: [proMonthly, proYearly] }), ], actions: [], }); @@ -378,8 +377,8 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) data: { org_id: ctx.org.id, env: AppEnv.Sandbox, - autumn_product_id: pro.id, - revenuecat_product_ids: [RC_PRO_ID], + autumn_product_id: proMonthly.id, + revenuecat_product_ids: [RC_PRO_MONTHLY_ID], }, }), RCMappingService.upsert({ @@ -387,8 +386,8 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) data: { org_id: ctx.org.id, env: AppEnv.Sandbox, - autumn_product_id: premium.id, - revenuecat_product_ids: [RC_PREMIUM_ID], + autumn_product_id: proYearly.id, + revenuecat_product_ids: [RC_PRO_YEARLY_ID], }, }), ]); @@ -399,9 +398,9 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) webhookSecret: RC_WEBHOOK_SECRET, }); - // First: initial purchase on premium ($50/mo) + // First: initial purchase on yearly ($1000/yr) await rcClient.initialPurchase({ - productId: RC_PREMIUM_ID, + productId: RC_PRO_YEARLY_ID, appUserId: customerId, originalTransactionId: "rcwh4_tx_001", }); @@ -415,9 +414,10 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) timeoutMs: 15000, }); - // Then: renewal to pro ($20/mo) — genuine downgrade + // Then: switch to monthly ($10/mo). For RC this applies immediately rather + // than scheduling a downgrade, so the inserted monthly product is active. await rcClient.renewal({ - productId: RC_PRO_ID, + productId: RC_PRO_MONTHLY_ID, appUserId: customerId, originalTransactionId: "rcwh4_tx_001", }); @@ -427,14 +427,15 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) predicate: (payload) => payload.type === "customer.products.updated" && payload.data?.customer?.id === customerId && - payload.data?.scenario === "downgrade", + payload.data?.scenario === "new" && + payload.data?.updated_product?.id === proMonthly.id, timeoutMs: 15000, }); expect(result).not.toBeNull(); const { data } = result!.payload; - expect(data.scenario).toBe("downgrade"); - expect(data.updated_product.id).toBe(pro.id); + expect(data.scenario).toBe("new"); + expect(data.updated_product.id).toBe(proMonthly.id); expect(data.customer.id).toBe(customerId); }); @@ -446,11 +447,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: cancellation → scenario: ca const customerId = "rc-webhook-cancel"; const RC_PRO_MONTHLY_ID = "com.app.rcwh5_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -525,11 +522,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: uncancellation → scenario: const customerId = "rc-webhook-uncancel"; const RC_PRO_MONTHLY_ID = "com.app.rcwh6_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -619,11 +612,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: billing issue → scenario: p const customerId = "rc-webhook-billing-issue"; const RC_PRO_MONTHLY_ID = "com.app.rcwh7_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -697,11 +686,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: expiration → scenario: expi const customerId = "rc-webhook-expire"; const RC_PRO_MONTHLY_ID = "com.app.rcwh8_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, diff --git a/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts index a779d8569..9b013343e 100644 --- a/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts +++ b/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts @@ -30,8 +30,9 @@ import { timeout } from "@tests/utils/genUtils"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; import { CusService } from "@/internal/customers/CusService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { OrgService } from "@/internal/orgs/OrgService"; @@ -44,6 +45,24 @@ import { TestFeature } from "@tests/setup/v2Features"; const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345"; +const rcProMonthly = ({ id = "pro-monthly" }: { id?: string } = {}) => + products.base({ + id, + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 10 }), + ], + }); + +const rcProYearly = ({ id = "pro-yearly" }: { id?: string } = {}) => + products.base({ + id, + items: [ + items.monthlyMessages({ includedUsage: 1000 }), + items.annualPrice({ price: 1000 }), + ], + }); + const setupRevenueCatOrg = async () => { if ( ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== @@ -69,6 +88,32 @@ const setupRevenueCatOrg = async () => { } }; +// This test reuses fixed customer + transaction ids. RC invoices upsert by stripe_id +// WITHOUT repointing internal_customer_id, so a prior run leaves orphaned invoice rows +// that make the freshly-created customer read 0 invoices. Clear those by stripe_id, and +// bust the customers' cached full-customer entries so the re-created customers are clean +// (s.deleteCustomer + s.customer in setup handle the DB rows). +const INVOICE_TEST_CUSTOMER_IDS = ["rc-invoices-1", "rc-invoices-nonrenewing-1"]; +const INVOICE_TEST_TX_IDS = [ + "rc3_tx_initial_001", + "rc3_tx_renewal_002", + "rc3_tx_nonrenewing_001", +]; + +const clearInvoiceTestData = async () => { + await ctx.db + .delete(invoices) + .where(inArray(invoices.stripe_id, INVOICE_TEST_TX_IDS)); + + for (const customerId of INVOICE_TEST_CUSTOMER_IDS) { + await deleteCachedFullCustomer({ + ctx, + customerId, + source: "revenuecat-test-cleanup", + }).catch(() => {}); + } +}; + // ═══════════════════════════════════════════════════════════════════════════════ // TEST 1: RevenueCat webhook lifecycle (purchase, upgrade, cancel, expire, add-on) // (from revenuecat-webhooks.test.ts) @@ -87,9 +132,8 @@ test.concurrent(`${chalk.yellowBright("revenuecat 1: webhook lifecycle")}`, asyn const RC_ADD_ON_ID = "com.app.rc1_add_on_pack"; // Autumn products - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ id: "pro-monthly", items: [messagesItem] }); - const proYearly = products.proAnnual({ id: "pro-yearly", items: [items.monthlyMessages({ includedUsage: 1000 })] }); + const proMonthly = rcProMonthly(); + const proYearly = rcProYearly(); const addOnPack = products.base({ id: "add-on", items: [items.lifetimeMessages({ includedUsage: 100 })], @@ -427,6 +471,10 @@ test.concurrent(`${chalk.yellowBright("revenuecat 2: customer migration v1 to v2 test.concurrent( `${chalk.yellowBright("revenuecat 3: writes invoice rows for INITIAL_PURCHASE / RENEWAL / NON_RENEWING_PURCHASE, refunds existing invoice for CANCELLATION-as-refund")}`, async () => { + // Clear stale invoices/customers from prior runs (fixed ids + invoice + // upsert-by-stripe_id leave orphans that otherwise make this read 0 invoices). + await clearInvoiceTestData(); + const customerId = "rc-invoices-1"; const nonRenewingCustomerId = "rc-invoices-nonrenewing-1"; @@ -448,6 +496,7 @@ test.concurrent( const { autumnV1, autumnV2_1 } = await initScenario({ customerId, setup: [ + s.deleteCustomer({ customerId }), s.customer({ testClock: false }), s.products({ list: [proMonthly, addOnPack] }), ], @@ -457,7 +506,10 @@ test.concurrent( // Initialize the second (non-renewing) customer in the same scenario context await initScenario({ customerId: nonRenewingCustomerId, - setup: [s.customer({ testClock: false })], + setup: [ + s.deleteCustomer({ customerId: nonRenewingCustomerId }), + s.customer({ testClock: false }), + ], actions: [], }); @@ -491,7 +543,10 @@ test.concurrent( // ─── Assertion 1: INITIAL_PURCHASE writes an invoice row ──────────────── const initialTxId = "rc3_tx_initial_001"; const initialPrice = 9.99; - const initialCurrency = "usd"; + // RevenueCat's `price` is normalized to USD; `currency` describes the + // purchase currency only. A non-USD purchase must still record total in + // USD with currency "usd" (regression: INR-labeled USD amounts). + const initialCurrency = "inr"; const initialPurchasedAt = Date.now(); expectWebhookSuccess( @@ -513,7 +568,7 @@ test.concurrent( const initialInvoiceV1 = v1Customer.invoices![0]!; expect(initialInvoiceV1.stripe_id).toBe(initialTxId); expect(initialInvoiceV1.total).toBe(initialPrice); - expect(initialInvoiceV1.currency).toBe(initialCurrency); + expect(initialInvoiceV1.currency).toBe("usd"); expect(initialInvoiceV1.status).toBe("paid"); // V5 fetch exposes processor_type @@ -529,7 +584,7 @@ test.concurrent( expect(initialInvoiceV5.processor_type).toBe(ProcessorType.RevenueCat); expect(initialInvoiceV5.stripe_id).toBe(initialTxId); expect(initialInvoiceV5.total).toBe(initialPrice); - expect(initialInvoiceV5.currency).toBe(initialCurrency); + expect(initialInvoiceV5.currency).toBe("usd"); expect(initialInvoiceV5.status).toBe("paid"); // ─── Assertion 2: RENEWAL with new transaction_id writes a second row ── @@ -654,5 +709,7 @@ test.concurrent( expect(inv.processor_type).toBe(ProcessorType.RevenueCat); } } + + await clearInvoiceTestData(); }, ); diff --git a/server/tests/integration/migrations/filters/none-filter.test.ts b/server/tests/integration/migrations/filters/none-filter.test.ts new file mode 100644 index 000000000..429ac9c6c --- /dev/null +++ b/server/tests/integration/migrations/filters/none-filter.test.ts @@ -0,0 +1,109 @@ +/** + * Integration test for the migration `$none` plan quantifier, end-to-end. + * + * Contract under test (raw filter is parsed through CustomerFilterSchema first — + * that parse layer is where the quantifier was previously stripped to `{}`): + * Behaviors: + * - parse({ plan: { $none: {} } }) -> customers with NO active plan only + * - parse({ plan: {} }) (implicit $some) -> customers with ANY active plan (complement) + * - parse({ plan: { $none: { plan_id: { $in: [X] } } }}) -> empty-inclusive "not on X": + * no-plan customers + customers on other plans, + * excluding plan X + * Side effects: none (read-only filter). + * + * Regression: before the arrayFilter union fix, CustomerFilterSchema.parse + * dropped `$none` -> `{}` (implicit $some), so `$none` matched "has any plan". + * That makes assertion 1 below count the complement instead of the no-plan set. + */ + +import { test, expect } from "bun:test"; +import chalk from "chalk"; +import { CustomerFilterSchema } from "@autumn/shared/api/migrations/filters/customerFilter.js"; +import { + countCustomers, + filterCustomers, + type CustomerRow, +} from "@/internal/migrations/v2/filters/customers/filterCustomers.js"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; + +async function collectIds( + gen: AsyncGenerator, +): Promise> { + const ids = new Set(); + for await (const batch of gen) { + for (const row of batch) if (row.id) ids.add(row.id); + } + return ids; +} + +test.concurrent( + `${chalk.yellowBright("migration filter $none: selects customers with no active plan")}`, + async () => { + // Unique per-run prefix so `search` scopes the shared org down to just + // these three customers (counts stay deterministic under concurrency). + const pfx = `none-flt-${Math.random().toString(36).slice(2, 8)}`; + const onX = `${pfx}-onx`; + const noPlan = `${pfx}-non`; + const onY = `${pfx}-ony`; + + const planX = products.base({ + id: `${pfx}-px`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const planY = products.base({ + id: `${pfx}-py`, + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { ctx } = await initScenario({ + customerId: onX, + setup: [ + s.customer({ testClock: false }), + s.otherCustomers([{ id: noPlan }, { id: onY }]), + s.products({ list: [planX, planY] }), + ], + actions: [ + s.billing.attach({ productId: planX.id }), + s.billing.attach({ productId: planY.id, customerId: onY }), + // `noPlan` intentionally attaches nothing. + ], + }); + + const noneEmpty = CustomerFilterSchema.parse({ plan: { $none: {} } }); + const hasAny = CustomerFilterSchema.parse({ plan: {} }); + const notOnX = CustomerFilterSchema.parse({ + plan: { $none: { plan_id: { $in: [planX.id] } } }, + }); + + // ── Assertion 1: $none empty -> only the no-plan customer ─────────────── + expect(await countCustomers({ ctx, filter: noneEmpty, search: pfx })).toBe( + 1, + ); + const noneIds = await collectIds( + filterCustomers({ ctx, filter: noneEmpty, search: pfx }), + ); + expect(noneIds.has(noPlan)).toBe(true); + expect(noneIds.has(onX)).toBe(false); + expect(noneIds.has(onY)).toBe(false); + + // ── Assertion 2: implicit $some (complement) -> the plan-bearing customers + expect(await countCustomers({ ctx, filter: hasAny, search: pfx })).toBe(2); + const anyIds = await collectIds( + filterCustomers({ ctx, filter: hasAny, search: pfx }), + ); + expect(anyIds.has(onX)).toBe(true); + expect(anyIds.has(onY)).toBe(true); + expect(anyIds.has(noPlan)).toBe(false); + + // ── Assertion 3: $none with inner plan_id -> empty-inclusive "not on X" ── + expect(await countCustomers({ ctx, filter: notOnX, search: pfx })).toBe(2); + const notOnXIds = await collectIds( + filterCustomers({ ctx, filter: notOnX, search: pfx }), + ); + expect(notOnXIds.has(noPlan)).toBe(true); + expect(notOnXIds.has(onY)).toBe(true); + expect(notOnXIds.has(onX)).toBe(false); + }, +); diff --git a/server/tests/integration/others/idempotency/idempotency-middleware.test.ts b/server/tests/integration/others/idempotency/idempotency-middleware.test.ts new file mode 100644 index 000000000..0e2199121 --- /dev/null +++ b/server/tests/integration/others/idempotency/idempotency-middleware.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; +import { ErrCode } from "@autumn/shared"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { Hono } from "hono"; +import { errorMiddleware } from "@/honoMiddlewares/errorMiddleware.js"; +import { idempotencyMiddleware } from "@/honoMiddlewares/idempotencyMiddleware.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; + +const buildApp = () => { + const app = new Hono(); + + app.use("*", async (c, next) => { + c.set("ctx", ctx); + await next(); + }); + app.use("*", idempotencyMiddleware); + + app.post("/success", (c) => c.json({ success: true })); + app.post("/failure", (c) => c.json({ success: false }, 500)); + + app.onError(errorMiddleware); + + return app; +}; + +test.concurrent( + "idempotency middleware keeps keys for 200 responses", + async () => { + const app = buildApp(); + const idempotencyKey = `idem-success-${Date.now().toString(36)}`; + + const first = await app.request("http://localhost/success", { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + }); + const second = await app.request("http://localhost/success", { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + }); + const secondBody = await second.json(); + + expect(first.status).toBe(200); + expect(second.status).toBe(409); + expect(secondBody.code).toBe(ErrCode.DuplicateIdempotencyKey); + }, +); + +test.concurrent( + "idempotency middleware releases keys for 500 responses", + async () => { + const app = buildApp(); + const idempotencyKey = `idem-failure-${Date.now().toString(36)}`; + + const first = await app.request("http://localhost/failure", { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + }); + const second = await app.request("http://localhost/failure", { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + }); + + expect(first.status).toBe(500); + expect(second.status).toBe(500); + }, +); diff --git a/server/tests/integration/platform/link-revenuecat.test.ts b/server/tests/integration/platform/link-revenuecat.test.ts new file mode 100644 index 000000000..e58e7a43c --- /dev/null +++ b/server/tests/integration/platform/link-revenuecat.test.ts @@ -0,0 +1,160 @@ +/** + * Integration coverage for the platform RevenueCat link flow against the live + * server (atmn-srv on :8080). + * + * `POST /v1/platform.link_revenuecat` makes NO RevenueCat HTTP call — it only + * builds the authorize URL and writes OAuth state to Redis — so the request + * half is fully testable here. The callback's happy path (real token exchange + + * project creation) needs a real OAuth code and stays unit-tested; only its + * deterministic guard branches (which return before any RC call) are exercised. + */ + +import { beforeAll, describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import defaultCtx, { + type TestContext, +} from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { + consumeOAuthState, + generateOAuthState, +} from "@/internal/platform/platformBeta/utils/oauthStateUtils.js"; + +const SERVER_BASE = ( + process.env.AUTUMN_TEST_BASE_URL ?? "http://localhost:8080" +).replace(/\/$/, ""); + +const REDIRECT_URL = "https://platform.example.com/callback/revenuecat"; +const REDIRECT_STATUSES = [301, 302, 303, 307, 308]; + +let subCtx: TestContext; +let bareSlug: string; +let masterAutumn: AutumnInt; + +beforeAll(async () => { + const { ctx } = await initScenario({ + setup: [s.platform.create({})], + actions: [], + }); + subCtx = ctx; + // Must pass the bare slug: validatePlatformOrg re-appends `|`. + bareSlug = ctx.org.slug.split("|")[0]; + masterAutumn = new AutumnInt({ secretKey: defaultCtx.orgSecretKey }); +}, 120_000); + +describe("POST /v1/platform.link_revenuecat", () => { + test("returns an RC authorize URL and persists the OAuth state", async () => { + const projectName = `atmn-it-${Math.random().toString(36).slice(2, 8)}`; + + const res = (await masterAutumn.post("/platform.link_revenuecat", { + organization_slug: bareSlug, + env: "test", + project_name: projectName, + redirect_url: REDIRECT_URL, + })) as { oauth_url: string }; + + expect( + res.oauth_url.startsWith("https://api.revenuecat.com/oauth2/authorize"), + ).toBe(true); + + const url = new URL(res.oauth_url); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBeTruthy(); + expect(url.searchParams.get("code_challenge")).toBeTruthy(); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("scope")).toBeTruthy(); + + const stateKey = url.searchParams.get("state"); + expect(stateKey).toBeTruthy(); + + // State was written by the server into the shared Redis; read it back. + const state = await consumeOAuthState({ stateKey: stateKey as string }); + expect(state).not.toBeNull(); + expect(state?.env).toBe(AppEnv.Sandbox); + expect(state?.master_org_id).toBe(defaultCtx.org.id); + expect(state?.provider).toBe("revenuecat"); + expect(state?.revenuecat_project_name).toBe(projectName); + expect(state?.redirect_uri).toBe(REDIRECT_URL); + expect(state?.organization_slug).toBe(subCtx.org.slug); + }); + + test("rejects when RevenueCat is already linked for the env", async () => { + // Mark the LIVE env linked, keeping sandbox free so this is order-independent. + await OrgService.update({ + db: subCtx.db, + orgId: subCtx.org.id, + updates: { + processor_configs: { + ...subCtx.org.processor_configs, + revenuecat: { + ...(subCtx.org.processor_configs?.revenuecat ?? {}), + oauth: { + access_token: "enc", + refresh_token: "enc", + expires_at: Date.now() + 3_600_000, + }, + }, + }, + }, + }); + + await expect( + masterAutumn.post("/platform.link_revenuecat", { + organization_slug: bareSlug, + env: "live", + project_name: "Already Linked", + redirect_url: REDIRECT_URL, + }), + ).rejects.toThrow(); + }); + + test("rejects an org not owned by the master org", async () => { + await expect( + masterAutumn.post("/platform.link_revenuecat", { + organization_slug: `atmn-it-missing-${Math.random().toString(36).slice(2, 8)}`, + env: "sandbox", + project_name: "Orphan", + redirect_url: REDIRECT_URL, + }), + ).rejects.toThrow(); + }); +}); + +describe("GET /revenuecat/oauth_callback (guard branches)", () => { + test("redirects with error=invalid_state for an unknown state", async () => { + const res = await fetch( + `${SERVER_BASE}/revenuecat/oauth_callback?code=x&state=missing-${Math.random().toString(36).slice(2)}`, + { redirect: "manual" }, + ); + + expect(REDIRECT_STATUSES).toContain(res.status); + expect(res.headers.get("location") ?? "").toContain("error=invalid_state"); + }); + + test("platform flow: redirects org_permission_denied when master_org_id mismatches", async () => { + // Real state, but a master_org_id that does not own subCtx's org → the + // callback returns at the permission check, before any RevenueCat call. + const stateKey = await generateOAuthState({ + organizationSlug: subCtx.org.slug, + env: AppEnv.Sandbox, + redirectUri: REDIRECT_URL, + masterOrgId: "org_not_the_owner", + codeVerifier: "test-verifier", + provider: "revenuecat", + revenuecatProjectName: "Mismatch Project", + }); + + const res = await fetch( + `${SERVER_BASE}/revenuecat/oauth_callback?code=x&state=${stateKey}`, + { redirect: "manual" }, + ); + + expect(REDIRECT_STATUSES).toContain(res.status); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("success=false"); + expect(location).toContain("provider=revenuecat"); + expect(location).toContain("message=org_permission_denied"); + }); +}); diff --git a/server/tests/integration/scopes/scope-403.test.ts b/server/tests/integration/scopes/scope-403.test.ts index 37263f265..577bdb35a 100644 --- a/server/tests/integration/scopes/scope-403.test.ts +++ b/server/tests/integration/scopes/scope-403.test.ts @@ -1355,6 +1355,51 @@ const ROUTES = [ needsScopes: true, isWebhookExempt: false, }, + { + handlerName: "handleLinkRevenueCat", + handlerFile: + "src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts", + method: "POST", + path: "/v1/platform.link_revenuecat", + style: "RPC", + group: "v1/platform", + mountChain: ["/v1", "", "", "/platform.link_revenuecat"], + sourceRouterFile: + "src/internal/platform/platformBeta/platformRpcRouter.ts", + routeKind: "createRoute", + needsScopes: true, + isWebhookExempt: false, + }, + { + handlerName: "handleSyncRevenueCat", + handlerFile: + "src/internal/platform/platformBeta/handlers/handleSyncRevenueCat.ts", + method: "POST", + path: "/v1/platform.sync_revenuecat", + style: "RPC", + group: "v1/platform", + mountChain: ["/v1", "", "", "/platform.sync_revenuecat"], + sourceRouterFile: + "src/internal/platform/platformBeta/platformRpcRouter.ts", + routeKind: "createRoute", + needsScopes: true, + isWebhookExempt: false, + }, + { + handlerName: "handleGetRevenueCatKeys", + handlerFile: + "src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts", + method: "POST", + path: "/v1/platform.get_revenuecat_keys", + style: "RPC", + group: "v1/platform", + mountChain: ["/v1", "", "", "/platform.get_revenuecat_keys"], + sourceRouterFile: + "src/internal/platform/platformBeta/platformRpcRouter.ts", + routeKind: "createRoute", + needsScopes: true, + isWebhookExempt: false, + }, { handlerName: "handleCreateSchedule", handlerFile: "src/internal/billing/v2/handlers/handleCreateSchedule.ts", @@ -4247,6 +4292,27 @@ const SCOPE_DECISIONS: Record< shape: "array", decidedAt: "2026-04-24T15:32:37.301Z", }, + "POST|/v1/platform.link_revenuecat|handleLinkRevenueCat": { + decision: "decided", + scopes: ["platform:write"], + shape: "array", + note: "platform RPC route — write", + decidedAt: "2026-06-01T00:00:00.000Z", + }, + "POST|/v1/platform.sync_revenuecat|handleSyncRevenueCat": { + decision: "decided", + scopes: ["platform:write"], + shape: "array", + note: "platform RPC route — write", + decidedAt: "2026-06-01T00:00:00.000Z", + }, + "POST|/v1/platform.get_revenuecat_keys|handleGetRevenueCatKeys": { + decision: "decided", + scopes: ["platform:write"], + shape: "array", + note: "platform RPC route — write", + decidedAt: "2026-06-01T00:00:00.000Z", + }, "POST|/v1/billing.create_schedule|handleCreateSchedule": { decision: "decided", scopes: ["billing:write"], diff --git a/server/tests/integration/utils/expectBalanceCorrect.ts b/server/tests/integration/utils/expectBalanceCorrect.ts index da72149cc..baed0fc22 100644 --- a/server/tests/integration/utils/expectBalanceCorrect.ts +++ b/server/tests/integration/utils/expectBalanceCorrect.ts @@ -8,10 +8,10 @@ import { type ResetInterval, } from "@autumn/shared"; -const roundTo8Dp = (value: number) => - Math.round(value * 1e8) / 1e8; +const roundTo8Dp = (value: number) => Math.round(value * 1e8) / 1e8; type BucketExpectation = { + granted?: number; included_grant?: number; prepaid_grant?: number; remaining?: number; @@ -28,6 +28,7 @@ type BreakdownExpectation = Partial>; export const expectBalanceCorrect = ({ customer, featureId, + granted, remaining, planId, usage, @@ -35,10 +36,12 @@ export const expectBalanceCorrect = ({ toleranceMs = TEN_MINUTES_MS, breakdown, rollovers, + positiveRolloverCount, }: { customer: ApiCustomerV5 | ApiEntityV2; featureId: string; - remaining: number; + granted?: number; + remaining?: number; planId?: string | null; usage?: number; nextResetAt?: number | null; @@ -46,10 +49,18 @@ export const expectBalanceCorrect = ({ breakdown?: BreakdownExpectation; /** Expected rollovers in order (oldest first). Only specified fields are checked. */ rollovers?: Partial[]; + positiveRolloverCount?: number; }) => { const balance = customer.balances[featureId]; expect(balance).toBeDefined(); - expect(roundTo8Dp(balance.remaining)).toBe(roundTo8Dp(remaining)); + + if (typeof granted !== "undefined") { + expect(roundTo8Dp(balance.granted)).toBe(roundTo8Dp(granted)); + } + + if (typeof remaining !== "undefined") { + expect(roundTo8Dp(balance.remaining)).toBe(roundTo8Dp(remaining)); + } if (typeof planId !== "undefined") { expect(balance.breakdown?.[0]?.plan_id ?? null).toBe(planId); @@ -92,7 +103,10 @@ export const expectBalanceCorrect = ({ (candidateBucket) => candidateBucket.reset?.interval === key, ); - expect(bucket).toBeDefined(); + expect( + bucket, + `Missing balance bucket ${key}: ${JSON.stringify(buckets)}`, + ).toBeDefined(); expect(bucket).toMatchObject(expectation as BucketExpectation); } } @@ -104,4 +118,11 @@ export const expectBalanceCorrect = ({ expect(actual![i]).toMatchObject(rollovers[i]); } } + + if (typeof positiveRolloverCount !== "undefined") { + const actual = balance.rollovers ?? []; + expect(actual.filter((item) => item.balance > 0).length).toBe( + positiveRolloverCount, + ); + } }; diff --git a/server/tests/integration/utils/getBalanceBucket.ts b/server/tests/integration/utils/getBalanceBucket.ts new file mode 100644 index 000000000..0152973cb --- /dev/null +++ b/server/tests/integration/utils/getBalanceBucket.ts @@ -0,0 +1,52 @@ +import type { + ApiCustomerV5, + ApiEntityV2, + BillingMethod, + ResetInterval, +} from "@autumn/shared"; + +export type BalanceSubject = ApiCustomerV5 | ApiEntityV2; +export type BalanceBucket = NonNullable< + ApiCustomerV5["balances"][string]["breakdown"] +>[number]; + +export const getBalanceBuckets = ({ + subject, + featureId, +}: { + subject: BalanceSubject; + featureId: string; +}): BalanceBucket[] => subject.balances[featureId]?.breakdown ?? []; + +export const getBalanceBucket = ({ + subject, + featureId, + planId, + resetInterval, + billingMethod, + includedGrant, +}: { + subject: BalanceSubject; + featureId: string; + planId?: string; + resetInterval?: ResetInterval | null; + billingMethod?: BillingMethod; + includedGrant?: number; +}) => { + for (const bucket of getBalanceBuckets({ subject, featureId })) { + if (planId && bucket.plan_id !== planId) continue; + if (resetInterval === null && bucket.reset !== null) continue; + if (resetInterval && bucket.reset?.interval !== resetInterval) continue; + if (billingMethod && bucket.price?.billing_method !== billingMethod) continue; + if ( + typeof includedGrant !== "undefined" && + bucket.included_grant !== includedGrant + ) { + continue; + } + + return bucket; + } + + throw new Error(`Expected balance bucket for feature ${featureId}`); +}; diff --git a/server/tests/scenarios/attach/prepaid-consumable-rollover-scenario.test.ts b/server/tests/scenarios/attach/prepaid-consumable-rollover-scenario.test.ts index e70b903ef..d7444c7c1 100644 --- a/server/tests/scenarios/attach/prepaid-consumable-rollover-scenario.test.ts +++ b/server/tests/scenarios/attach/prepaid-consumable-rollover-scenario.test.ts @@ -1,13 +1,14 @@ import { test } from "bun:test"; -import { RolloverExpiryDurationType } from "@autumn/shared"; +import { type ApiCustomerV5, RolloverExpiryDurationType } from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; import { constructArrearItem, constructPrepaidItem, } from "@/utils/scriptUtils/constructItem"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; -import { TestFeature } from "@tests/setup/v2Features"; /** * Scenario: Prepaid + Consumable messages on the same plan, both with rollovers. @@ -48,7 +49,7 @@ test(`${chalk.yellowBright("scenario: prepaid + consumable messages with rollove items: [prepaidMessages, consumableMessages], }); - await initScenario({ + const { autumnV2_2 } = await initScenario({ customerId: "combo-rollover", setup: [ s.customer({ paymentMethod: "success" }), @@ -63,4 +64,13 @@ test(`${chalk.yellowBright("scenario: prepaid + consumable messages with rollove s.advanceToNextInvoice(), ], }); + + const customer = + await autumnV2_2.customers.get("combo-rollover"); + + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + positiveRolloverCount: 2, + }); }); diff --git a/server/tests/scenarios/migrations/dense-old-versions-scenario.test.ts b/server/tests/scenarios/migrations/dense-old-versions-scenario.test.ts new file mode 100644 index 000000000..5524d5a9b --- /dev/null +++ b/server/tests/scenarios/migrations/dense-old-versions-scenario.test.ts @@ -0,0 +1,72 @@ +import { test } from "bun:test"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +/** + * Migration setup: one old version with many customers, another old version + * with a smaller group, and a latest version with no customers. + * + * v1 100 messages → cus migdense-v1-1..migdense-v1-6 + * v2 250 messages + credits → cus migdense-v2-1..migdense-v2-3 + * v3 500 messages + credits + admin (latest, no customer) + */ +test(`${chalk.yellowBright("migration-setup: dense old versions")}`, async () => { + const team = products.base({ + id: "team", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const v1Customers = Array.from({ length: 6 }, (_, i) => `migdense-v1-${i + 1}`); + const v2Customers = Array.from({ length: 3 }, (_, i) => `migdense-v2-${i + 1}`); + + const { autumnV1 } = await initScenario({ + customerId: v1Customers[0], + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [team], prefix: "migdense" }), + s.otherCustomers( + [...v1Customers.slice(1), ...v2Customers].map((id) => ({ + id, + paymentMethod: "success", + })), + ), + ], + actions: [s.billing.attach({ productId: "team" })], + }); + + for (const customerId of v1Customers.slice(1)) { + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: team.id, + }); + } + + await autumnV1.products.update(team.id, { + items: [ + items.monthlyMessages({ includedUsage: 250 }), + items.monthlyCredits({ includedUsage: 50 }), + ], + }); + + for (const customerId of v2Customers) { + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: team.id, + }); + } + + await autumnV1.products.update(team.id, { + items: [ + items.monthlyMessages({ includedUsage: 500 }), + items.monthlyCredits({ includedUsage: 100 }), + items.adminRights(), + ], + }); + + console.log( + chalk.green( + `[migration-setup] plan "${team.id}" has v1-v3. Six migdense-v1-* customers sit on v1; three migdense-v2-* customers sit on v2; latest is v3.`, + ), + ); +}); diff --git a/server/tests/scenarios/migrations/free-plan-versions-scenario.test.ts b/server/tests/scenarios/migrations/free-plan-versions-scenario.test.ts new file mode 100644 index 000000000..561a45ebe --- /dev/null +++ b/server/tests/scenarios/migrations/free-plan-versions-scenario.test.ts @@ -0,0 +1,63 @@ +import { test } from "bun:test"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +/** + * Migration setup: free plan with feature-only versions (no billing changes). + * + * v1 100 messages → cus migfree-v1 + * v2 200 messages → cus migfree-v2 + * v3 200 messages + 50 credits → cus migfree-v3 + * v4 500 messages + 100 credits + admin (latest, no customer) + * + * All changes are entitlement-only, so migrations here exercise the + * no-billing-changes (DB-only) path. + */ +test(`${chalk.yellowBright("migration-setup: free plan multi-version (no billing)")}`, async () => { + const free = products.base({ + id: "starter", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1 } = await initScenario({ + customerId: "migfree-v1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [free], prefix: "migfree" }), + s.otherCustomers([ + { id: "migfree-v2", paymentMethod: "success" }, + { id: "migfree-v3", paymentMethod: "success" }, + ]), + ], + actions: [s.billing.attach({ productId: "starter" })], + }); + + await autumnV1.products.update(free.id, { + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + await autumnV1.billing.attach({ customer_id: "migfree-v2", product_id: free.id }); + + await autumnV1.products.update(free.id, { + items: [ + items.monthlyMessages({ includedUsage: 200 }), + items.monthlyCredits({ includedUsage: 50 }), + ], + }); + await autumnV1.billing.attach({ customer_id: "migfree-v3", product_id: free.id }); + + await autumnV1.products.update(free.id, { + items: [ + items.monthlyMessages({ includedUsage: 500 }), + items.monthlyCredits({ includedUsage: 100 }), + items.adminRights(), + ], + }); + + console.log( + chalk.green( + `[migration-setup] plan "${free.id}" has v1-v4. Customers migfree-v1..migfree-v3 sit on v1..v3; latest is v4.`, + ), + ); +}); diff --git a/server/tests/scenarios/migrations/paid-plan-versions-scenario.test.ts b/server/tests/scenarios/migrations/paid-plan-versions-scenario.test.ts new file mode 100644 index 000000000..98c95fbd8 --- /dev/null +++ b/server/tests/scenarios/migrations/paid-plan-versions-scenario.test.ts @@ -0,0 +1,77 @@ +import { test } from "bun:test"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +/** + * Migration setup: paid plan with many versions + a customer on each old version. + * + * v1 $20/mo · 100 messages → cus migpaid-v1 + * v2 $30/mo · 200 messages → cus migpaid-v2 + * v3 $40/mo · 300 messages → cus migpaid-v3 + * v4 $50/mo · 500 messages → cus migpaid-v4 + * v5 $60/mo · 1000 messages (latest, no customer) + * + * Gives you real customers stranded on v1-v4 to migrate forward. + */ +test(`${chalk.yellowBright("migration-setup: paid plan multi-version")}`, async () => { + const pro = products.base({ + id: "pro", + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 100 }), + ], + }); + + const { autumnV1 } = await initScenario({ + customerId: "migpaid-v1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro], prefix: "migpaid" }), + s.otherCustomers([ + { id: "migpaid-v2", paymentMethod: "success" }, + { id: "migpaid-v3", paymentMethod: "success" }, + { id: "migpaid-v4", paymentMethod: "success" }, + ]), + ], + actions: [s.billing.attach({ productId: "pro" })], + }); + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 30 }), + items.monthlyMessages({ includedUsage: 200 }), + ], + }); + await autumnV1.billing.attach({ customer_id: "migpaid-v2", product_id: pro.id }); + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 40 }), + items.monthlyMessages({ includedUsage: 300 }), + ], + }); + await autumnV1.billing.attach({ customer_id: "migpaid-v3", product_id: pro.id }); + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 50 }), + items.monthlyMessages({ includedUsage: 500 }), + ], + }); + await autumnV1.billing.attach({ customer_id: "migpaid-v4", product_id: pro.id }); + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 60 }), + items.monthlyMessages({ includedUsage: 1000 }), + ], + }); + + console.log( + chalk.green( + `[migration-setup] plan "${pro.id}" has v1-v5. Customers migpaid-v1..migpaid-v4 sit on v1..v4; latest is v5.`, + ), + ); +}); diff --git a/server/tests/scenarios/migrations/prepaid-plan-versions-scenario.test.ts b/server/tests/scenarios/migrations/prepaid-plan-versions-scenario.test.ts new file mode 100644 index 000000000..1a41d78b1 --- /dev/null +++ b/server/tests/scenarios/migrations/prepaid-plan-versions-scenario.test.ts @@ -0,0 +1,74 @@ +import { test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +/** + * Migration setup: paid plan with prepaid messages across versions. + * Each customer buys a different prepaid quantity so you can verify + * quantity preservation when migrating. + * + * v1 $20/mo · prepaid 100/pack @ $10, 0 incl → cus migprepaid-v1 (qty 200) + * v2 $20/mo · prepaid 100/pack @ $8, 100 incl → cus migprepaid-v2 (qty 300) + * v3 $20/mo · prepaid 100/pack @ $8, 200 incl + admin (latest, no customer) + */ +test(`${chalk.yellowBright("migration-setup: prepaid plan multi-version")}`, async () => { + const scale = products.base({ + id: "scale", + items: [ + items.monthlyPrice({ price: 20 }), + items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 10 }), + ], + }); + + const { autumnV1 } = await initScenario({ + customerId: "migprepaid-v1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [scale], prefix: "migprepaid" }), + s.otherCustomers([{ id: "migprepaid-v2", paymentMethod: "success" }]), + ], + actions: [ + s.billing.attach({ + productId: "scale", + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + ], + }); + + await autumnV1.products.update(scale.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 8, + }), + ], + }); + await autumnV1.billing.attach({ + customer_id: "migprepaid-v2", + product_id: scale.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + }); + + await autumnV1.products.update(scale.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.prepaidMessages({ + includedUsage: 200, + billingUnits: 100, + price: 8, + }), + items.adminRights(), + ], + }); + + console.log( + chalk.green( + `[migration-setup] plan "${scale.id}" has v1-v3. migprepaid-v1 (qty 200) on v1, migprepaid-v2 (qty 300) on v2; latest is v3.`, + ), + ); +}); diff --git a/server/tests/scenarios/migrations/usage-plan-versions-scenario.test.ts b/server/tests/scenarios/migrations/usage-plan-versions-scenario.test.ts new file mode 100644 index 000000000..8151d8a15 --- /dev/null +++ b/server/tests/scenarios/migrations/usage-plan-versions-scenario.test.ts @@ -0,0 +1,67 @@ +import { test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +/** + * Migration setup: paid plan with consumable (pay-per-use) messages across + * versions, with tracked usage so you can verify usage carry-over on migrate. + * + * v1 $100/mo · 500 incl, $0.10 overage → cus migusage-v1 (used 600) + * v2 $100/mo · 1000 incl, $0.08 overage → cus migusage-v2 (used 1200) + * v3 $100/mo · 2000 incl, $0.05 overage + admin (latest, no customer) + */ +test(`${chalk.yellowBright("migration-setup: usage plan multi-version")}`, async () => { + const growth = products.base({ + id: "growth", + items: [ + items.monthlyPrice({ price: 100 }), + items.consumableMessages({ includedUsage: 500, price: 0.1 }), + ], + }); + + const { autumnV1 } = await initScenario({ + customerId: "migusage-v1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [growth], prefix: "migusage" }), + s.otherCustomers([{ id: "migusage-v2", paymentMethod: "success" }]), + ], + actions: [ + s.billing.attach({ productId: "growth" }), + s.track({ featureId: TestFeature.Messages, value: 600, timeout: 2000 }), + ], + }); + + await autumnV1.products.update(growth.id, { + items: [ + items.monthlyPrice({ price: 100 }), + items.consumableMessages({ includedUsage: 1000, price: 0.08 }), + ], + }); + await autumnV1.billing.attach({ + customer_id: "migusage-v2", + product_id: growth.id, + }); + await autumnV1.track({ + customer_id: "migusage-v2", + feature_id: TestFeature.Messages, + value: 1200, + }); + + await autumnV1.products.update(growth.id, { + items: [ + items.monthlyPrice({ price: 100 }), + items.consumableMessages({ includedUsage: 2000, price: 0.05 }), + items.adminRights(), + ], + }); + + console.log( + chalk.green( + `[migration-setup] plan "${growth.id}" has v1-v3. migusage-v1 (used 600) on v1, migusage-v2 (used 1200) on v2; latest is v3.`, + ), + ); +}); diff --git a/server/tests/scenarios/migrations/users-usage-scenario.test.ts b/server/tests/scenarios/migrations/users-usage-scenario.test.ts new file mode 100644 index 000000000..1ea5fcba5 --- /dev/null +++ b/server/tests/scenarios/migrations/users-usage-scenario.test.ts @@ -0,0 +1,47 @@ +import { test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +/** + * Migration setup: users entitlement with existing usage. + * + * v1 $20/mo · 5 included users → cus migusers-v1 (used 4) + * v2 $20/mo · 10 included users (latest, no customer) + */ +test(`${chalk.yellowBright("migration-setup: users included with usage")}`, async () => { + const team = products.base({ + id: "team-users", + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyUsers({ includedUsage: 5 }), + ], + }); + + const { autumnV1 } = await initScenario({ + customerId: "migusers-v1", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [team], prefix: "migusers" }), + ], + actions: [ + s.billing.attach({ productId: team.id }), + s.track({ featureId: TestFeature.Users, value: 4, timeout: 2000 }), + ], + }); + + await autumnV1.products.update(team.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyUsers({ includedUsage: 10 }), + ], + }); + + console.log( + chalk.green( + `[migration-setup] plan "${team.id}" has v1-v2. migusers-v1 is on v1 with 5 users included and 4 users used; latest is v2.`, + ), + ); +}, 20_000); diff --git a/server/tests/unit/analytics/period-grid-timezone.test.ts b/server/tests/unit/analytics/period-grid-timezone.test.ts new file mode 100644 index 000000000..0a344b19d --- /dev/null +++ b/server/tests/unit/analytics/period-grid-timezone.test.ts @@ -0,0 +1,74 @@ +// generateAllPeriods must build the day/month grid in the viewer's timezone so +// it lines up with the pipe's toStartOfDay(hour, tz) buckets; a UTC grid drops +// the newest local day for non-UTC viewers. +// Ref: tickets/ANALYTICS_TIMEZONE_BUCKET_OFFSET.md + +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { generateAllPeriods } from "@/internal/analytics/actions/aggregate.js"; + +// Window expressed in UTC wall-clock (what calculateDateRange produces and the +// Tinybird pipe filters `hour` on). 2026-06-05 03:00 UTC is still 2026-06-04 +// 23:00 in America/New_York (EDT, UTC-4) -> the viewer's "today" is Jun 4. +const START_UTC = "2026-05-29 03:00:00"; +const END_UTC = "2026-06-05 03:00:00"; + +test(`${chalk.yellowBright( + "analytics period grid: non-UTC viewer's latest day labeled by local calendar day", +)}`, () => { + const periods = generateAllPeriods({ + startDate: START_UTC, + endDate: END_UTC, + binSize: "day", + timezone: "America/New_York", + }); + + // The pipe buckets the live "today" data into the viewer's local day + // (Jun 4 in New York). The grid's newest bucket must match that string, + // not the UTC day (Jun 5). + expect(periods[periods.length - 1]).toBe("2026-06-04 00:00:00"); + // And the earliest bucket should be the viewer's local start day, not the + // UTC start day. + expect(periods[0]).toBe("2026-05-28 00:00:00"); +}); + +test(`${chalk.yellowBright( + "analytics period grid: UTC viewer unchanged (no regression)", +)}`, () => { + const periods = generateAllPeriods({ + startDate: START_UTC, + endDate: END_UTC, + binSize: "day", + timezone: "UTC", + }); + + expect(periods[0]).toBe("2026-05-29 00:00:00"); + expect(periods[periods.length - 1]).toBe("2026-06-05 00:00:00"); +}); + +// Spot-check the acceptance-criteria zones at one instant just past UTC +// midnight (2026-06-05 02:00 UTC). West-of-UTC viewers are still on Jun 4 +// locally; UTC / UTC+1 viewers have rolled to Jun 5. +const BOUNDARY_END_UTC = "2026-06-05 02:00:00"; +const BOUNDARY_START_UTC = "2026-06-01 02:00:00"; + +const zoneCases: { timezone: string; expectedLatest: string }[] = [ + { timezone: "America/Los_Angeles", expectedLatest: "2026-06-04 00:00:00" }, + { timezone: "America/New_York", expectedLatest: "2026-06-04 00:00:00" }, + { timezone: "UTC", expectedLatest: "2026-06-05 00:00:00" }, + { timezone: "Europe/London", expectedLatest: "2026-06-05 00:00:00" }, +]; + +for (const { timezone, expectedLatest } of zoneCases) { + test(`${chalk.yellowBright( + `analytics period grid: latest local day for ${timezone}`, + )}`, () => { + const periods = generateAllPeriods({ + startDate: BOUNDARY_START_UTC, + endDate: BOUNDARY_END_UTC, + binSize: "day", + timezone, + }); + expect(periods[periods.length - 1]).toBe(expectedLatest); + }); +} diff --git a/server/tests/unit/auth/atmnOAuthClients.test.ts b/server/tests/unit/auth/atmnOAuthClients.test.ts new file mode 100644 index 000000000..afa752f3a --- /dev/null +++ b/server/tests/unit/auth/atmnOAuthClients.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { isAtmnOAuthClientRecord } from "@/internal/auth/oauth/atmnOAuthClients.js"; + +describe("isAtmnOAuthClientRecord", () => { + test("does not classify arbitrary metadata values as atmn", () => { + expect( + isAtmnOAuthClientRecord({ + clientId: "client_123", + name: "Third Party App", + metadata: { description: "connects to atmn projects" }, + }), + ).toBe(false); + }); + + test("classifies explicit atmn metadata and names", () => { + expect( + isAtmnOAuthClientRecord({ + clientId: "client_123", + name: "Third Party App", + metadata: { kind: "atmn" }, + }), + ).toBe(true); + + expect( + isAtmnOAuthClientRecord({ + clientId: "client_123", + name: "atmn", + }), + ).toBe(true); + }); +}); diff --git a/server/tests/unit/auth/oauthApiKeyRepo.test.ts b/server/tests/unit/auth/oauthApiKeyRepo.test.ts new file mode 100644 index 000000000..6a2aabc25 --- /dev/null +++ b/server/tests/unit/auth/oauthApiKeyRepo.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import { isOAuthConsentLinkedApiKey } from "@/internal/auth/repos/oauthApiKeyRepo.js"; + +type GuardApiKey = Parameters[0]["apiKey"]; + +const oauthMeta = { + created_via: "oauth", + oauth_consent_id: "consent_123", + oauth_client_id: "autumn_mcp_cursor", + oauth_redirect_uri: "cursor://oauth/callback", + env: AppEnv.Sandbox, +}; + +const baseApiKey: GuardApiKey = { + id: "key_123", + orgId: "org_123", + userId: "user_123", + env: AppEnv.Sandbox, + hashedKey: "hashed", + meta: oauthMeta, +}; + +const matchesConsent = (apiKey: GuardApiKey) => + isOAuthConsentLinkedApiKey({ + apiKey, + consentId: "consent_123", + clientId: "autumn_mcp_cursor", + redirectUri: "cursor://oauth/callback", + orgId: "org_123", + userId: "user_123", + env: AppEnv.Sandbox, + }); + +describe("isOAuthConsentLinkedApiKey", () => { + test("accepts an OAuth-created key linked to the same consent", () => { + expect(matchesConsent(baseApiKey)).toBe(true); + }); + + test("rejects a user-created key even if it is the stored api key id", () => { + expect( + matchesConsent({ + ...baseApiKey, + meta: { created_via: "dashboard" }, + }), + ).toBe(false); + }); + + test("rejects an OAuth key linked to a different consent", () => { + expect( + matchesConsent({ + ...baseApiKey, + meta: { + ...oauthMeta, + oauth_consent_id: "consent_other", + }, + }), + ).toBe(false); + }); + + test("rejects an OAuth key linked to a different redirect URI", () => { + expect( + isOAuthConsentLinkedApiKey({ + apiKey: baseApiKey, + consentId: "consent_123", + clientId: "autumn_mcp_cursor", + redirectUri: "cursor://oauth/other-callback", + orgId: "org_123", + userId: "user_123", + env: AppEnv.Sandbox, + }), + ).toBe(false); + }); + + test("rejects an OAuth key with different ownership or env", () => { + expect(matchesConsent({ ...baseApiKey, orgId: "org_other" })).toBe(false); + expect(matchesConsent({ ...baseApiKey, userId: "user_other" })).toBe(false); + expect(matchesConsent({ ...baseApiKey, env: AppEnv.Live })).toBe(false); + }); +}); diff --git a/server/tests/unit/auth/registerMcpOAuthClient.test.ts b/server/tests/unit/auth/registerMcpOAuthClient.test.ts new file mode 100644 index 000000000..4819fd086 --- /dev/null +++ b/server/tests/unit/auth/registerMcpOAuthClient.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { getDefaultOAuthScopes } from "@autumn/auth/oauth"; +import { LEAF_OAUTH_SCOPES } from "@autumn/shared"; +import { Scopes } from "@autumn/shared/scopeDefinitions"; +import { getRequestedScopesForMcpClient } from "@/internal/auth/actions/registerMcpOAuthClient.js"; + +describe("getRequestedScopesForMcpClient", () => { + test("defaults Slack MCP clients to Leaf OAuth scopes", () => { + expect( + getRequestedScopesForMcpClient({ clientType: "slack", scope: undefined }), + ).toEqual([...LEAF_OAUTH_SCOPES]); + }); + + test("defaults Codex MCP clients to Leaf OAuth scopes", () => { + expect( + getRequestedScopesForMcpClient({ clientType: "codex", scope: undefined }), + ).toEqual([...LEAF_OAUTH_SCOPES]); + }); + + test("defaults dynamic MCP clients to Leaf OAuth scopes", () => { + expect( + getRequestedScopesForMcpClient({ + clientType: "dynamic", + scope: undefined, + }), + ).toEqual([...LEAF_OAUTH_SCOPES]); + }); + + test("caps explicit requested scopes to Leaf scopes", () => { + expect( + getRequestedScopesForMcpClient({ + clientType: "slack", + scope: `${Scopes.Customers.Read} ${Scopes.Plans.Write} ${Scopes.ApiKeys.Write} invalid`, + }), + ).toEqual([Scopes.Customers.Read, Scopes.Plans.Write]); + }); + + test("caps OAuth grants to Leaf scopes", () => { + expect( + getDefaultOAuthScopes([ + Scopes.Customers.Read, + Scopes.ApiKeys.Write, + Scopes.Analytics.Read, + ]), + ).toEqual([Scopes.Customers.Read, Scopes.Analytics.Read]); + }); +}); diff --git a/server/tests/unit/balances/compute-credit-costs.test.ts b/server/tests/unit/balances/compute-credit-costs.test.ts new file mode 100644 index 000000000..38df654d1 --- /dev/null +++ b/server/tests/unit/balances/compute-credit-costs.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { + type Feature, + FeatureType, + FeatureUsageType, + type FullCusEntWithFullCusProduct, +} from "@autumn/shared"; +import { computeCreditCosts } from "@/internal/balances/utils/deduction/computeCreditCosts.js"; +import type { FeatureDeduction } from "@/internal/balances/utils/types/featureDeduction.js"; + +const makeFeature = ( + id: string, + type: FeatureType, + schema: { metered_feature_id: string; credit_amount: number }[] = [], +): Feature => ({ + internal_id: `fe_${id}`, + org_id: "org_test", + created_at: 0, + env: "sandbox" as Feature["env"], + id, + name: id, + type, + config: { schema, usage_type: FeatureUsageType.Single }, + archived: false, + event_names: [], + model_markups: null, +}); + +const makeCusEnt = (id: string, feature: Feature) => + ({ id, entitlement: { feature } }) as FullCusEntWithFullCusProduct; + +const messages = makeFeature("messages", FeatureType.Metered); +const credits = makeFeature("credits", FeatureType.CreditSystem, [ + { metered_feature_id: "messages", credit_amount: 0.2 }, +]); +// Simulates a stale cached snapshot whose schema no longer includes "messages". +const staleCredits = makeFeature("credits", FeatureType.CreditSystem, [ + { metered_feature_id: "other_feature", credit_amount: 5 }, +]); + +describe("computeCreditCosts", () => { + test("applies schema ratios for parent credit systems", () => { + const deduction: FeatureDeduction = { feature: messages, deduction: 10 }; + const lookup = computeCreditCosts({ + cusEnts: [makeCusEnt("ce_msg", messages), makeCusEnt("ce_cred", credits)], + deduction, + }); + + expect(lookup("ce_msg")).toBe(1); + expect(lookup("ce_cred")).toBe(0.2); + }); + + test("token deductions use their USD cost 1:1 and ratio-map to parents", () => { + const aiCredits = makeFeature("ai_credits", FeatureType.AiCreditSystem); + const orbs = makeFeature("orbs", FeatureType.CreditSystem, [ + { metered_feature_id: "ai_credits", credit_amount: 1000 }, + ]); + const deduction: FeatureDeduction = { + feature: aiCredits, + deduction: 1, + tokens: { + usage: { modelName: "custom/m", inputTokens: 1, outputTokens: 1 }, + cost: 0.125, + }, + }; + const lookup = computeCreditCosts({ + cusEnts: [makeCusEnt("ce_ai", aiCredits), makeCusEnt("ce_orbs", orbs)], + deduction, + }); + + expect(lookup("ce_ai")).toBe(0.125); + expect(lookup("ce_orbs")).toBe(125); + }); + + test("stale schema snapshot falls back to 1 instead of failing the track", () => { + const deduction: FeatureDeduction = { feature: messages, deduction: 10 }; + const lookup = computeCreditCosts({ + cusEnts: [makeCusEnt("ce_stale", staleCredits)], + deduction, + }); + + expect(lookup("ce_stale")).toBe(1); + }); +}); diff --git a/server/tests/unit/balances/track/handle-track-tokens.test.ts b/server/tests/unit/balances/track/handle-track-tokens.test.ts index decca2c78..81f01e3e4 100644 --- a/server/tests/unit/balances/track/handle-track-tokens.test.ts +++ b/server/tests/unit/balances/track/handle-track-tokens.test.ts @@ -19,7 +19,14 @@ const featureDeductions = [ { feature: { id: "ai_credits" }, deduction: 1, - precomputedCreditCost: 3.5, + tokens: { + usage: { + modelName: "openai/gpt-4.1", + inputTokens: 100, + outputTokens: 50, + }, + cost: 3.5, + }, }, ]; diff --git a/server/tests/unit/balances/trackWebhooks/checkUsageAlerts.test.ts b/server/tests/unit/balances/trackWebhooks/checkUsageAlerts.test.ts new file mode 100644 index 000000000..1e1ec3b88 --- /dev/null +++ b/server/tests/unit/balances/trackWebhooks/checkUsageAlerts.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import type { ApiBalanceV1, DbUsageAlert } from "@autumn/shared"; +import { wasThresholdCrossed } from "@/internal/balances/trackWebhooks/checkUsageAlerts.js"; + +const balance = ({ + usage, + granted = 1000, + remaining = granted - usage, +}: { + usage: number; + granted?: number; + remaining?: number; +}): ApiBalanceV1 => + ({ + object: "balance", + feature_id: "messages", + granted, + remaining, + usage, + unlimited: false, + overage_allowed: false, + max_purchase: null, + next_reset_at: null, + }) as ApiBalanceV1; + +const alert = ({ + threshold, + threshold_type, +}: Pick): DbUsageAlert => ({ + threshold, + threshold_type, + enabled: true, + feature_id: "messages", +}); + +describe("wasThresholdCrossed", () => { + test("usage alert fires when usage lands exactly on threshold", () => { + expect( + wasThresholdCrossed({ + alert: alert({ threshold: 500, threshold_type: "usage" }), + oldApiBalance: balance({ usage: 490 }), + newApiBalance: balance({ usage: 500 }), + }), + ).toBe(true); + }); + + test("usage alert does not refire when usage was already at threshold", () => { + expect( + wasThresholdCrossed({ + alert: alert({ threshold: 500, threshold_type: "usage" }), + oldApiBalance: balance({ usage: 500 }), + newApiBalance: balance({ usage: 510 }), + }), + ).toBe(false); + }); + + test("usage percentage alert fires when usage lands exactly on threshold", () => { + expect( + wasThresholdCrossed({ + alert: alert({ + threshold: 100, + threshold_type: "usage_percentage", + }), + oldApiBalance: balance({ usage: 990 }), + newApiBalance: balance({ usage: 1000 }), + }), + ).toBe(true); + }); + + test("usage percentage alert does not refire when already at threshold", () => { + expect( + wasThresholdCrossed({ + alert: alert({ + threshold: 100, + threshold_type: "usage_percentage", + }), + oldApiBalance: balance({ usage: 1000 }), + newApiBalance: balance({ usage: 1010 }), + }), + ).toBe(false); + }); + + test("remaining threshold behavior is already inclusive on the new value", () => { + expect( + wasThresholdCrossed({ + alert: alert({ threshold: 200, threshold_type: "remaining" }), + oldApiBalance: balance({ usage: 790, remaining: 210 }), + newApiBalance: balance({ usage: 800, remaining: 200 }), + }), + ).toBe(true); + }); +}); diff --git a/server/tests/unit/billing/apply-backdated-immediate-periods.spec.ts b/server/tests/unit/billing/apply-backdated-immediate-periods.spec.ts new file mode 100644 index 000000000..b2c6f1aef --- /dev/null +++ b/server/tests/unit/billing/apply-backdated-immediate-periods.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { + addInterval, + applyBackdatedLineItemAmount, + BillingInterval, + type BillingPeriod, + getCycleEnd, + type LineItemContext, + ms, + type Price, +} from "@autumn/shared"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; +import { prices } from "@tests/utils/fixtures/db/prices"; +import { getBackdatedLineItemContext } from "@/internal/billing/v2/utils/lineItems/getBackdatedLineItemContext"; + +const startsAt = Date.UTC(2026, 0, 1); +const intoThirdCycle = + addInterval({ + from: startsAt, + interval: BillingInterval.Month, + intervalCount: 2, + }) + ms.days(14); + +const monthly = prices.createFixed({ id: "monthly" }); +const billingPeriod: BillingPeriod = { + start: addInterval({ + from: startsAt, + interval: BillingInterval.Month, + intervalCount: 2, + }), + end: addInterval({ + from: startsAt, + interval: BillingInterval.Month, + intervalCount: 3, + }), +}; + +const backdateContext = ({ + currentEpochMs = intoThirdCycle, +}: { + currentEpochMs?: number; +} = {}) => ({ + ...contexts.createBilling({ + currentEpochMs, + billingCycleAnchorMs: startsAt, + }), + subscriptionBackdateStartMs: startsAt, +}); + +const lineItemContext = ({ + cycleCount, + direction = "charge", + billingTiming = "in_advance", +}: { + cycleCount?: number; + direction?: LineItemContext["direction"]; + billingTiming?: LineItemContext["billingTiming"]; +}): LineItemContext => + ({ + direction, + billingTiming, + backdate: cycleCount ? { startsAt, cycleCount } : undefined, + } as LineItemContext); + +describe("backdated line item context", () => { + test("derives the backdated period, snapped now and cycle count", () => { + const backdatedContext = getBackdatedLineItemContext({ + price: monthly, + billingContext: backdateContext(), + billingPeriod, + direction: "charge", + billingTiming: "in_advance", + }); + + const expectedEnd = getCycleEnd({ + anchor: startsAt, + interval: BillingInterval.Month, + intervalCount: 1, + now: intoThirdCycle, + floor: startsAt, + }); + + expect(backdatedContext).toEqual({ + now: billingPeriod.start, + effectivePeriod: { start: startsAt, end: expectedEnd }, + backdate: { startsAt, cycleCount: 3 }, + }); + }); + + test("derives one full cycle before a full cycle has elapsed", () => { + const currentEpochMs = startsAt + ms.days(14); + const backdatedContext = getBackdatedLineItemContext({ + price: monthly, + billingContext: backdateContext({ + currentEpochMs, + }), + billingPeriod: { + start: startsAt, + end: addInterval({ + from: startsAt, + interval: BillingInterval.Month, + }), + }, + direction: "charge", + billingTiming: "in_advance", + }); + + expect(backdatedContext?.backdate?.cycleCount).toBe(1); + expect(backdatedContext?.effectivePeriod).toEqual({ + start: startsAt, + end: getCycleEnd({ + anchor: startsAt, + interval: BillingInterval.Month, + intervalCount: 1, + now: currentEpochMs, + floor: startsAt, + }), + }); + }); + + test("does not derive context without a backdated start", () => { + const backdatedContext = getBackdatedLineItemContext({ + price: monthly, + billingContext: contexts.createBilling({ + currentEpochMs: intoThirdCycle, + billingCycleAnchorMs: startsAt, + }), + billingPeriod, + direction: "charge", + billingTiming: "in_advance", + }); + + expect(backdatedContext).toBeUndefined(); + }); + + test("does not derive context for one-off prices", () => { + const oneOff = prices.createOneOff({ id: "setup" }); + const backdatedContext = getBackdatedLineItemContext({ + price: oneOff as Price, + billingContext: backdateContext(), + billingPeriod, + direction: "charge", + billingTiming: "in_advance", + }); + + expect(backdatedContext).toBeUndefined(); + }); + + test("does not derive context against an existing Stripe subscription", () => { + const backdatedContext = getBackdatedLineItemContext({ + price: monthly, + billingContext: { + ...backdateContext(), + stripeSubscription: { id: "sub_existing" } as never, + }, + billingPeriod, + direction: "charge", + billingTiming: "in_advance", + }); + + expect(backdatedContext).toBeUndefined(); + }); + + test("does not derive context for refunds or arrears", () => { + expect( + getBackdatedLineItemContext({ + price: monthly, + billingContext: backdateContext(), + billingPeriod, + direction: "refund", + billingTiming: "in_advance", + }), + ).toBeUndefined(); + + expect( + getBackdatedLineItemContext({ + price: monthly, + billingContext: backdateContext(), + billingPeriod, + direction: "charge", + billingTiming: "in_arrear", + }), + ).toBeUndefined(); + }); + + test("scales charge in-advance amounts from backdate context", () => { + const result = applyBackdatedLineItemAmount({ + amount: 100, + context: lineItemContext({ cycleCount: 3 }), + }); + + expect(result).toBe(300); + }); + + test("does not scale refunds or arrears amounts", () => { + expect( + applyBackdatedLineItemAmount({ + amount: 100, + context: lineItemContext({ cycleCount: 3, direction: "refund" }), + }), + ).toBe(100); + + expect( + applyBackdatedLineItemAmount({ + amount: 100, + context: lineItemContext({ + cycleCount: 3, + billingTiming: "in_arrear", + }), + }), + ).toBe(100); + }); +}); diff --git a/server/tests/unit/billing/attach/handle-start-date-errors.spec.ts b/server/tests/unit/billing/attach/handle-start-date-errors.spec.ts new file mode 100644 index 000000000..92d9bd619 --- /dev/null +++ b/server/tests/unit/billing/attach/handle-start-date-errors.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { + type AttachBillingContext, + type AttachParamsV1, + addInterval, + BillingInterval, +} from "@autumn/shared"; +import { prices } from "@tests/utils/fixtures/db/prices"; +import { products } from "@tests/utils/fixtures/db/products"; +import { handleStartDateErrors } from "@/internal/billing/v2/actions/attach/errors/handleStartDateErrors"; +import { STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT } from "@/internal/billing/v2/utils/backdate/countBackdatedPeriods"; + +const startsAt = Date.UTC(2026, 0, 1); + +const buildContext = ({ + currentEpochMs, + priceCount = 1, + checkoutMode = null, +}: { + currentEpochMs: number; + priceCount?: number; + checkoutMode?: "stripe_checkout" | null; +}) => + ({ + currentEpochMs, + attachProduct: products.createFull({ + prices: Array.from({ length: priceCount }, (_, index) => + prices.createFixed({ id: `price_${index}` }), + ), + }), + checkoutMode, + trialContext: null, + }) as unknown as AttachBillingContext; + +const paramsWithStartsAt = (startsAt: number) => + ({ + customer_id: "cus_backdate_limit", + plan_id: "pro", + starts_at: startsAt, + }) as AttachParamsV1; + +const dateAfterMonthlyCycles = (cycles: number) => + addInterval({ + from: startsAt, + interval: BillingInterval.Month, + intervalCount: cycles, + }); + +describe("handleStartDateErrors", () => { + test("allows the earliest backdate that stays within Stripe's invoice line item limit", () => { + expect(() => + handleStartDateErrors({ + billingContext: buildContext({ + currentEpochMs: dateAfterMonthlyCycles( + STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT, + ), + }), + params: paramsWithStartsAt(startsAt), + }), + ).not.toThrow(); + }); + + test("rejects backdates that would exceed Stripe's invoice line item limit", () => { + expect(() => + handleStartDateErrors({ + billingContext: buildContext({ + currentEpochMs: dateAfterMonthlyCycles( + STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT + 1, + ), + }), + params: paramsWithStartsAt(startsAt), + }), + ).toThrow("at most 250 line items"); + }); + + test("applies Stripe's invoice line item limit across all recurring prices", () => { + expect(() => + handleStartDateErrors({ + billingContext: buildContext({ + currentEpochMs: dateAfterMonthlyCycles(126), + priceCount: 2, + }), + params: paramsWithStartsAt(startsAt), + }), + ).toThrow("at most 250 line items"); + }); + + test("rejects a backdated checkout-required start at execution time", () => { + expect(() => + handleStartDateErrors({ + billingContext: buildContext({ + currentEpochMs: dateAfterMonthlyCycles(1), + checkoutMode: "stripe_checkout", + }), + params: paramsWithStartsAt(startsAt), + }), + ).toThrow("Past starts_at cannot be used when Stripe Checkout is required"); + }); + + test("skips the checkout-required guard during preview", () => { + expect(() => + handleStartDateErrors({ + billingContext: buildContext({ + currentEpochMs: dateAfterMonthlyCycles(1), + checkoutMode: "stripe_checkout", + }), + params: paramsWithStartsAt(startsAt), + preview: true, + }), + ).not.toThrow(); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts b/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts index 4b4f971f9..4b9800748 100644 --- a/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts +++ b/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts @@ -54,7 +54,11 @@ export const expectPlanChange = ( } if (itemChanges !== undefined) { - expect(resolved.item_changes).toEqual(itemChanges); + expect(resolved.item_changes).toEqual( + expect.arrayContaining( + itemChanges.map((itemChange) => expect.objectContaining(itemChange)), + ), + ); } return resolved; diff --git a/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts b/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts index be450cf93..237488a37 100644 --- a/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts +++ b/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts @@ -5,6 +5,7 @@ import type { FullCustomerEntitlement, PatchCustomerProductSchema, } from "@autumn/shared"; +import { AllowanceType, EntInterval, FeatureType } from "@autumn/shared"; import type { z } from "zod/v4"; type CustomerProductUpdate = z.infer; @@ -70,4 +71,38 @@ export const makeCustomerEntitlement = ({ id: `cusEnt_${featureId}`, feature_id: featureId, internal_feature_id: `internal_${featureId}`, + entitlement: { + id: `ent_${featureId}`, + created_at: 1_700_000_000_000, + internal_feature_id: `internal_${featureId}`, + internal_product_id: "internal_pro", + internal_reward_id: null, + is_custom: false, + allowance_type: AllowanceType.Fixed, + allowance: 100, + interval: EntInterval.Month, + interval_count: 1, + carry_from_previous: false, + entity_feature_id: null, + usage_limit: null, + expiry_duration: null, + expiry_length: null, + rollover: null, + feature_id: featureId, + feature: { + internal_id: `internal_${featureId}`, + org_id: "org_test", + created_at: 1_700_000_000_000, + env: "sandbox", + id: featureId, + name: featureId, + type: FeatureType.Metered, + config: { usage_type: "single_use" }, + display: null, + archived: false, + event_names: [], + }, + }, + replaceables: [], + rollovers: [], }) as unknown as FullCustomerEntitlement; diff --git a/server/tests/unit/billing/billing-change-response/update-subscription.test.ts b/server/tests/unit/billing/billing-change-response/update-subscription.test.ts index 7238203e5..6498723d7 100644 --- a/server/tests/unit/billing/billing-change-response/update-subscription.test.ts +++ b/server/tests/unit/billing/billing-change-response/update-subscription.test.ts @@ -326,4 +326,47 @@ describe("buildBillingChangeResponse — updateSubscription", () => { expired: ["pro"], }); }); + + test("collapse same-plan_id pairs preserves replacement item changes", () => { + const newPro = makeFullCusProduct({ + planId: "pro", + status: CusProductStatus.Active, + startedAt: NOW, + id: "cp_pro_new", + }); + newPro.customer_entitlements = [ + makeCustomerEntitlement({ featureId: "api_calls" }), + ]; + + const oldPro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 30_000, + id: "cp_pro_old", + }); + oldPro.customer_entitlements = [ + makeCustomerEntitlement({ featureId: "legacy_feature" }), + ]; + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [oldPro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [newPro], + update: makeUpdate({ + customerProduct: oldPro, + updates: { status: CusProductStatus.Expired }, + }), + }), + }); + + expectBillingChangeResponse(response, { updated: ["pro"] }); + expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), { + action: "updated", + planId: "pro", + itemChanges: [ + { action: "created", feature_id: "api_calls" }, + { action: "deleted", feature_id: "legacy_feature" }, + ], + }); + }); }); diff --git a/server/tests/unit/billing/create-schedule/compute-create-schedule-plan.spec.ts b/server/tests/unit/billing/create-schedule/compute-create-schedule-plan.spec.ts index 36237a19d..4813d75d2 100644 --- a/server/tests/unit/billing/create-schedule/compute-create-schedule-plan.spec.ts +++ b/server/tests/unit/billing/create-schedule/compute-create-schedule-plan.spec.ts @@ -3,6 +3,7 @@ import { BillingVersion, type CreateScheduleBillingContext, CusProductStatus, + ms, } from "@autumn/shared"; import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; @@ -46,6 +47,7 @@ const createBillingContext = ({ customEnts: [], isCustom: false, billingVersion: BillingVersion.V2, + billingStartsAt: immediatePhase.starts_at, immediatePhase, futurePhases, scheduledPhaseContexts: [], @@ -106,6 +108,45 @@ describe(chalk.yellowBright("computeCreateSchedulePlan"), () => { expect(result.autumnBillingPlan.deleteCustomerProduct).toBeUndefined(); }); + test("uses the immediate phase starts_at for first-phase customer products", () => { + const ctx = contexts.create({}); + const currentEpochMs = 1_800_000_000_000; + const startsAt = currentEpochMs - ms.days(35); + const proProduct = products.createFull({ + id: "pro", + prices: [prices.createFixed({ id: "price_pro" })], + }); + + const billingContext = createBillingContext({ + currentEpochMs, + productContexts: [ + { + fullProduct: proProduct, + customPrices: [], + customEnts: [], + featureQuantities: [], + }, + ], + immediatePhase: { + starts_at: startsAt, + plans: [{ plan_id: proProduct.id }], + }, + }); + + const result = computeCreateSchedulePlan({ + ctx, + billingContext, + }); + + expect(result.autumnBillingPlan.insertCustomerProducts).toHaveLength(1); + expect(result.autumnBillingPlan.insertCustomerProducts[0]!.status).toBe( + CusProductStatus.Active, + ); + expect(result.autumnBillingPlan.insertCustomerProducts[0]!.starts_at).toBe( + startsAt, + ); + }); + test("expires the current product and removes a scheduled replacement during a transition", () => { const ctx = contexts.create({}); const currentEpochMs = 1_000_000; diff --git a/server/tests/unit/billing/create-schedule/handle-create-schedule-errors.spec.ts b/server/tests/unit/billing/create-schedule/handle-create-schedule-errors.spec.ts index cbee664ab..204eaf21c 100644 --- a/server/tests/unit/billing/create-schedule/handle-create-schedule-errors.spec.ts +++ b/server/tests/unit/billing/create-schedule/handle-create-schedule-errors.spec.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from "bun:test"; -import type { CreateScheduleBillingContext } from "@autumn/shared"; -import { ms } from "@autumn/shared"; +import type { CreateScheduleBillingContext, FullProduct } from "@autumn/shared"; +import { addInterval, BillingInterval, ms } from "@autumn/shared"; +import { prices } from "@tests/utils/fixtures/db/prices"; +import { products } from "@tests/utils/fixtures/db/products"; import chalk from "chalk"; -import type { DrizzleCli } from "@/db/initDrizzle"; import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle"; import { handleCreateScheduleErrors } from "@/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors"; +import { STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT } from "@/internal/billing/v2/utils/backdate/countBackdatedPeriods"; const db = undefined as unknown as DrizzleCli; @@ -12,10 +15,14 @@ const buildContext = ({ immediateStartsAt, currentEpochMs, existingSchedule, + fullProducts = [], + checkoutMode, }: { immediateStartsAt: number; currentEpochMs: number; existingSchedule?: Stripe.SubscriptionSchedule; + fullProducts?: FullProduct[]; + checkoutMode?: "stripe_checkout"; }) => ({ currentEpochMs, @@ -24,9 +31,10 @@ const buildContext = ({ plans: [{ plan_id: "plan" }], }, stripeSubscriptionSchedule: existingSchedule, + checkoutMode, productContexts: [], scheduledPhaseContexts: [], - fullProducts: [], + fullProducts, fullCustomer: { internal_id: "internal_cus_123", customer_products: [], @@ -48,7 +56,7 @@ describe(chalk.yellowBright("handleCreateScheduleErrors"), () => { ).resolves.toBeUndefined(); }); - test("rejects creation when the immediate phase is far in the past", async () => { + test("rejects creation when a past immediate phase has no paid recurring products", async () => { const now = Date.now(); await expect( @@ -59,7 +67,95 @@ describe(chalk.yellowBright("handleCreateScheduleErrors"), () => { currentEpochMs: now, }), }), - ).rejects.toThrow("The first phase must start immediately"); + ).rejects.toThrow( + "Past first phase starts_at is only supported for paid recurring plans", + ); + }); + + test("allows creation when the immediate phase is a supported backdate", async () => { + const now = Date.now(); + const pro = products.createFull({ + id: "pro", + prices: [prices.createFixed({ id: "price_pro" })], + }); + + await expect( + handleCreateScheduleErrors({ + db, + billingContext: buildContext({ + immediateStartsAt: now - ms.hours(1), + currentEpochMs: now, + fullProducts: [pro], + }), + }), + ).resolves.toBeUndefined(); + }); + + test("rejects creation when a backdated first invoice would exceed Stripe's line item limit", async () => { + const now = Date.UTC(2026, 4, 29); + const pro = products.createFull({ + id: "pro", + prices: [prices.createFixed({ id: "price_pro" })], + }); + const startsAt = addInterval({ + from: now, + interval: BillingInterval.Month, + intervalCount: -(STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT + 1), + }); + + await expect( + handleCreateScheduleErrors({ + db, + billingContext: buildContext({ + immediateStartsAt: startsAt, + currentEpochMs: now, + fullProducts: [pro], + }), + }), + ).rejects.toThrow("at most 250 line items"); + }); + + test("rejects a backdated checkout-required start at execution time", async () => { + const now = Date.now(); + const pro = products.createFull({ + id: "pro", + prices: [prices.createFixed({ id: "price_pro" })], + }); + + await expect( + handleCreateScheduleErrors({ + db, + billingContext: buildContext({ + immediateStartsAt: now - ms.hours(1), + currentEpochMs: now, + fullProducts: [pro], + checkoutMode: "stripe_checkout", + }), + }), + ).rejects.toThrow( + "Past first phase starts_at cannot be used when Stripe Checkout is required", + ); + }); + + test("skips the checkout-required guard during preview", async () => { + const now = Date.now(); + const pro = products.createFull({ + id: "pro", + prices: [prices.createFixed({ id: "price_pro" })], + }); + + await expect( + handleCreateScheduleErrors({ + db, + preview: true, + billingContext: buildContext({ + immediateStartsAt: now - ms.hours(1), + currentEpochMs: now, + fullProducts: [pro], + checkoutMode: "stripe_checkout", + }), + }), + ).resolves.toBeUndefined(); }); test("rejects creation when the immediate phase is far in the future", async () => { diff --git a/server/tests/unit/billing/get-next-cycle-event.spec.ts b/server/tests/unit/billing/get-next-cycle-event.spec.ts new file mode 100644 index 000000000..61e60cc66 --- /dev/null +++ b/server/tests/unit/billing/get-next-cycle-event.spec.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from "bun:test"; +import { + type BillingContext, + BillingInterval, + CusProductStatus, + type FullCusProduct, + getCycleEnd, + ms, +} from "@autumn/shared"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; +import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; +import { discounts } from "@tests/utils/fixtures/db/discounts"; +import { prices } from "@tests/utils/fixtures/db/prices"; +import { products } from "@tests/utils/fixtures/db/products"; +import { getNextCycleEvent } from "@/internal/billing/v2/utils/billingPlan/toNextCyclePreview/getNextCycleEvent"; + +const anchorMs = Date.UTC(2026, 0, 1); +const currentEpochMs = Date.UTC(2026, 0, 11); + +const renewalBoundaryMs = getCycleEnd({ + anchor: anchorMs, + interval: BillingInterval.Month, + intervalCount: 1, + now: currentEpochMs, + floor: anchorMs, +}); + +const buildContext = ( + overrides: Partial = {}, +): BillingContext => ({ + ...contexts.createBilling({ currentEpochMs, billingCycleAnchorMs: anchorMs }), + ...overrides, +}); + +const cusProduct = ({ + id, + startsAt = anchorMs, + endedAt, + status = CusProductStatus.Active, + entityId, + trialEndsAt, + oneOff = false, + group, +}: { + id: string; + startsAt?: number; + endedAt?: number | null; + status?: CusProductStatus; + entityId?: string; + trialEndsAt?: number; + oneOff?: boolean; + group?: string; +}): FullCusProduct => { + const price = oneOff + ? prices.createOneOff({ id: `price_${id}` }) + : prices.createFixed({ id: `price_${id}` }); + + const product = products.createFull({ + id, + prices: [price], + isAddOn: id.includes("addon"), + }); + + return { + ...customerProducts.create({ + id, + productId: id, + internalEntityId: entityId, + status, + startsAt, + endedAt, + customerPrices: [ + prices.createCustomer({ + price, + customerProductId: id, + }), + ], + product: group ? { ...product, group } : product, + }), + status, + trial_ends_at: trialEndsAt, + }; +}; + +const resolve = ({ + billingContext = buildContext(), + customerProducts = [], +}: { + billingContext?: BillingContext; + customerProducts?: FullCusProduct[]; +} = {}) => + getNextCycleEvent({ + billingContext, + customerProducts, + anchorMs: + billingContext.billingCycleAnchorMs === "now" + ? billingContext.currentEpochMs + : billingContext.billingCycleAnchorMs, + }); + +const productIds = (customerProducts: FullCusProduct[]) => + customerProducts.map((product) => product.id).sort(); + +describe("getNextCycleEvent", () => { + test("keeps current behavior for an immediate new subscription with no future transition", () => { + const event = resolve({ + billingContext: buildContext({ billingCycleAnchorMs: "now" }), + customerProducts: [cusProduct({ id: "pro", startsAt: currentEpochMs })], + }); + + expect(event.kind).toBe("none"); + }); + + test("returns renewal with all products active at the boundary", () => { + const products = [ + cusProduct({ id: "entity-1-pro", entityId: "entity_1" }), + cusProduct({ id: "entity-2-pro", entityId: "entity_2" }), + cusProduct({ id: "addon", entityId: "entity_2" }), + cusProduct({ + id: "expired-before-renewal", + endedAt: currentEpochMs - ms.days(1), + }), + ]; + + const event = resolve({ customerProducts: products }); + + expect(event.kind).toBe("renewal"); + if (event.kind === "renewal") { + expect(event.startsAtMs).toBe(renewalBoundaryMs); + expect( + event.customerProducts.map((product) => product.id).sort(), + ).toEqual(["addon", "entity-1-pro", "entity-2-pro"]); + } + }); + + test("future starts_at with no active products is a scheduled start", () => { + const scheduledAt = currentEpochMs + ms.days(3); + const incoming = cusProduct({ + id: "pro", + startsAt: scheduledAt, + status: CusProductStatus.Scheduled, + }); + + const event = resolve({ + billingContext: buildContext({ billingCycleAnchorMs: "now" }), + customerProducts: [incoming], + }); + + expect(event.kind).toBe("scheduled_start"); + if (event.kind === "scheduled_start") { + expect(event.startsAtMs).toBe(scheduledAt); + expect(productIds(event.customerProducts)).toEqual([incoming.id]); + } + }); + + test("future add-on start before renewal is an incoming-only scheduled start", () => { + const scheduledAt = renewalBoundaryMs - ms.days(4); + const active = cusProduct({ id: "pro" }); + const addon = cusProduct({ + id: "addon", + startsAt: scheduledAt, + status: CusProductStatus.Scheduled, + }); + + const event = resolve({ customerProducts: [active, addon] }); + + expect(event.kind).toBe("scheduled_start"); + if (event.kind === "scheduled_start") { + expect(event.startsAtMs).toBe(scheduledAt); + expect(productIds(event.customerProducts)).toEqual([addon.id]); + } + }); + + test("phase replacement before renewal is a scheduled change", () => { + const scheduledAt = renewalBoundaryMs - ms.days(5); + const pro = cusProduct({ id: "pro", endedAt: scheduledAt }); + const premium = cusProduct({ + id: "premium", + startsAt: scheduledAt, + status: CusProductStatus.Scheduled, + }); + + const event = resolve({ customerProducts: [pro, premium] }); + + expect(event.kind).toBe("scheduled_change"); + if (event.kind === "scheduled_change") { + expect(event.startsAtMs).toBe(scheduledAt); + expect(productIds(event.incomingCustomerProducts)).toEqual([premium.id]); + expect(productIds(event.outgoingCustomerProducts)).toEqual([pro.id]); + } + }); + + test("same-group future phase is a scheduled change even before ended_at is patched", () => { + const scheduledAt = renewalBoundaryMs - ms.days(5); + const pro = cusProduct({ id: "pro", group: "main" }); + const premium = cusProduct({ + id: "premium", + startsAt: scheduledAt, + status: CusProductStatus.Scheduled, + group: "main", + }); + + const event = resolve({ customerProducts: [pro, premium] }); + + expect(event.kind).toBe("scheduled_change"); + if (event.kind === "scheduled_change") { + expect(productIds(event.incomingCustomerProducts)).toEqual([premium.id]); + expect(productIds(event.outgoingCustomerProducts)).toEqual([pro.id]); + } + }); + + test("phase change at the renewal boundary is classified as renewal", () => { + const pro = cusProduct({ id: "pro", endedAt: renewalBoundaryMs }); + const premium = cusProduct({ + id: "premium", + startsAt: renewalBoundaryMs, + status: CusProductStatus.Scheduled, + }); + + const event = resolve({ customerProducts: [pro, premium] }); + + expect(event.kind).toBe("renewal"); + if (event.kind === "renewal") { + expect(event.startsAtMs).toBe(renewalBoundaryMs); + expect(productIds(event.customerProducts)).toEqual([premium.id]); + } + }); + + test("trial end before renewal is the next event", () => { + const trialEndsAt = currentEpochMs + ms.days(7); + const pro = cusProduct({ id: "pro", trialEndsAt }); + const addon = cusProduct({ id: "addon", trialEndsAt }); + + const event = resolve({ + billingContext: buildContext({ + trialContext: { + trialEndsAt, + appliesToBilling: true, + cardRequired: true, + }, + }), + customerProducts: [pro, addon], + }); + + expect(event.kind).toBe("trial_end"); + if (event.kind === "trial_end") { + expect(event.startsAtMs).toBe(trialEndsAt); + expect( + event.customerProducts.map((product) => product.id).sort(), + ).toEqual(["addon", "pro"]); + } + }); + + test("phase transition wins over a later trial end", () => { + const scheduledAt = currentEpochMs + ms.days(4); + const trialEndsAt = currentEpochMs + ms.days(7); + const pro = cusProduct({ id: "pro", endedAt: scheduledAt, trialEndsAt }); + const premium = cusProduct({ + id: "premium", + startsAt: scheduledAt, + status: CusProductStatus.Scheduled, + trialEndsAt, + }); + + const event = resolve({ + billingContext: buildContext({ + trialContext: { + trialEndsAt, + appliesToBilling: true, + cardRequired: true, + }, + }), + customerProducts: [pro, premium], + }); + + expect(event.kind).toBe("scheduled_change"); + if (event.kind === "scheduled_change") { + expect(event.startsAtMs).toBe(scheduledAt); + } + }); + + test("anchor reset before renewal is the next event", () => { + const requestedBillingCycleAnchor = currentEpochMs + ms.days(5); + const event = resolve({ + billingContext: buildContext({ requestedBillingCycleAnchor }), + customerProducts: [cusProduct({ id: "pro" })], + }); + + expect(event.kind).toBe("anchor_reset"); + }); + + test("renewal wins when requested anchor reset lands after renewal", () => { + const requestedBillingCycleAnchor = renewalBoundaryMs + ms.days(5); + const event = resolve({ + billingContext: buildContext({ requestedBillingCycleAnchor }), + customerProducts: [cusProduct({ id: "pro" })], + }); + + expect(event.kind).toBe("renewal"); + if (event.kind === "renewal") { + expect(event.startsAtMs).toBe(renewalBoundaryMs); + } + }); + + test("discounts do not affect event selection", () => { + const scheduledAt = renewalBoundaryMs - ms.days(5); + const event = resolve({ + billingContext: buildContext({ + stripeDiscounts: [discounts.twentyPercentOff()], + }), + customerProducts: [ + cusProduct({ id: "pro", endedAt: scheduledAt }), + cusProduct({ + id: "premium", + startsAt: scheduledAt, + status: CusProductStatus.Scheduled, + }), + ], + }); + + expect(event.kind).toBe("scheduled_change"); + }); + + test("returns none without a recurring interval", () => { + const event = getNextCycleEvent({ + billingContext: buildContext(), + customerProducts: [cusProduct({ id: "one-off", oneOff: true })], + anchorMs, + }); + + expect(event.kind).toBe("none"); + }); +}); diff --git a/server/tests/unit/billing/init-customer-entitlement-next-reset-at.test.ts b/server/tests/unit/billing/init-customer-entitlement-next-reset-at.test.ts new file mode 100644 index 000000000..8245a6692 --- /dev/null +++ b/server/tests/unit/billing/init-customer-entitlement-next-reset-at.test.ts @@ -0,0 +1,50 @@ +/** + * TDD regression: one-off entitlements can be represented by `interval: null`. + * Red: null intervals were treated as monthly and received a future reset date. + */ + +import { expect, test } from "bun:test"; +import { + AllowanceType, + FeatureType, + type EntitlementWithFeature, +} from "@autumn/shared"; +import { initCustomerEntitlementNextResetAt } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementNextResetAt"; + +test("initCustomerEntitlementNextResetAt returns null for null-interval one-off entitlements", () => { + const now = Date.now(); + const entitlement = { + id: "ent_null_interval_one_off", + created_at: now, + internal_feature_id: "feat_credits", + internal_product_id: "prod_one_off", + is_custom: false, + allowance_type: AllowanceType.Fixed, + allowance: 150, + interval: null, + interval_count: 1, + carry_from_previous: false, + entity_feature_id: null, + usage_limit: null, + rollover: null, + feature_id: "credits", + feature: { + id: "credits", + internal_id: "feat_credits", + type: FeatureType.Metered, + }, + } as EntitlementWithFeature; + + expect( + initCustomerEntitlementNextResetAt({ + initContext: { + fullCustomer: { id: "cus_unit" }, + fullProduct: { id: "prod_one_off" }, + featureQuantities: [], + resetCycleAnchor: now, + now, + } as any, + entitlement, + }), + ).toBeNull(); +}); diff --git a/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts b/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts new file mode 100644 index 000000000..59a9bbb0d --- /dev/null +++ b/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts @@ -0,0 +1,317 @@ +import { describe, expect, test } from "bun:test"; +import type { BillingContext, DbInvoiceLineItem } from "@autumn/shared"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; +import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; +import { prices } from "@tests/utils/fixtures/db/prices"; +import chalk from "chalk"; +import { getRefundLineItemsForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice"; +import { invoiceCreditFromStoredLineItems } from "@/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems"; +import { + computeAlreadyRefundedForCharge, + computeProratedCredit, + splitMultiEntityAmount, +} from "@/internal/billing/v2/utils/lineItems/storedLineItemUtils"; + +const PERIOD_START = 1_700_000_000_000; +const PERIOD_END = PERIOD_START + 30 * 24 * 60 * 60 * 1000; +const MID_CYCLE = PERIOD_START + 15 * 24 * 60 * 60 * 1000; + +const makeChargeRow = ( + overrides: Partial = {}, +): DbInvoiceLineItem => + ({ + id: "li_charge_1", + amount: 20, + amount_after_discounts: 20, + effective_period_start: PERIOD_START, + effective_period_end: PERIOD_END, + customer_product_ids: ["cp_1"], + price_id: "price_pro", + stripe_price_id: "stripe_price_pro", + direction: "charge", + discounts: [], + ...overrides, + }) as DbInvoiceLineItem; + +const makeRefundRow = ( + overrides: Partial = {}, +): DbInvoiceLineItem => + ({ + id: "li_refund_1", + amount: -10, + amount_after_discounts: -10, + effective_period_start: PERIOD_START, + effective_period_end: PERIOD_END, + customer_product_ids: ["cp_1"], + price_id: "price_pro", + stripe_price_id: "stripe_price_pro", + direction: "refund", + discounts: [], + ...overrides, + }) as DbInvoiceLineItem; + +describe(chalk.yellowBright("computeProratedCredit"), () => { + test("prorates a full charge at mid-cycle to ~half negative", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow(), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + expect(result).toBeLessThan(0); + expect(result).toBeCloseTo(-10, 0); + }); + + test("returns 0 when period has ended", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow(), + now: PERIOD_END + 1000, + alreadyRefunded: 0, + }); + + expect(result).toBe(0); + }); + + test("returns 0 when period is null", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow({ effective_period_start: null }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + expect(result).toBe(0); + }); + + test("subtracts already-refunded before prorating", () => { + const fullCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + const partialCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 10, + }); + + expect(Math.abs(partialCredit)).toBeLessThan(Math.abs(fullCredit)); + }); + + test("returns 0 when fully refunded", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 20, + }); + + expect(result).toBe(0); + }); + + test("uses amount_after_discounts (discounted charge gives smaller credit)", () => { + const fullPriceCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + const discountedCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 16 }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + expect(Math.abs(discountedCredit)).toBeLessThan(Math.abs(fullPriceCredit)); + }); +}); + +describe(chalk.yellowBright("computeAlreadyRefundedForCharge"), () => { + test("sums matching refund rows by price and period", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [ + makeRefundRow({ amount_after_discounts: -5 }), + makeRefundRow({ id: "li_refund_2", amount_after_discounts: -3 }), + ], + }); + + expect(result).toBe(8); + }); + + test("excludes refunds with different price_id", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [ + makeRefundRow({ price_id: "price_other", stripe_price_id: "other" }), + ], + }); + + expect(result).toBe(0); + }); + + test("excludes refunds outside the charge period", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [ + makeRefundRow({ + effective_period_start: PERIOD_END + 1000, + effective_period_end: PERIOD_END + 30 * 24 * 60 * 60 * 1000, + }), + ], + }); + + expect(result).toBe(0); + }); + + test("returns 0 with no refund rows", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [], + }); + + expect(result).toBe(0); + }); +}); + +describe(chalk.yellowBright("splitMultiEntityAmount"), () => { + test("returns full amount for single cusProduct", () => { + const result = splitMultiEntityAmount( + makeChargeRow({ amount_after_discounts: 30 }), + ); + + expect(result).toBe(30); + }); + + test("splits evenly across multiple cusProduct ids", () => { + const result = splitMultiEntityAmount( + makeChargeRow({ + amount_after_discounts: 30, + customer_product_ids: ["cp_1", "cp_2", "cp_3"], + }), + ); + + expect(result).toBe(10); + }); + + test("handles empty customer_product_ids", () => { + const result = splitMultiEntityAmount( + makeChargeRow({ + amount_after_discounts: 30, + customer_product_ids: [], + }), + ); + + expect(result).toBe(30); + }); +}); + +describe(chalk.yellowBright("invoiceCreditFromStoredLineItems"), () => { + const buildMultiPriceContext = ({ + storedChargeLineItems, + }: { + storedChargeLineItems: DbInvoiceLineItem[]; + }) => { + const proPrice = prices.createFixed({ id: "price_pro" }); + const addonPrice = prices.createFixed({ id: "price_addon" }); + const customerProduct = customerProducts.create({ + id: "cp_1", + customerPrices: [ + prices.createCustomer({ price: proPrice, customerProductId: "cp_1" }), + prices.createCustomer({ price: addonPrice, customerProductId: "cp_1" }), + ], + }); + const billingContext: BillingContext = { + ...contexts.createBilling({ + customerProducts: [customerProduct], + currentEpochMs: MID_CYCLE, + }), + storedChargeLineItems, + storedRefundLineItems: [], + }; + return { ctx: contexts.create({}), customerProduct, billingContext }; + }; + + test("does not duplicate credits when only some prices have stored rows", () => { + const { ctx, customerProduct, billingContext } = buildMultiPriceContext({ + storedChargeLineItems: [makeChargeRow({ price_id: "price_pro" })], + }); + + const result = invoiceCreditFromStoredLineItems({ + ctx, + customerProduct, + billingContext, + }); + + expect(result.allPricesResolved).toBe(false); + expect(result.resolvedPriceIds).toEqual(["price_pro"]); + expect(result.lineItems).toHaveLength(1); + expect(result.lineItems[0].amount).toBeLessThan(0); + }); + + test("resolves all prices when every price has a stored row", () => { + const { ctx, customerProduct, billingContext } = buildMultiPriceContext({ + storedChargeLineItems: [ + makeChargeRow({ id: "li_charge_pro", price_id: "price_pro" }), + makeChargeRow({ id: "li_charge_addon", price_id: "price_addon" }), + ], + }); + + const result = invoiceCreditFromStoredLineItems({ + ctx, + customerProduct, + billingContext, + }); + + expect(result.allPricesResolved).toBe(true); + expect(result.resolvedPriceIds).toEqual(["price_pro", "price_addon"]); + expect(result.lineItems).toHaveLength(2); + }); +}); + +describe(chalk.yellowBright("getRefundLineItemsForPrice"), () => { + const buildSinglePriceContext = ({ + storedChargeLineItems, + }: { + storedChargeLineItems: DbInvoiceLineItem[]; + }) => { + const proPrice = prices.createFixed({ id: "price_pro" }); + const customerProduct = customerProducts.create({ + id: "cp_1", + customerPrices: [ + prices.createCustomer({ price: proPrice, customerProductId: "cp_1" }), + ], + }); + const billingContext: BillingContext = { + ...contexts.createBilling({ + customerProducts: [customerProduct], + currentEpochMs: MID_CYCLE, + }), + storedChargeLineItems, + storedRefundLineItems: [], + }; + return { ctx: contexts.create({}), customerProduct, billingContext }; + }; + + test("returns every matched credit when a price has multiple stored charge rows", () => { + const { ctx, customerProduct, billingContext } = buildSinglePriceContext({ + storedChargeLineItems: [ + makeChargeRow({ id: "li_charge_initial", price_id: "price_pro" }), + makeChargeRow({ id: "li_charge_topup", price_id: "price_pro" }), + ], + }); + + const result = getRefundLineItemsForPrice({ + ctx, + customerProduct, + billingContext, + priceId: "price_pro", + catalogFallback: undefined, + }); + + expect(result).toHaveLength(2); + for (const lineItem of result) { + expect(lineItem.context.price.id).toBe("price_pro"); + expect(lineItem.amount).toBeLessThan(0); + } + }); +}); diff --git a/server/tests/unit/billing/setup-billing-cycle-anchor.spec.ts b/server/tests/unit/billing/setup-billing-cycle-anchor.spec.ts new file mode 100644 index 000000000..5b9e759f6 --- /dev/null +++ b/server/tests/unit/billing/setup-billing-cycle-anchor.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { ms } from "@autumn/shared"; +import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; +import { prices } from "@tests/utils/fixtures/db/prices"; +import { products } from "@tests/utils/fixtures/db/products"; +import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; + +const currentEpochMs = Date.UTC(2026, 4, 29); +const pastStartsAt = Date.UTC(2026, 4, 1); +const futureStartsAt = Date.UTC(2026, 5, 10); + +const paidRecurring = products.createFull({ + prices: [prices.createFixed({ id: "monthly" })], +}); +const freeProduct = products.createFull({ prices: [] }); +const oneOffProduct = products.createFull({ + prices: [prices.createOneOff({ id: "setup" })], +}); + +describe("setupBillingCycleAnchor backdate branch", () => { + test("anchors a new paid recurring subscription to a backdated start", () => { + expect( + setupBillingCycleAnchor({ + customerProduct: undefined, + newFullProduct: paidRecurring, + currentEpochMs, + billingStartsAt: pastStartsAt, + }), + ).toBe(pastStartsAt); + }); + + test("a future start does not backdate the anchor", () => { + expect( + setupBillingCycleAnchor({ + customerProduct: undefined, + newFullProduct: paidRecurring, + currentEpochMs, + billingStartsAt: futureStartsAt, + }), + ).toBe("now"); + }); + + test("a present start (not strictly past) does not backdate the anchor", () => { + expect( + setupBillingCycleAnchor({ + customerProduct: undefined, + newFullProduct: paidRecurring, + currentEpochMs, + billingStartsAt: currentEpochMs, + }), + ).toBe("now"); + }); + + test("an existing customer product blocks the backdate anchor", () => { + const existing = customerProducts.create({ + customerPrices: [ + prices.createCustomer({ price: prices.createFixed({ id: "monthly" }) }), + ], + startsAt: currentEpochMs - ms.days(60), + }); + expect( + setupBillingCycleAnchor({ + customerProduct: existing, + newFullProduct: paidRecurring, + currentEpochMs, + billingStartsAt: pastStartsAt, + }), + ).toBe("now"); + }); + + test("a free product has no recurring cycle to backdate", () => { + expect( + setupBillingCycleAnchor({ + customerProduct: undefined, + newFullProduct: freeProduct, + currentEpochMs, + billingStartsAt: pastStartsAt, + }), + ).toBe("now"); + }); + + test("a one-off product has no recurring cycle to backdate", () => { + expect( + setupBillingCycleAnchor({ + customerProduct: undefined, + newFullProduct: oneOffProduct, + currentEpochMs, + billingStartsAt: pastStartsAt, + }), + ).toBe("now"); + }); + + test("an explicitly requested anchor wins over a backdated start", () => { + expect( + setupBillingCycleAnchor({ + customerProduct: undefined, + newFullProduct: paidRecurring, + currentEpochMs, + billingStartsAt: pastStartsAt, + requestedBillingCycleAnchor: futureStartsAt, + }), + ).toBe(futureStartsAt); + }); +}); diff --git a/server/tests/unit/billing/stripe-backdate-start-date-utils.spec.ts b/server/tests/unit/billing/stripe-backdate-start-date-utils.spec.ts new file mode 100644 index 000000000..4d8beefe9 --- /dev/null +++ b/server/tests/unit/billing/stripe-backdate-start-date-utils.spec.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from "bun:test"; +import { addInterval, BillingInterval, ms, type Price } from "@autumn/shared"; +import { prices } from "@tests/utils/fixtures/db/prices"; +import { products } from "@tests/utils/fixtures/db/products"; +import { + countBackdatedPeriodsForPrice, + getBackdatedCycleCountForPrice, + STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT, +} from "@/internal/billing/v2/utils/backdate/countBackdatedPeriods"; +import { + assertStripeBackdateInvoiceLineItemLimit, + countStripeBackdateInvoiceLineItems, +} from "@/internal/billing/v2/utils/backdate/stripeBackdateInvoiceLimit"; + +const startsAt = Date.UTC(2026, 0, 1); + +const dateAfterCycles = ({ + cycles, + interval = BillingInterval.Month, + intervalCount = 1, +}: { + cycles: number; + interval?: BillingInterval; + intervalCount?: number; +}) => + addInterval({ + from: startsAt, + interval, + intervalCount: cycles * intervalCount, + }); + +const fixedPrice = ({ + id, + interval = BillingInterval.Month, + intervalCount = 1, +}: { + id: string; + interval?: BillingInterval; + intervalCount?: number; +}) => { + const price = prices.createFixed({ id }); + price.config.interval = interval; + price.config.interval_count = intervalCount; + return price; +}; + +describe("stripe backdate start date utilities", () => { + test("allows the earliest start date that creates exactly Stripe's line item limit", () => { + const product = products.createFull({ + prices: [fixedPrice({ id: "monthly" })], + }); + const currentEpochMs = dateAfterCycles({ + cycles: STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT, + }); + + expect( + countStripeBackdateInvoiceLineItems({ + products: [product], + startsAt, + currentEpochMs, + }), + ).toBe(STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT); + expect(() => + assertStripeBackdateInvoiceLineItemLimit({ + products: [product], + startsAt, + currentEpochMs, + }), + ).not.toThrow(); + }); + + test("rejects a start date that would exceed Stripe's line item limit", () => { + const product = products.createFull({ + prices: [fixedPrice({ id: "monthly" })], + }); + const currentEpochMs = dateAfterCycles({ + cycles: STRIPE_BACKDATE_INVOICE_LINE_ITEM_LIMIT + 1, + }); + + expect(() => + assertStripeBackdateInvoiceLineItemLimit({ + products: [product], + startsAt, + currentEpochMs, + }), + ).toThrow("at most 250 line items"); + }); + + test("counts each recurring Stripe price toward the backdated invoice limit", () => { + const product = products.createFull({ + prices: [ + fixedPrice({ id: "base" }), + fixedPrice({ id: "addon" }), + prices.createOneOff({ id: "setup" }) as Price, + ], + }); + const currentEpochMs = dateAfterCycles({ cycles: 126 }); + + expect( + countStripeBackdateInvoiceLineItems({ + products: [product], + startsAt, + currentEpochMs, + }), + ).toBe(252); + expect(() => + assertStripeBackdateInvoiceLineItemLimit({ + products: [product], + startsAt, + currentEpochMs, + }), + ).toThrow("at most 250 line items"); + }); +}); + +describe("backdated cycle counting", () => { + const monthly = fixedPrice({ id: "monthly" }); + + test("counts one elapsed period part-way through the first cycle", () => { + expect( + countBackdatedPeriodsForPrice({ + price: monthly, + startsAt, + currentEpochMs: startsAt + ms.days(14), + }), + ).toBe(1); + }); + + test("counts two elapsed periods once the second cycle has begun", () => { + const intoSecondCycle = + addInterval({ from: startsAt, interval: BillingInterval.Month }) + + ms.days(14); + expect( + countBackdatedPeriodsForPrice({ + price: monthly, + startsAt, + currentEpochMs: intoSecondCycle, + }), + ).toBe(2); + }); + + test("counts zero elapsed periods for a one-off price", () => { + expect( + countBackdatedPeriodsForPrice({ + price: prices.createOneOff({ id: "setup" }) as Price, + startsAt, + currentEpochMs: startsAt + ms.days(40), + }), + ).toBe(0); + }); + + test("cycle count floors at 1 when nothing has elapsed yet", () => { + expect( + getBackdatedCycleCountForPrice({ + price: monthly, + startsAt, + currentEpochMs: startsAt - ms.days(5), + }), + ).toBe(1); + }); + + test("cycle count reflects multiple elapsed periods", () => { + const intoThirdCycle = + addInterval({ + from: startsAt, + interval: BillingInterval.Month, + intervalCount: 2, + }) + ms.days(14); + expect( + getBackdatedCycleCountForPrice({ + price: monthly, + startsAt, + currentEpochMs: intoThirdCycle, + }), + ).toBe(3); + }); +}); diff --git a/server/tests/unit/billing/stripe/discounts/apply-percent-off-discount-to-line-items.spec.ts b/server/tests/unit/billing/stripe/discounts/apply-percent-off-discount-to-line-items.spec.ts index 9e2e3080b..8784e9e8f 100644 --- a/server/tests/unit/billing/stripe/discounts/apply-percent-off-discount-to-line-items.spec.ts +++ b/server/tests/unit/billing/stripe/discounts/apply-percent-off-discount-to-line-items.spec.ts @@ -212,7 +212,6 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => { }); test("handles decimal rounding correctly", () => { - // 33 * 10% = 3.3, should round to 3 const lineItems = [lineItemFixtures.charge({ amount: 33 })]; const discount = discounts.tenPercentOff(); @@ -221,8 +220,8 @@ describe(chalk.yellowBright("applyPercentOffDiscountToLineItems"), () => { discount, }); - expect(result[0].discounts[0].amountOff).toBe(3); - expect(result[0].amountAfterDiscounts).toBe(30); + expect(result[0].discounts[0].amountOff).toBe(3.3); + expect(result[0].amountAfterDiscounts).toBe(29.7); }); test("zero amount line item is skipped", () => { diff --git a/server/tests/unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts b/server/tests/unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts index 261b9fbd8..db928de2e 100644 --- a/server/tests/unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts +++ b/server/tests/unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts @@ -6,7 +6,11 @@ */ import { describe, expect, test } from "bun:test"; -import type { LineItem, StripeDiscountWithCoupon } from "@autumn/shared"; +import { + BillingInterval, + type LineItem, + type StripeDiscountWithCoupon, +} from "@autumn/shared"; import { lineItems as lineItemFixtures } from "@tests/utils/fixtures/billing/lineItems"; import { discounts } from "@tests/utils/fixtures/db/discounts"; import chalk from "chalk"; @@ -14,6 +18,53 @@ import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers // ============ TESTS ============ +const startsAt = Date.UTC(2026, 0, 1); + +const backdatedCharge = ({ + amount, + cycleCount, +}: { + amount: number; + cycleCount: number; +}): LineItem => { + const lineItem = lineItemFixtures.charge({ amount }); + return { + ...lineItem, + context: { + ...lineItem.context, + backdate: { startsAt, cycleCount }, + price: { + ...lineItem.context.price, + config: { + ...(lineItem.context.price.config ?? {}), + interval: BillingInterval.Month, + interval_count: 1, + }, + }, + }, + }; +}; + +const withDuration = ({ + discount, + duration, + durationInMonths, +}: { + discount: StripeDiscountWithCoupon; + duration: "forever" | "once" | "repeating"; + durationInMonths?: number; +}): StripeDiscountWithCoupon => ({ + ...discount, + source: { + ...discount.source, + coupon: { + ...discount.source.coupon, + duration, + duration_in_months: durationInMonths ?? null, + }, + }, +}); + describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => { describe(chalk.cyan("Empty inputs"), () => { test("empty line items returns empty array", () => { @@ -74,6 +125,63 @@ describe(chalk.yellowBright("applyStripeDiscountsToLineItems"), () => { expect(result[0].discounts).toHaveLength(1); expect(result[0].discounts[0].amountOff).toBe(15); }); + + test("amount_off discount applies once to a backdated invoice", () => { + const lineItems = [backdatedCharge({ amount: 40, cycleCount: 2 })]; + const discountList = [ + withDuration({ + discount: discounts.amountOff({ amountOffCents: 500 }), + duration: "repeating", + durationInMonths: 12, + }), + ]; + + const result = applyStripeDiscountsToLineItems({ + lineItems, + discounts: discountList, + }); + + expect(result[0].amountAfterDiscounts).toBe(35); + expect(result[0].discounts[0].amountOff).toBe(5); + }); + + test("one-month amount_off discount only applies to one backdated cycle", () => { + const lineItems = [backdatedCharge({ amount: 40, cycleCount: 2 })]; + const discountList = [ + withDuration({ + discount: discounts.amountOff({ amountOffCents: 500 }), + duration: "repeating", + durationInMonths: 1, + }), + ]; + + const result = applyStripeDiscountsToLineItems({ + lineItems, + discounts: discountList, + }); + + expect(result[0].amountAfterDiscounts).toBe(35); + expect(result[0].discounts[0].amountOff).toBe(5); + }); + + test("duration-limited percent_off only discounts eligible backdated cycles", () => { + const lineItems = [backdatedCharge({ amount: 40, cycleCount: 2 })]; + const discountList = [ + withDuration({ + discount: discounts.percentOff({ percentOff: 50 }), + duration: "repeating", + durationInMonths: 1, + }), + ]; + + const result = applyStripeDiscountsToLineItems({ + lineItems, + discounts: discountList, + }); + + expect(result[0].amountAfterDiscounts).toBe(30); + expect(result[0].discounts[0].amountOff).toBe(10); + }); }); describe(chalk.cyan("Multiple discounts stacking"), () => { diff --git a/server/tests/unit/billing/stripe/match-utils/match-stripe-inline-price.spec.ts b/server/tests/unit/billing/stripe/match-utils/match-stripe-inline-price.spec.ts index f554a67b3..e0add57d0 100644 --- a/server/tests/unit/billing/stripe/match-utils/match-stripe-inline-price.spec.ts +++ b/server/tests/unit/billing/stripe/match-utils/match-stripe-inline-price.spec.ts @@ -20,6 +20,10 @@ const subscriptionItem = ({ priceId = "price_inline", interval = "month", amount = "1000", + billingScheme = "per_unit", + taxBehavior = "unspecified", + tiersMode = null, + transformQuantity = null, }: { id?: string; customerPriceId?: string | null; @@ -27,6 +31,10 @@ const subscriptionItem = ({ priceId?: string; interval?: Stripe.Price.Recurring.Interval; amount?: string; + billingScheme?: Stripe.Price.BillingScheme; + taxBehavior?: Stripe.Price.TaxBehavior | null; + tiersMode?: Stripe.Price.TiersMode | null; + transformQuantity?: Stripe.Price.TransformQuantity | null; } = {}) => ({ id, @@ -39,7 +47,11 @@ const subscriptionItem = ({ object: "price", product: { id: "stripe_prod_inline" }, currency: "usd", + billing_scheme: billingScheme, + tax_behavior: taxBehavior, recurring: { interval, interval_count: 1 }, + tiers_mode: tiersMode, + transform_quantity: transformQuantity, unit_amount_decimal: amount, }, }) as unknown as Stripe.SubscriptionItem; @@ -54,6 +66,25 @@ describe("matchStripeInlinePrice", () => { ).toBe(true); }); + test("does not match non-inline-compatible Stripe prices", () => { + const incompatiblePrices = [ + subscriptionItem({ billingScheme: "tiered", tiersMode: "graduated" }), + subscriptionItem({ + transformQuantity: { divide_by: 10, round: "up" }, + }), + subscriptionItem({ taxBehavior: "exclusive" }), + ]; + + for (const item of incompatiblePrices) { + expect( + stripeInlinePriceMatchesStripePrice({ + inlinePrice, + stripePrice: item.price, + }), + ).toBe(false); + } + }); + test("requires Autumn customer price metadata and matching price shape", () => { const items = [ subscriptionItem({ id: "si_wrong_interval", interval: "year" }), diff --git a/server/tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts b/server/tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts index bf7816190..03ff3d986 100644 --- a/server/tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts +++ b/server/tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts @@ -144,7 +144,7 @@ describe( // Phase 2: Pro expect(phases[1].start_date).toBe(msToSeconds(proStartMs)); expect(phases[1].end_date).toBeUndefined(); - expect(phases[1].proration_behavior).toBeUndefined(); + expect(phases[1].proration_behavior).toBe("always_invoice"); expectPhaseItems(phases[1].items!, getStripePriceIds(pro)); }); diff --git a/server/tests/unit/compiler/customer/basic.test.ts b/server/tests/unit/compiler/customer/basic.test.ts index ea305dd0a..e87a0cc62 100644 --- a/server/tests/unit/compiler/customer/basic.test.ts +++ b/server/tests/unit/compiler/customer/basic.test.ts @@ -11,8 +11,8 @@ const ctx = contexts.create({ features }); const ambient = { orgId: "org_test", env: "live" }; const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?"; -const PLAN_AMBIENT = "cp.status IN (?, ?)"; -const PLAN_AMBIENT_PARAMS = ["active", "past_due"]; +const PLAN_AMBIENT = "cp.status IN (?, ?, ?)"; +const PLAN_AMBIENT_PARAMS = ["active", "past_due", "scheduled"]; const normalize = (sql: string) => sql.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim(); diff --git a/server/tests/unit/compiler/customer/derived-and-or.test.ts b/server/tests/unit/compiler/customer/derived-and-or.test.ts index 273c20e93..88648f77c 100644 --- a/server/tests/unit/compiler/customer/derived-and-or.test.ts +++ b/server/tests/unit/compiler/customer/derived-and-or.test.ts @@ -11,8 +11,8 @@ const ctx = contexts.create({ features }); const ambient = { orgId: "org_test", env: "live" }; const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?"; -const PLAN_AMBIENT = "cp.status IN (?, ?)"; -const PLAN_AMBIENT_PARAMS = ["active", "past_due"]; +const PLAN_AMBIENT = "cp.status IN (?, ?, ?)"; +const PLAN_AMBIENT_PARAMS = ["active", "past_due", "scheduled"]; const BASE_PRICE_EXISTS = [ "(SELECT base_cpr.id FROM customer_prices base_cpr", diff --git a/server/tests/unit/compiler/customer/nested-item.test.ts b/server/tests/unit/compiler/customer/nested-item.test.ts index 3414b9790..1b7502558 100644 --- a/server/tests/unit/compiler/customer/nested-item.test.ts +++ b/server/tests/unit/compiler/customer/nested-item.test.ts @@ -11,8 +11,8 @@ const ctx = contexts.create({ features }); const ambient = { orgId: "org_test", env: "live" }; const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?"; -const PLAN_AMBIENT = "cp.status IN (?, ?)"; -const PLAN_AMBIENT_PARAMS = ["active", "past_due"]; +const PLAN_AMBIENT = "cp.status IN (?, ?, ?)"; +const PLAN_AMBIENT_PARAMS = ["active", "past_due", "scheduled"]; const ITEM_FROM = [ "customer_entitlements ce", diff --git a/server/tests/unit/compiler/customer/planner.test.ts b/server/tests/unit/compiler/customer/planner.test.ts new file mode 100644 index 000000000..18a3d8104 --- /dev/null +++ b/server/tests/unit/compiler/customer/planner.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from "bun:test"; +import type { Feature } from "@autumn/shared"; +import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js"; +import { buildCustomerCandidateQuery } from "@autumn/shared/api/migrations/filters/planner/buildCustomerCandidateQuery.js"; +import type { CustomerFilter } from "@autumn/shared/api/migrations/filters/customerFilter.js"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; + +const features: Feature[] = [ + { id: "credits", internal_id: "fea_credits_internal" } as Feature, +]; + +const ctx = contexts.create({ features }); +const ambient = { orgId: "org_test", env: "live" }; +const RELEVANT_STATUS_PARAMS = ["active", "past_due", "scheduled"]; + +const normalize = (sql: string) => + sql.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim(); + +const buildCandidate = (filter: CustomerFilter) => + buildCustomerCandidateQuery({ + filter, + ctx: { features: ctx.features }, + ambient, + }); + +const expectFallbackWhereParity = (filter: CustomerFilter) => { + const candidate = buildCandidate(filter); + const fallback = compileFilter({ + filter, + ctx: { features: ctx.features }, + ambient, + }); + + expect(normalize(candidate.where.sql)).toBe(normalize(fallback.sql)); + expect(candidate.where.params).toEqual(fallback.params); + return candidate; +}; + +describe("customer filter planner", () => { + test("plan.plan_id eq uses a products-driven candidate source", () => { + const candidate = expectFallbackWhereParity({ + plan: { plan_id: "enterprise" }, + }); + + expect(candidate.accessPath).toEqual({ + kind: "planned", + id: "plan.plan_id", + }); + expect(normalize(candidate.source.sql)).toBe( + normalize(` + (WITH plan_products AS MATERIALIZED ( + SELECT p.internal_id FROM products p + WHERE p.org_id = ? AND p.env = ? + AND p.id = ? + ) SELECT DISTINCT c.internal_id, c.id, c.name, c.email, c.org_id, c.env + FROM plan_products pp + JOIN customer_products cp ON cp.internal_product_id = pp.internal_id + JOIN customers c ON c.internal_id = cp.internal_customer_id + WHERE cp.status IN (?, ?, ?) + AND c.org_id = ? + AND c.env = ?) c + `), + ); + expect(candidate.source.params).toEqual([ + "org_test", + "live", + "enterprise", + ...RELEVANT_STATUS_PARAMS, + "org_test", + "live", + ]); + }); + + test("plan.plan_id in uses the same candidate path", () => { + const candidate = expectFallbackWhereParity({ + plan: { plan_id: { $in: ["enterprise", "pro"] } }, + }); + + expect(candidate.accessPath).toEqual({ + kind: "planned", + id: "plan.plan_id", + }); + expect(normalize(candidate.source.sql)).toContain("p.id IN (?, ?)"); + expect(candidate.source.params).toEqual([ + "org_test", + "live", + "enterprise", + "pro", + ...RELEVANT_STATUS_PARAMS, + "org_test", + "live", + ]); + }); + + test("compound filters use plan_id as a candidate and keep fallback semantics", () => { + const candidate = expectFallbackWhereParity({ + plan: { + plan_id: "enterprise", + item: { feature_id: "credits" }, + }, + }); + + expect(candidate.accessPath).toEqual({ + kind: "planned", + id: "plan.plan_id", + }); + expect(normalize(candidate.source.sql)).toContain("p.id = ?"); + expect(normalize(candidate.where.sql)).toContain("e.internal_feature_id = ?"); + }); + + test("plan_id + version keeps version as a residual fallback predicate", () => { + const candidate = expectFallbackWhereParity({ + plan: { + plan_id: "enterprise", + version: 2, + }, + }); + + expect(candidate.accessPath).toEqual({ + kind: "planned", + id: "plan.plan_id", + }); + expect(normalize(candidate.source.sql)).toContain("p.id = ?"); + expect(normalize(candidate.source.sql)).not.toContain("p.version = ?"); + expect(normalize(candidate.where.sql)).toContain( + "(p.id = ? AND p.version = ?)", + ); + expect(candidate.where.params).toEqual([ + "org_test", + "live", + ...RELEVANT_STATUS_PARAMS, + "enterprise", + 2, + ]); + }); + + test("plan_id + custom keeps customer-product custom state as a residual predicate", () => { + const candidate = expectFallbackWhereParity({ + plan: { + plan_id: "enterprise", + custom: false, + }, + }); + + expect(candidate.accessPath).toEqual({ + kind: "planned", + id: "plan.plan_id", + }); + expect(normalize(candidate.source.sql)).not.toContain("cp.is_custom = ?"); + expect(normalize(candidate.where.sql)).toContain( + "(p.id = ? AND cp.is_custom = ?)", + ); + expect(candidate.where.params).toEqual([ + "org_test", + "live", + ...RELEVANT_STATUS_PARAMS, + "enterprise", + false, + ]); + }); + + test("plan_id + price keeps base-price existence as a residual predicate", () => { + const candidate = expectFallbackWhereParity({ + plan: { + plan_id: "enterprise", + price: { $ne: null }, + }, + }); + + expect(candidate.accessPath).toEqual({ + kind: "planned", + id: "plan.plan_id", + }); + expect(normalize(candidate.source.sql)).not.toContain("base_cpr.id"); + expect(normalize(candidate.where.sql)).toContain("base_cpr.id"); + expect(normalize(candidate.where.sql)).toContain("IS NOT NULL"); + }); + + test("plan_id + paid/recurring derived filters remain residual predicates", () => { + const candidate = expectFallbackWhereParity({ + plan: { + plan_id: "enterprise", + paid: true, + recurring: true, + }, + }); + + expect(candidate.accessPath).toEqual({ + kind: "planned", + id: "plan.plan_id", + }); + expect(normalize(candidate.source.sql)).not.toContain("customer_prices"); + expect(normalize(candidate.where.sql)).toContain("customer_prices cpr"); + expect(normalize(candidate.where.sql)).toContain( + "pr.config->>'interval' <> 'one_off'", + ); + }); + + test("plan_id + item rollover keeps entitlement rollover as a residual predicate", () => { + const candidate = expectFallbackWhereParity({ + plan: { + plan_id: "enterprise", + item: { rollover: { $ne: null } }, + }, + }); + + expect(candidate.accessPath).toEqual({ + kind: "planned", + id: "plan.plan_id", + }); + expect(normalize(candidate.source.sql)).not.toContain("e.rollover"); + expect(normalize(candidate.where.sql)).toContain("e.rollover IS NOT NULL"); + }); + + test("top-level item rollover falls back until an entitlement access path exists", () => { + const candidate = expectFallbackWhereParity({ + item: { rollover: { $ne: null } }, + }); + + expect(candidate.accessPath).toEqual({ kind: "fallback" }); + expect(normalize(candidate.source.sql)).toBe("customers c"); + expect(normalize(candidate.where.sql)).toContain("e.rollover IS NOT NULL"); + }); + + test("plan_id inside an OR falls back to avoid dropping other branches", () => { + const candidate = expectFallbackWhereParity({ + plan: { + $or: [{ plan_id: "enterprise" }, { paid: true }], + }, + }); + + expect(candidate.accessPath).toEqual({ kind: "fallback" }); + expect(normalize(candidate.source.sql)).toBe("customers c"); + }); + + test("negative plan quantifiers fall back", () => { + const candidate = expectFallbackWhereParity({ + plan: { $none: { plan_id: "enterprise" } }, + }); + + expect(candidate.accessPath).toEqual({ kind: "fallback" }); + expect(normalize(candidate.source.sql)).toBe("customers c"); + }); + + test("direct customer filters remain customer-rooted", () => { + const candidate = expectFallbackWhereParity({ + customer_id: "cus_123", + }); + + expect(candidate.accessPath).toEqual({ kind: "fallback" }); + expect(normalize(candidate.source.sql)).toBe("customers c"); + }); +}); diff --git a/server/tests/unit/compiler/customer/rollover-existence.test.ts b/server/tests/unit/compiler/customer/rollover-existence.test.ts index 976ec97a6..896bcf3b5 100644 --- a/server/tests/unit/compiler/customer/rollover-existence.test.ts +++ b/server/tests/unit/compiler/customer/rollover-existence.test.ts @@ -11,8 +11,8 @@ const ctx = contexts.create({ features }); const ambient = { orgId: "org_test", env: "live" }; const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?"; -const PLAN_AMBIENT = "cp.status IN (?, ?)"; -const PLAN_AMBIENT_PARAMS = ["active", "past_due"]; +const PLAN_AMBIENT = "cp.status IN (?, ?, ?)"; +const PLAN_AMBIENT_PARAMS = ["active", "past_due", "scheduled"]; const ITEM_FROM = [ "customer_entitlements ce", diff --git a/server/tests/unit/compiler/plan/version.test.ts b/server/tests/unit/compiler/plan/version.test.ts index 90ceefe48..fafb8b012 100644 --- a/server/tests/unit/compiler/plan/version.test.ts +++ b/server/tests/unit/compiler/plan/version.test.ts @@ -20,8 +20,8 @@ const ctx = contexts.create({ features: [] }); const ambient = { orgId: "org_test", env: "live" }; const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?"; -const PLAN_AMBIENT = "cp.status IN (?, ?)"; -const PLAN_AMBIENT_PARAMS = ["active", "past_due"]; +const PLAN_AMBIENT = "cp.status IN (?, ?, ?)"; +const PLAN_AMBIENT_PARAMS = ["active", "past_due", "scheduled"]; const PLAN_ROOT_AMBIENT = "p.org_id = ? AND p.env = ?"; const normalize = (sql: string) => diff --git a/server/tests/unit/customers/dashboard-product-filter.test.ts b/server/tests/unit/customers/dashboard-product-filter.test.ts new file mode 100644 index 000000000..8cc8cf9b9 --- /dev/null +++ b/server/tests/unit/customers/dashboard-product-filter.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { sql } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + getCustomerListFilterSql, + parseDashboardVersionFilter, +} from "@/internal/customers/getFullCusQuery.js"; + +const dialect = new PgDialect(); +const normalize = (value: string) => value.replace(/\s+/g, " ").trim(); + +const render = (filter: ReturnType) => + dialect.sqlToQuery(sql`SELECT * FROM customers c WHERE true ${filter}`); + +describe("dashboard product filters", () => { + test("parses numbered and custom product version selections", () => { + expect( + parseDashboardVersionFilter([ + "pro:2", + "pro:custom", + "", + "missing-version", + "bad:not-a-number", + ]), + ).toEqual([ + { productId: "pro", version: 2 }, + { productId: "pro", custom: true }, + ]); + }); + + test("custom plan selection filters customer_products.is_custom", () => { + const { sql: query, params } = render( + getCustomerListFilterSql({ + productVersionFilters: [{ productId: "pro", custom: true }], + }), + ); + + expect(normalize(query)).toContain("cp_dash.product_id = $3"); + expect(normalize(query)).toContain("cp_dash.is_custom = true"); + expect(normalize(query)).not.toContain("JOIN products p_dash"); + expect(params).toEqual(["active", "past_due", "pro"]); + }); + + test("custom and numbered selections share the product filter group", () => { + const { sql: query, params } = render( + getCustomerListFilterSql({ + productVersionFilters: [ + { productId: "pro", version: 2 }, + { productId: "pro", custom: true }, + ], + }), + ); + + const normalized = normalize(query); + expect(normalized).toContain("JOIN products p_dash"); + expect(normalized).toContain("p_dash.version = $4"); + expect(normalized).toContain("cp_dash.is_custom = true"); + expect(params).toEqual(["active", "past_due", "pro", 2, "pro"]); + }); +}); diff --git a/server/tests/unit/customers/transfer-related-customer-products.test.ts b/server/tests/unit/customers/transfer-related-customer-products.test.ts new file mode 100644 index 000000000..8f4874bd6 --- /dev/null +++ b/server/tests/unit/customers/transfer-related-customer-products.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import type { Entity, FullCusProduct, FullCustomer } from "@autumn/shared"; +import { + findTransferCustomerProduct, + getTransferCustomerProducts, +} from "@/internal/customers/handlers/handleTransferProduct/transferRelatedCustomerProducts.js"; + +const sourceEntity = { + id: "entity_public_1", + internal_id: "entity_internal_1", +} as Entity; + +const product = { + id: "pro", + group: "main", + is_add_on: false, +}; + +const createCustomerProduct = ({ + id, + productId = product.id, + internalEntityId = sourceEntity.internal_id, +}: { + id: string; + productId?: string; + internalEntityId?: string | null; +}) => + ({ + id, + internal_entity_id: internalEntityId, + product_id: productId, + product: { + id: productId, + group: product.group, + is_add_on: product.is_add_on, + }, + }) as FullCusProduct; + +const fullCustomer = { + customer_products: [ + createCustomerProduct({ id: "cus_prod_target" }), + createCustomerProduct({ id: "cus_prod_related" }), + createCustomerProduct({ + id: "cus_prod_other_scope", + internalEntityId: "entity_internal_2", + }), + ], +} as FullCustomer; + +describe("transfer customer product selection", () => { + test("finds the exact customer product when an id is provided", () => { + const result = findTransferCustomerProduct({ + fullCustomer, + fromEntity: sourceEntity, + productId: product.id, + customerProductId: "cus_prod_related", + }); + + expect(result?.id).toBe("cus_prod_related"); + }); + + test("targets only the selected customer product when an id is provided", () => { + const results = getTransferCustomerProducts({ + fullCustomer, + fromEntity: sourceEntity, + product, + customerProductId: "cus_prod_target", + }); + + expect(results.map((customerProduct) => customerProduct.id)).toEqual([ + "cus_prod_target", + ]); + }); + + test("keeps legacy related-product selection when no id is provided", () => { + const results = getTransferCustomerProducts({ + fullCustomer, + fromEntity: sourceEntity, + product, + }); + + expect(results.map((customerProduct) => customerProduct.id)).toEqual([ + "cus_prod_target", + "cus_prod_related", + ]); + }); +}); diff --git a/server/tests/unit/features/get-credit-cost.test.ts b/server/tests/unit/features/get-credit-cost.test.ts new file mode 100644 index 000000000..5f64b31fa --- /dev/null +++ b/server/tests/unit/features/get-credit-cost.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { + ErrCode, + type Feature, + FeatureType, + FeatureUsageType, +} from "@autumn/shared"; +import { + getModelCreditCost, + getModelCreditCostBreakdown, +} from "@/internal/features/aiCreditSystemUtils.js"; +import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; + +// Uses custom/* models so pricing resolves offline (no models.dev fetch). +const CUSTOM_MODEL = "custom/foo"; + +const aiCreditFeature: Feature = { + internal_id: "fe_ai_credits", + org_id: "org_test", + created_at: Date.now(), + env: "sandbox" as Feature["env"], + id: "ai_credits", + name: "AI Credits", + type: FeatureType.AiCreditSystem, + config: { schema: [], usage_type: FeatureUsageType.Single }, + archived: false, + event_names: [], + model_markups: { + [CUSTOM_MODEL]: { markup: 0, input_cost: 1000, output_cost: 2000 }, + }, +}; + +describe("getCreditCost — AI credit system schema math", () => { + test("self feature maps 1:1 (plain /track values, queued replays)", () => { + const cost = getCreditCost({ + featureId: aiCreditFeature.id, + creditSystem: aiCreditFeature, + amount: 5.25, + }); + expect(cost).toBe(5.25); + }); + + test("self feature defaults to a per-unit cost of 1", () => { + const cost = getCreditCost({ + featureId: aiCreditFeature.id, + creditSystem: aiCreditFeature, + }); + expect(cost).toBe(1); + }); + + test("non-self feature throws — AI credit systems have no schema", () => { + expect(() => + getCreditCost({ + featureId: "some_other_feature", + creditSystem: aiCreditFeature, + amount: 5, + }), + ).toThrow(/no schema/); + }); +}); + +describe("getModelCreditCost — token pricing", () => { + test("prices through the model markup config", async () => { + const cost = await getModelCreditCost({ + modelName: CUSTOM_MODEL, + creditSystem: aiCreditFeature, + input: 1000, + output: 500, + }); + // (1000 * 1000 + 2000 * 500) / 1_000_000 = 2.0 + expect(cost).toBeCloseTo(2.0, 10); + }); + + test("custom model without configured costs throws", async () => { + expect( + getModelCreditCost({ + modelName: "custom/unconfigured", + creditSystem: aiCreditFeature, + input: 100, + output: 50, + }), + ).rejects.toMatchObject({ + code: ErrCode.InvalidRequest, + }); + }); +}); + +describe("getModelCreditCostBreakdown — pricing audit trail", () => { + test("records base cost, markup source, and effective rates", async () => { + const withMarkup: Feature = { + ...aiCreditFeature, + model_markups: { + [CUSTOM_MODEL]: { markup: 50, input_cost: 1000, output_cost: 2000 }, + }, + }; + const breakdown = await getModelCreditCostBreakdown({ + modelName: CUSTOM_MODEL, + creditSystem: withMarkup, + input: 1000, + output: 500, + }); + + expect(breakdown.baseCost).toBeCloseTo(2.0, 10); + expect(breakdown.cost).toBeCloseTo(3.0, 10); + expect(breakdown.markup).toBe(50); + expect(breakdown.markupSource).toBe("model"); + expect(breakdown.tierApplied).toBe(false); + expect(breakdown.rates.input).toBe(1000); + expect(breakdown.rates.output).toBe(2000); + // Unpublished pools fall back to the text rates. + expect(breakdown.rates.cacheRead).toBe(1000); + expect(breakdown.rates.reasoning).toBe(2000); + }); + + test("explicit markup 0 reports source model; no markup anywhere reports none", async () => { + const explicitZero = await getModelCreditCostBreakdown({ + modelName: CUSTOM_MODEL, + creditSystem: aiCreditFeature, + input: 1000, + output: 500, + }); + expect(explicitZero.markup).toBe(0); + expect(explicitZero.markupSource).toBe("model"); + + const unconfigured = await getModelCreditCostBreakdown({ + modelName: CUSTOM_MODEL, + creditSystem: { + ...aiCreditFeature, + model_markups: { + [CUSTOM_MODEL]: { input_cost: 1000, output_cost: 2000 }, + }, + }, + input: 1000, + output: 500, + }); + expect(unconfigured.markup).toBe(0); + expect(unconfigured.markupSource).toBe("none"); + }); + + test("reports provider and default markup sources", async () => { + const noModelMarkup: Feature = { + ...aiCreditFeature, + config: { + schema: [], + usage_type: FeatureUsageType.Single, + default_markup: 10, + provider_markups: { custom: { markup: 20 } }, + }, + model_markups: { + [CUSTOM_MODEL]: { input_cost: 1000, output_cost: 2000 }, + }, + }; + + const provider = await getModelCreditCostBreakdown({ + modelName: CUSTOM_MODEL, + creditSystem: noModelMarkup, + input: 1000, + output: 500, + }); + expect(provider.markup).toBe(20); + expect(provider.markupSource).toBe("provider"); + + const defaultOnly = await getModelCreditCostBreakdown({ + modelName: CUSTOM_MODEL, + creditSystem: { + ...noModelMarkup, + config: { + schema: [], + usage_type: FeatureUsageType.Single, + default_markup: 10, + }, + }, + input: 1000, + output: 500, + }); + expect(defaultOnly.markup).toBe(10); + expect(defaultOnly.markupSource).toBe("default"); + }); +}); diff --git a/server/tests/unit/features/get-model-pricing.test.ts b/server/tests/unit/features/get-model-pricing.test.ts new file mode 100644 index 000000000..9b06aedeb --- /dev/null +++ b/server/tests/unit/features/get-model-pricing.test.ts @@ -0,0 +1,132 @@ +import { afterAll, afterEach, expect, mock, test } from "bun:test"; +import { ErrCode } from "@autumn/shared"; + +// Map-backed CacheManager stub — getModelsDevPricing's cache key is shared +// with the dev server, so the real Redis must never be touched here. +const store = new Map(); +const setJsonCalls: { key: string; value: unknown; ttl?: number }[] = []; + +mock.module("@/utils/cacheUtils/CacheManager.js", () => ({ + CacheManager: { + getJson: async (key: string) => store.get(key) ?? null, + setJson: async (key: string, value: unknown, ttl?: number) => { + setJsonCalls.push({ key, value, ttl }); + store.set(key, value); + }, + }, +})); + +const { getModelsDevPricing } = await import( + "@/internal/features/utils/getModelPricing.js" +); + +const PRIMARY_KEY = "models_dev_pricing"; +const STALE_KEY = "models_dev_pricing_stale"; + +const pricingData = { + anthropic: { id: "anthropic", name: "Anthropic", models: {} }, +}; +const stalePricingData = { + openai: { id: "openai", name: "OpenAI", models: {} }, +}; + +const realFetch = globalThis.fetch; +let fetchCalls = 0; + +const stubFetch = (impl: () => Promise) => { + globalThis.fetch = Object.assign( + async () => { + fetchCalls++; + return await impl(); + }, + { preconnect: realFetch.preconnect }, + ); +}; + +afterEach(() => { + store.clear(); + setJsonCalls.length = 0; + fetchCalls = 0; + globalThis.fetch = realFetch; +}); + +afterAll(() => { + mock.restore(); + globalThis.fetch = realFetch; +}); + +test("primary cache hit returns cached data without fetching", async () => { + store.set(PRIMARY_KEY, pricingData); + stubFetch(() => { + throw new Error("should not fetch"); + }); + + const result = await getModelsDevPricing(); + + expect(result).toEqual(pricingData); + expect(fetchCalls).toBe(0); +}); + +test("cache miss fetches and populates primary + stale caches", async () => { + stubFetch(async () => Response.json(pricingData)); + + const result = await getModelsDevPricing(); + + expect(result).toEqual(pricingData); + expect(fetchCalls).toBe(1); + + // Cache writes are fire-and-forget — flush microtasks before asserting + await Bun.sleep(0); + expect(setJsonCalls).toEqual([ + { key: PRIMARY_KEY, value: pricingData, ttl: 60 * 60 * 3 }, + { key: STALE_KEY, value: pricingData, ttl: 60 * 60 * 24 * 3 }, + ]); +}); + +test("non-ok response falls back to the stale cache", async () => { + store.set(STALE_KEY, stalePricingData); + stubFetch(async () => new Response("oops", { status: 500 })); + + const result = await getModelsDevPricing(); + + expect(result).toEqual(stalePricingData); +}); + +test("fetch network error falls back to the stale cache", async () => { + store.set(STALE_KEY, stalePricingData); + stubFetch(() => { + throw new Error("network down"); + }); + + const result = await getModelsDevPricing(); + + expect(result).toEqual(stalePricingData); +}); + +test("fetch failure with no stale cache throws InternalError", async () => { + stubFetch(() => { + throw new Error("network down"); + }); + + await expect(getModelsDevPricing()).rejects.toMatchObject({ + code: ErrCode.InternalError, + message: "Failed to fetch models.dev pricing and no cache available", + }); +}); + +test("fetch carries an abort timeout so a hanging models.dev cannot hang tracks", async () => { + let capturedSignal: AbortSignal | undefined; + globalThis.fetch = Object.assign( + async (_input: unknown, init?: RequestInit) => { + fetchCalls++; + capturedSignal = init?.signal ?? undefined; + return Response.json(pricingData); + }, + { preconnect: realFetch.preconnect }, + ) as typeof fetch; + + await getModelsDevPricing(); + + expect(capturedSignal).toBeInstanceOf(AbortSignal); + expect(capturedSignal?.aborted).toBe(false); +}); diff --git a/server/tests/unit/logs/requestLogs/logsRange.test.ts b/server/tests/unit/logs/requestLogs/logsRange.test.ts new file mode 100644 index 000000000..b4ab00206 --- /dev/null +++ b/server/tests/unit/logs/requestLogs/logsRange.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { + getQueryLogsRangePolicy, + resolveLogsRange, +} from "@/internal/logs/handlers/logsRequestUtils.js"; +import { parseRestrictedApl } from "@/internal/logs/parser/restrictedApl.js"; + +const queryStages = [ + "where", + "summarize", + "project", + "orderBy", + "limit", +] as const; +const now = new Date("2026-06-06T12:00:00.000Z"); + +const parseQuery = (query: string) => + parseRestrictedApl({ + query, + allowedStages: [...queryStages], + }); + +describe("request-log ranges", () => { + test("defaults customer-filtered aggregate queries to 30 days", () => { + const policy = getQueryLogsRangePolicy( + parseQuery("where customer_id == 'cus_1' | summarize requests = count()"), + ); + + expect(resolveLogsRange({ now, ...policy })).toEqual({ + startDate: "2026-05-07T12:00:00.000Z", + endDate: "2026-06-06T12:00:00.000Z", + }); + }); + + test("defaults org-scoped aggregate queries to 15 days", () => { + const policy = getQueryLogsRangePolicy( + parseQuery("where status_code >= 400 | summarize requests = count()"), + ); + + expect(resolveLogsRange({ now, ...policy })).toEqual({ + startDate: "2026-05-22T12:00:00.000Z", + endDate: "2026-06-06T12:00:00.000Z", + }); + }); + + test("does not treat customer grouping as a customer-scoped filter", () => { + const policy = getQueryLogsRangePolicy( + parseQuery("summarize requests = count() by customer_id"), + ); + + expect(resolveLogsRange({ now, ...policy }).startDate).toBe( + "2026-05-22T12:00:00.000Z", + ); + }); + + test("rejects org-scoped aggregate ranges over 15 days", () => { + const policy = getQueryLogsRangePolicy( + parseQuery("summarize requests = count() by request_path"), + ); + + expect(() => + resolveLogsRange({ + startDate: "2026-05-21T12:00:00.000Z", + endDate: now.toISOString(), + ...policy, + }), + ).toThrow("Log range cannot exceed 15 days"); + }); + + test("allows customer-filtered aggregate ranges up to 30 days", () => { + const policy = getQueryLogsRangePolicy( + parseQuery( + "where context.customer_id == 'cus_1' | summarize requests = count()", + ), + ); + + expect( + resolveLogsRange({ + startDate: "2026-05-07T12:00:00.000Z", + endDate: now.toISOString(), + ...policy, + }), + ).toEqual({ + startDate: "2026-05-07T12:00:00.000Z", + endDate: "2026-06-06T12:00:00.000Z", + }); + }); +}); diff --git a/server/tests/unit/logs/requestLogs/projectRequestLog.test.ts b/server/tests/unit/logs/requestLogs/projectRequestLog.test.ts new file mode 100644 index 000000000..3af85d4f9 --- /dev/null +++ b/server/tests/unit/logs/requestLogs/projectRequestLog.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { + isExternalRequestLog, + projectRequestLog, +} from "@/internal/logs/actions/searchRequestLogs/projectRequestLog.js"; + +describe("projectRequestLog", () => { + test("projects the public request log shape", () => { + const log = projectRequestLog({ + _time: "2026-06-06T10:00:00Z", + data: { + timestamp: "2026-06-06T10:00:00Z", + source: "api_request", + status_code: 201, + duration_ms: 123, + message: "not public", + request_method: "POST", + request_url: "https://api.useautumn.com/v1/customers", + request_path: "/v1/customers", + request_body: { id: "cus_123" }, + response_body: { ok: true }, + org_id: "org_123", + customer_id: "cus_123", + entity_id: "ent_123", + }, + }); + + expect(log).toEqual({ + timestamp: "2026-06-06T10:00:00Z", + source: "api_request", + status_code: 201, + request: { + method: "POST", + url: "https://api.useautumn.com/v1/customers", + path: "/v1/customers", + }, + context: { + org_id: "org_123", + customer_id: "cus_123", + entity_id: "ent_123", + }, + stripe: { + event_id: null, + event_type: null, + object_id: null, + }, + request_body: { id: "cus_123" }, + response_body: { ok: true }, + }); + + expect("id" in log).toBe(false); + expect("duration_ms" in log).toBe(false); + expect("message" in log).toBe(false); + expect(isExternalRequestLog(log)).toBe(true); + }); + + test("falls back to raw fields and derives path from URL", () => { + const log = projectRequestLog({ + _time: "2026-06-06T10:00:00Z", + data: { + statusCode: 500, + "req.method": "GET", + "req.url": "https://api.useautumn.com/v1/check?x=1", + "req.body": null, + res: { error: "failed" }, + "context.org_id": "org_123", + "req.customer_id": "cus_123", + }, + }); + + expect(log.request.path).toBe("/v1/check"); + expect(log.context.org_id).toBe("org_123"); + expect(log.context.customer_id).toBe("cus_123"); + expect(log.response_body).toEqual({ error: "failed" }); + }); + + test("projects public-safe Stripe webhook fields", () => { + const log = projectRequestLog({ + _time: "2026-06-06T10:00:00Z", + data: { + status_code: 200, + request_method: "POST", + request_url: "https://api.useautumn.com/webhooks/connect/live", + request_path: "/webhooks/connect/live", + request_body: { id: "evt_123" }, + response_body: { received: true }, + org_id: "org_123", + customer_id: "cus_123", + stripe_event_id: "evt_123", + stripe_event_type: "customer.subscription.updated", + stripe_object_id: "sub_123", + }, + }); + + expect(log.source).toBe("stripe_webhook"); + expect(log.request.path).toBe("/webhooks/connect/live"); + expect(log.stripe).toEqual({ + event_id: "evt_123", + event_type: "customer.subscription.updated", + object_id: "sub_123", + }); + expect(isExternalRequestLog(log)).toBe(true); + }); + + test("derives Stripe webhook source from path", () => { + const log = projectRequestLog({ + _time: "2026-06-06T10:00:00Z", + data: { + status_code: 200, + request_url: "https://api.useautumn.com/webhooks/stripe/org_123/live", + }, + }); + + expect(log.source).toBe("stripe_webhook"); + expect("webhook_route" in log.stripe).toBe(false); + expect(isExternalRequestLog(log)).toBe(true); + }); + + test("filters non-v1 paths", () => { + const log = projectRequestLog({ + _time: "2026-06-06T10:00:00Z", + data: { + status_code: 200, + request_url: "https://api.useautumn.com/slack/events", + }, + }); + + expect(isExternalRequestLog(log)).toBe(false); + }); +}); diff --git a/server/tests/unit/logs/requestLogs/restrictedApl.test.ts b/server/tests/unit/logs/requestLogs/restrictedApl.test.ts new file mode 100644 index 000000000..d6efdd42f --- /dev/null +++ b/server/tests/unit/logs/requestLogs/restrictedApl.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import { buildRequestLogsApl } from "@/internal/logs/actions/searchRequestLogs/buildRequestLogsApl.js"; +import { + parseRestrictedApl, + restrictedAplToApl, +} from "@/internal/logs/parser/restrictedApl.js"; + +const normalize = (value: string) => value.replace(/\s+/g, " ").trim(); + +describe("restricted request-log APL", () => { + test("parses where, order, and limit stages over projected fields", () => { + const ast = parseRestrictedApl({ + query: + "| where request_body contains 'price_id' and status_code >= 400 | order by timestamp desc | limit 50", + }); + + expect(restrictedAplToApl(ast)).toEqual([ + "| where (dynamic_to_json(request_body) contains 'price_id' and status_code >= 400)", + "| order by timestamp desc", + "| limit 50", + ]); + }); + + test("parses public-safe source and Stripe webhook fields", () => { + const ast = parseRestrictedApl({ + query: + "where source == 'stripe_webhook' and stripe_event_type == 'customer.subscription.updated' and stripe_object_id == 'sub_123' | order by timestamp desc | limit 25", + }); + + expect(restrictedAplToApl(ast)).toEqual([ + "| where ((source == 'stripe_webhook' and stripe_event_type == 'customer.subscription.updated') and stripe_object_id == 'sub_123')", + "| order by timestamp desc", + "| limit 25", + ]); + }); + + test("parses nested request and response body predicates", () => { + const ast = parseRestrictedApl({ + query: + "where request_body.feature_id == 'credits' and response_body.allowed == false and response_body.balance.remaining > 0", + }); + + expect(restrictedAplToApl(ast)).toEqual([ + "| where ((tostring(request_body['feature_id']) == 'credits' and tobool(response_body['allowed']) == false) and todouble(response_body['balance']['remaining']) > 0)", + ]); + }); + + test("parses nested body fields in aggregate queries", () => { + const ast = parseRestrictedApl({ + query: + "where customer_id == 'cus_123' and request_body.event_name in ('credits', 'tokens') | summarize requests = count(), denied = countif(response_body.allowed == false) by request_body.event_name | project event_name = request_body_event_name, requests, denied | order by requests desc | limit 20", + allowedStages: ["where", "summarize", "project", "orderBy", "limit"], + }); + + expect(restrictedAplToApl(ast)).toEqual([ + "| where (customer_id == 'cus_123' and tostring(request_body['event_name']) in ('credits', 'tokens'))", + "| summarize requests = count(), denied = countif(tobool(response_body['allowed']) == false) by request_body_event_name = tostring(request_body['event_name'])", + "| project event_name = request_body_event_name, requests, denied", + "| order by requests desc", + "| limit 20", + ]); + }); + + test("parses nested body fields in project stages", () => { + const ast = parseRestrictedApl({ + query: + "project feature = request_body.feature_id, response_body.balance.remaining", + allowedStages: ["project"], + }); + + expect(restrictedAplToApl(ast)).toEqual([ + "| project feature = tostring(request_body['feature_id']), response_body_balance_remaining = tostring(response_body['balance']['remaining'])", + ]); + }); + + test("escapes strings when compiling back to APL", () => { + const ast = parseRestrictedApl({ + query: "where request_body contains 'it\\'s ok'", + }); + + expect(restrictedAplToApl(ast)).toEqual([ + "| where dynamic_to_json(request_body) contains 'it\\'s ok'", + ]); + }); + + test("rejects dataset sources and raw APL field syntax", () => { + expect(() => + parseRestrictedApl({ query: "['express'] | limit 10" }), + ).toThrow("unsupported syntax"); + expect(() => + parseRestrictedApl({ query: "where ['req.url'] contains '/v1'" }), + ).toThrow("unsupported syntax"); + }); + + test("rejects unsupported stages and comments", () => { + expect(() => parseRestrictedApl({ query: "project request_body" })).toThrow( + "Unsupported query stage", + ); + expect(() => + parseRestrictedApl({ query: "where status_code == 200 // test" }), + ).toThrow("comments are not supported"); + }); + + test("rejects unknown fields and unsafe limits", () => { + expect(() => + parseRestrictedApl({ query: "where secret contains 'x'" }), + ).toThrow("Unknown query field"); + expect(() => + parseRestrictedApl({ query: "where extras contains 'x'" }), + ).toThrow("Unknown query field"); + expect(() => + parseRestrictedApl({ query: "where workflow contains 'x'" }), + ).toThrow("Unknown query field"); + expect(() => + parseRestrictedApl({ + query: "where stripe_webhook_route == 'connect'", + }), + ).toThrow("Unknown query field"); + expect(() => + parseRestrictedApl({ + query: + "where request_body.feature_id.value.extra.too_deep.really_too_deep == 'x'", + }), + ).toThrow("Nested query field must have 1-4 path segments"); + expect(() => + parseRestrictedApl({ + query: "where response_body.balances.api-calls.remaining == 1", + }), + ).toThrow(); + expect(() => parseRestrictedApl({ query: "limit 500" })).toThrow( + "limit must be between 1 and 200", + ); + }); + + test("rejects user-authored raw body access and parsing functions", () => { + expect(() => + parseRestrictedApl({ + query: "where request_body['feature_id'] == 'credits'", + }), + ).toThrow("unsupported syntax"); + expect(() => + parseRestrictedApl({ + query: "where parse_json(request_body).feature_id == 'credits'", + }), + ).toThrow("Unsupported query character"); + expect(() => + parseRestrictedApl({ + query: "where todynamic(response_body).allowed == false", + }), + ).toThrow("Unsupported query character"); + }); + + test("parses aggregate query stages", () => { + const ast = parseRestrictedApl({ + query: + "where status_code >= 400 | summarize errors = count(), failures = countif(status_code >= 500) by request_path | order by errors desc | limit 10", + allowedStages: ["where", "summarize", "orderBy", "limit"], + }); + + expect(restrictedAplToApl(ast)).toEqual([ + "| where status_code >= 400", + "| summarize errors = count(), failures = countif(status_code >= 500) by request_path", + "| order by errors desc", + "| limit 10", + ]); + }); + + test("parses project stages over safe result aliases", () => { + const ast = parseRestrictedApl({ + query: + "summarize total = count() by request_method | project method = request_method, total", + allowedStages: ["summarize", "project"], + }); + + expect(restrictedAplToApl(ast)).toEqual([ + "| summarize total = count() by request_method", + "| project method = request_method, total", + ]); + }); + + test("rejects aggregate stages when caller disallows them", () => { + expect(() => + parseRestrictedApl({ + query: "summarize total = count() by request_path", + allowedStages: ["where", "orderBy", "limit"], + }), + ).toThrow("Unsupported query stage: summarize"); + }); + + test("rejects unsupported aggregate functions", () => { + expect(() => + parseRestrictedApl({ + query: "summarize total = dcount(customer_id) by request_path", + allowedStages: ["summarize"], + }), + ).toThrow("Unsupported summarize function: dcount"); + }); + + test("builds tenant-projected APL before appending user stages", () => { + const apl = buildRequestLogsApl({ + ctx: { + org: { id: "org_123", slug: "acme" }, + env: AppEnv.Sandbox, + }, + query: "where response_body contains 'checkout'", + limit: 25, + }); + + expect(normalize(apl)).toContain("['express'] | where"); + expect(apl).toContain("['context.org_id'] == 'org_123'"); + expect(apl).toContain("['context.org_slug'] == 'acme'"); + expect(apl).toContain("['context.env'] == 'sandbox'"); + expect(apl).not.toContain("context.orgId"); + expect(apl).not.toContain("context.orgSlug"); + expect(apl).toContain( + "request_path = tostring(parse_url(['req.url']).path)", + ); + expect(apl).toContain( + "source = case(request_path startswith '/v1', 'api_request'", + ); + expect(apl).not.toContain("stripe_webhook_route"); + expect(apl).toContain( + "| where source in ('api_request', 'stripe_webhook')", + ); + expect(apl).toContain( + "| project timestamp = _time, source = source, status_code = statusCode", + ); + expect(apl).toContain("stripe_event_id = ['stripe_event.id']"); + expect(apl).toContain( + "| where dynamic_to_json(response_body) contains 'checkout'", + ); + expect(apl).toContain("| limit 25"); + }); + + test("can omit default timestamp ordering for aggregate queries", () => { + const apl = buildRequestLogsApl({ + ctx: { + org: { id: "org_123", slug: "acme" }, + env: AppEnv.Sandbox, + }, + query: "summarize total = count() by request_path", + limit: 25, + allowedStages: ["summarize"], + appendDefaultOrder: false, + }); + + expect(apl).toContain("| summarize total = count() by request_path"); + expect(apl).not.toContain("| order by timestamp desc"); + expect(apl).toContain("| limit 25"); + }); + + test("escapes tenant values in generated APL", () => { + const apl = buildRequestLogsApl({ + ctx: { + org: { id: "org_'x", slug: "slug\\x" }, + env: AppEnv.Live, + }, + limit: 10, + }); + + expect(apl).toContain("org_\\'x"); + expect(apl).toContain("slug\\\\x"); + }); +}); diff --git a/server/tests/unit/migrations-v2/compiler/customer-select-planner.test.ts b/server/tests/unit/migrations-v2/compiler/customer-select-planner.test.ts new file mode 100644 index 000000000..7810f518e --- /dev/null +++ b/server/tests/unit/migrations-v2/compiler/customer-select-planner.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { + buildCustomerCount, + buildCustomerSelect, +} from "@/internal/migrations/v2/filters/customers/buildCustomerSelect.js"; +import { PgDialect } from "drizzle-orm/pg-core"; + +const dialect = new PgDialect(); +const ctx = { features: [] }; + +const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim(); + +describe("migration customer select planner wiring", () => { + test("plan_id filters use a planned customer source plus fallback predicate", () => { + const query = buildCustomerCount({ + orgId: "org_test", + env: "live", + filter: { plan: { plan_id: "enterprise" } }, + ctx, + }); + const { sql, params } = dialect.sqlToQuery(query); + + expect(normalize(sql)).toContain( + "FROM (WITH plan_products AS MATERIALIZED", + ); + expect(normalize(sql)).toContain("SELECT p.internal_id FROM products p"); + expect(normalize(sql)).toContain( + "AND EXISTS (SELECT 1 FROM customer_products cp JOIN products p", + ); + expect(params).toEqual([ + "org_test", + "live", + "enterprise", + "active", + "past_due", + "scheduled", + "org_test", + "live", + "org_test", + "live", + "active", + "past_due", + "scheduled", + "enterprise", + ]); + }); + + test("non-planned filters keep the customer root source", () => { + const query = buildCustomerSelect({ + orgId: "org_test", + env: "live", + filter: { customer_id: "cus_123" }, + ctx, + limit: 10, + }); + const { sql, params } = dialect.sqlToQuery(query); + + expect(normalize(sql)).toContain("FROM customers c"); + expect(normalize(sql)).not.toContain("FROM (SELECT DISTINCT"); + expect(params).toEqual(["org_test", "live", "cus_123", 10]); + }); + + test("customer list filters are applied in the select SQL", () => { + const query = buildCustomerSelect({ + orgId: "org_test", + env: "live", + filter: { customer_id: "cus_123" }, + ctx, + customerFilters: { + status: ["active"], + version: ["pro:1"], + processor: ["stripe"], + }, + }); + const { sql } = dialect.sqlToQuery(query); + const normalized = normalize(sql); + + expect(normalized).toContain("c.processor->>'id' IS NOT NULL"); + expect(normalized).toContain("FROM customer_products cp_dash"); + expect(normalized).toContain("AND c.internal_id IN"); + expect(normalized).toContain("cp_dash.internal_product_id IN"); + expect(normalized).toContain("FROM products p_lookup"); + }); +}); diff --git a/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts b/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts index 5b4107ab9..7e46b2623 100644 --- a/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts +++ b/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts @@ -19,11 +19,12 @@ describe("$none quantifier", () => { expect(sql).toContain("NOT EXISTS"); }); - test("string shorthand '$none' is equivalent to { $none: {} }", () => { - const full = compile({ plan: { $none: {} } }); - const shorthand = compile({ plan: "$none" }); - expect(shorthand.sql).toBe(full.sql); - expect(shorthand.params).toEqual(full.params); + test("$none with plan_id $in is the empty-inclusive 'not on plan' negation", () => { + const { sql, params } = compile({ + plan: { $none: { plan_id: { $in: ["pro"] } } }, + }); + expect(sql).toContain("NOT EXISTS"); + expect(params).toContain("pro"); }); test("$none with plan_id filter selects customers without that plan", () => { diff --git a/server/tests/unit/migrations-v2/filters/array-filter-quantifier.test.ts b/server/tests/unit/migrations-v2/filters/array-filter-quantifier.test.ts new file mode 100644 index 000000000..97b0b4010 --- /dev/null +++ b/server/tests/unit/migrations-v2/filters/array-filter-quantifier.test.ts @@ -0,0 +1,31 @@ +import { CustomerFilterSchema } from "@autumn/shared/api/migrations/filters/customerFilter.js"; +import { describe, expect, it } from "bun:test"; + +// Regression: the quantifier wrapper must win over the permissive element in +// arrayFilter's union, otherwise PlanFilterSchema strips `$none`/`$some`/ +// `$every` down to `{}` and the filter silently degrades to "has any plan". +describe("arrayFilter quantifier preservation", () => { + it("preserves $none with an empty inner filter", () => { + const parsed = CustomerFilterSchema.parse({ plan: { $none: {} } }); + expect(parsed).toEqual({ plan: { $none: {} } }); + }); + + it("preserves $none with an inner plan_id matcher", () => { + const parsed = CustomerFilterSchema.parse({ + plan: { $none: { plan_id: { $in: ["pro"] } } }, + }); + expect(parsed).toEqual({ plan: { $none: { plan_id: { $in: ["pro"] } } } }); + }); + + it("keeps a bare element filter as implicit $some", () => { + const parsed = CustomerFilterSchema.parse({ plan: { plan_id: "pro" } }); + expect(parsed).toEqual({ plan: { plan_id: "pro" } }); + }); + + it("keeps an $or element filter (not mistaken for a quantifier)", () => { + const parsed = CustomerFilterSchema.parse({ + plan: { $or: [{ paid: true }] }, + }); + expect(parsed).toEqual({ plan: { $or: [{ paid: true }] } }); + }); +}); diff --git a/server/tests/unit/migrations-v2/pre-process-version-custom-guard.test.ts b/server/tests/unit/migrations-v2/pre-process-version-custom-guard.test.ts new file mode 100644 index 000000000..0288046b7 --- /dev/null +++ b/server/tests/unit/migrations-v2/pre-process-version-custom-guard.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import type { MigrationFilter, Operations, UpdatePlanOp } from "@autumn/shared"; +import { preProcessMigrationOperations } from "@/internal/migrations/v2/run/preProcess/preProcessMigrationOperations"; + +const operations: Operations = { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: "pro", version: 1 }, + version: 1, + }, + ], +}; + +const firstUpdatePlan = (ops: Operations): UpdatePlanOp => { + const op = ops.customer?.[0]; + if (op?.type === "update_plan") return op; + throw new Error("Expected first operation to update a plan"); +}; + +const process = (filter?: MigrationFilter) => + firstUpdatePlan(preProcessMigrationOperations({ operations, filter })); + +describe("preProcessMigrationOperations custom guard", () => { + test("defaults version migrations to non-custom plans", () => { + expect(process().plan_filter).toEqual({ + plan_id: "pro", + version: 1, + custom: false, + }); + }); + + test("keeps custom plans eligible when the migration targets one customer", () => { + expect( + process({ customer: { customer_id: "cus_1" } }).plan_filter, + ).toEqual({ + plan_id: "pro", + version: 1, + }); + }); + + test("keeps custom plans eligible when the filter explicitly targets custom", () => { + expect( + process({ customer: { plan: { plan_id: "pro", custom: true } } }) + .plan_filter, + ).toEqual({ + plan_id: "pro", + version: 1, + }); + }); + + test("keeps custom plans eligible through plan quantifiers and OR filters", () => { + expect( + process({ + customer: { + plan: { + $some: { + plan_id: "pro", + $or: [{ version: 1 }, { custom: true }], + }, + }, + }, + }).plan_filter, + ).toEqual({ + plan_id: "pro", + version: 1, + }); + }); +}); diff --git a/server/tests/unit/products/get-plan-response.test.ts b/server/tests/unit/products/get-plan-response.test.ts new file mode 100644 index 000000000..36f7dd7af --- /dev/null +++ b/server/tests/unit/products/get-plan-response.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv, type FullProduct } from "@autumn/shared"; +import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js"; + +const baseProduct = { + id: "legacy-plan", + name: "Legacy Plan", + description: null, + group: "", + version: 1, + env: AppEnv.Sandbox, + internal_id: "prod_internal", + org_id: "org_123", + created_at: 1, + processor: null, + base_variant_id: null, + archived: false, + config: { ignore_past_due: false }, + prices: [], + entitlements: [], + free_trial: null, + free_trials: [], + free_trial_ids: [], +} satisfies Omit; + +describe("getPlanResponse", () => { + test("normalizes null product booleans to DB defaults", async () => { + const response = await getPlanResponse({ + product: { + ...baseProduct, + is_add_on: null, + is_default: null, + } as unknown as FullProduct, + features: [], + }); + + expect(response.add_on).toBe(false); + expect(response.auto_enable).toBe(false); + }); +}); diff --git a/server/tests/unit/rate-limits/get-rate-limit-type.test.ts b/server/tests/unit/rate-limits/get-rate-limit-type.test.ts index c66d2237d..814365194 100644 --- a/server/tests/unit/rate-limits/get-rate-limit-type.test.ts +++ b/server/tests/unit/rate-limits/get-rate-limit-type.test.ts @@ -3,6 +3,8 @@ import type { Context } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { getRateLimitType, + RATE_LIMIT_CONFIGS, + RateLimitScope, RateLimitType, } from "@/internal/misc/rateLimiter/rateLimitConfigs.js"; @@ -110,6 +112,26 @@ describe("getRateLimitType", () => { ).toBe(RateLimitType.CustomerEntitiesGet); }); + test("classifies log endpoints into their org-scoped logs bucket", () => { + expect( + getRateLimitType( + createContext({ method: "POST", path: "/v1/logs.search" }), + ), + ).toBe(RateLimitType.Logs); + expect( + getRateLimitType( + createContext({ method: "POST", path: "/v1/logs.query" }), + ), + ).toBe(RateLimitType.Logs); + + expect(RATE_LIMIT_CONFIGS[RateLimitType.Logs]).toMatchObject({ + name: "logs", + limit: 10, + windowMs: 1000, + scope: RateLimitScope.Org, + }); + }); + test("falls back to the general bucket for uncategorized routes", () => { expect( getRateLimitType(createContext({ method: "GET", path: "/v1/products" })), diff --git a/server/tests/unit/revenuecat/buildRcPreflightItems.test.ts b/server/tests/unit/revenuecat/buildRcPreflightItems.test.ts new file mode 100644 index 000000000..55daa30ad --- /dev/null +++ b/server/tests/unit/revenuecat/buildRcPreflightItems.test.ts @@ -0,0 +1,109 @@ +/** + * Unit tests for buildRcPreflightItems — the read-only sync preview. Per plan it + * matches the minted store id (autumn.{env}.{org}.{planId}) to an RC product and + * reports Autumn's base price vs RC's, so the sheet can show Create/Rename + price + * mismatch. No network/DB: listPrices is injected. + */ + +import { + AppEnv, + BillingInterval, + type FullProduct, + type Organization, + type Price, + PriceType, +} from "@autumn/shared"; +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { buildRcPreflightItems } from "@/external/revenueCat/handlers/handlePreflightRevenueCatSync.js"; +import type { RevenueCatProduct } from "@/external/revenueCat/revenuecatTypes.js"; + +const org = { id: "org_1", default_currency: "usd" } as unknown as Organization; +const env = AppEnv.Sandbox; +const storeId = (planId: string) => `autumn.${env}.${org.id}.${planId}`; + +const fixed = (amount: number): Price => + ({ + config: { + type: PriceType.Fixed, + amount, + interval: BillingInterval.Month, + interval_count: 1, + }, + }) as unknown as Price; + +const product = (id: string, name: string, prices: Price[] = [fixed(4.99)]): FullProduct => + ({ id, name, prices }) as unknown as FullProduct; + +const rcProduct = ( + storeIdentifier: string, + display_name: string, + id = "prod_x", +): RevenueCatProduct => + ({ id, store_identifier: storeIdentifier, display_name }) as RevenueCatProduct; + +test(`${chalk.yellowBright("preflight: plan with no RC product -> Create (rc_exists false)")}`, async () => { + const [item] = await buildRcPreflightItems({ + products: [product("pro", "Pro")], + rcProducts: [], + org, + env, + listPrices: async () => [], + }); + + expect(item.rc_exists).toBe(false); + expect(item.rc_name).toBeNull(); + expect(item.autumn_price).toEqual({ amount_micros: 4_990_000, currency: "USD" }); +}); + +test(`${chalk.yellowBright("preflight: matching RC product surfaces name + price for rename/mismatch checks")}`, async () => { + const [item] = await buildRcPreflightItems({ + products: [product("pro", "Pro")], + rcProducts: [rcProduct(storeId("pro"), "Old Name", "prod_1")], + org, + env, + // RC price differs from Autumn's 4.99 -> a mismatch the sheet flags + listPrices: async (id) => + id === "prod_1" ? [{ id: "prc1", amount_micros: 5_990_000, currency: "USD" }] : [], + }); + + expect(item.rc_exists).toBe(true); + expect(item.rc_name).toBe("Old Name"); + expect(item.autumn_price).toEqual({ amount_micros: 4_990_000, currency: "USD" }); + expect(item.rc_price).toEqual({ amount_micros: 5_990_000, currency: "USD" }); +}); + +test(`${chalk.yellowBright("preflight: RC product without a price -> rc_price null")}`, async () => { + const [item] = await buildRcPreflightItems({ + products: [product("pro", "Pro")], + rcProducts: [rcProduct(storeId("pro"), "Pro", "prod_2")], + org, + env, + listPrices: async () => [], + }); + + expect(item.rc_exists).toBe(true); + expect(item.rc_name).toBe("Pro"); + expect(item.rc_price).toBeNull(); +}); + +test(`${chalk.yellowBright("preflight: only the first RC product per store id is priced (one price fetch)")}`, async () => { + let priceCalls = 0; + const items = await buildRcPreflightItems({ + products: [product("pro", "Pro")], + // two apps share the same minted store id + rcProducts: [ + rcProduct(storeId("pro"), "Pro", "prod_ios"), + rcProduct(storeId("pro"), "Pro", "prod_android"), + ], + org, + env, + listPrices: async () => { + priceCalls += 1; + return [{ id: "prc", amount_micros: 4_990_000, currency: "USD" }]; + }, + }); + + expect(items).toHaveLength(1); + expect(priceCalls).toBe(1); +}); diff --git a/server/tests/unit/revenuecat/getRcBasePrice.test.ts b/server/tests/unit/revenuecat/getRcBasePrice.test.ts new file mode 100644 index 000000000..220197b38 --- /dev/null +++ b/server/tests/unit/revenuecat/getRcBasePrice.test.ts @@ -0,0 +1,66 @@ +/** + * Unit tests for getRcBasePrice — extracts an Autumn plan's flat base price as + * RevenueCat micros + uppercased currency, or null for free / usage-only plans. + */ + +import { + BillingInterval, + type FullProduct, + type Organization, + type Price, + PriceType, +} from "@autumn/shared"; +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { getRcBasePrice } from "@/external/revenueCat/sync/revenuecatProductSyncUtils.js"; + +const org = (currency = "usd") => + ({ default_currency: currency }) as unknown as Organization; + +const fixedPrice = (amount: number): Price => + ({ + config: { + type: PriceType.Fixed, + amount, + interval: BillingInterval.Month, + interval_count: 1, + }, + }) as unknown as Price; + +const usagePrice = (): Price => + ({ + config: { + type: PriceType.Usage, + bill_when: "end_of_period", + usage_tiers: [{ to: -1, amount: 0.1 }], + interval: BillingInterval.Month, + }, + }) as unknown as Price; + +const product = (prices: Price[]): FullProduct => + ({ id: "pro", name: "Pro", prices }) as unknown as FullProduct; + +test(`${chalk.yellowBright("getRcBasePrice: fixed price -> micros + uppercased currency")}`, () => { + expect(getRcBasePrice({ product: product([fixedPrice(4.99)]), org: org() })).toEqual({ + amountMicros: 4_990_000, + currency: "USD", + }); +}); + +test(`${chalk.yellowBright("getRcBasePrice: respects org currency, uppercased")}`, () => { + expect( + getRcBasePrice({ product: product([fixedPrice(9.99)]), org: org("eur") }), + ).toEqual({ amountMicros: 9_990_000, currency: "EUR" }); +}); + +test(`${chalk.yellowBright("getRcBasePrice: usage-only plan -> null")}`, () => { + expect(getRcBasePrice({ product: product([usagePrice()]), org: org() })).toBeNull(); +}); + +test(`${chalk.yellowBright("getRcBasePrice: free plan (no prices) -> null")}`, () => { + expect(getRcBasePrice({ product: product([]), org: org() })).toBeNull(); +}); + +test(`${chalk.yellowBright("getRcBasePrice: zero-amount base -> null")}`, () => { + expect(getRcBasePrice({ product: product([fixedPrice(0)]), org: org() })).toBeNull(); +}); diff --git a/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts b/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts new file mode 100644 index 000000000..c28862b69 --- /dev/null +++ b/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { AppEnv, type Organization } from "@autumn/shared"; +import { OAuth2Tokens } from "arctic"; +import { encryptData } from "@/utils/encryptUtils.js"; + +const mockRefreshRcTokens = mock(() => + Promise.resolve( + new OAuth2Tokens({ + access_token: "atk_refreshed", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rtk_rotated", + }), + ), +); + +const mockOrgUpdate = mock( + (_args: { updates: Organization }): Promise => Promise.resolve(null), +); + +mock.module("@/external/revenueCat/misc/revenuecatOAuth.js", () => ({ + refreshRcTokens: mockRefreshRcTokens, +})); + +mock.module("@/internal/orgs/OrgService.js", () => ({ + OrgService: { + update: mockOrgUpdate, + }, +})); + +const { getRevenuecatAccessToken } = await import( + "@/external/revenueCat/misc/getRevenuecatAccessToken.js" +); + +const buildOrg = ({ + expiresAt, + withApiKey = false, +}: { + expiresAt: number; + withApiKey?: boolean; +}): Organization => + ({ + id: "org_123", + processor_configs: { + revenuecat: { + ...(withApiKey + ? { sandbox_api_key: encryptData("legacy_api_key") } + : {}), + sandbox_oauth: { + access_token: encryptData("cached_access_token"), + refresh_token: encryptData("cached_refresh_token"), + expires_at: expiresAt, + }, + webhook_secret: "whsec", + sandbox_webhook_secret: "whsec_sandbox", + }, + }, + }) as Organization; + +describe("getRevenuecatAccessToken", () => { + beforeEach(() => { + process.env.ENCRYPTION_PASSWORD = "test-encryption-password"; + mockRefreshRcTokens.mockClear(); + mockOrgUpdate.mockClear(); + }); + + afterEach(() => { + delete process.env.ENCRYPTION_PASSWORD; + }); + + test("returns cached access token when not expired", async () => { + const org = buildOrg({ expiresAt: Date.now() + 60 * 60 * 1000 }); + + const token = await getRevenuecatAccessToken({ + db: {} as never, + org, + env: AppEnv.Sandbox, + }); + + expect(token).toBe("cached_access_token"); + expect(mockRefreshRcTokens).not.toHaveBeenCalled(); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + }); + + test("refreshes and persists rotated tokens when expired", async () => { + const org = buildOrg({ expiresAt: Date.now() - 1000 }); + + const token = await getRevenuecatAccessToken({ + db: {} as never, + org, + env: AppEnv.Sandbox, + }); + + expect(token).toBe("atk_refreshed"); + expect(mockRefreshRcTokens).toHaveBeenCalledTimes(1); + expect(mockOrgUpdate).toHaveBeenCalledTimes(1); + + const updateCall = mockOrgUpdate.mock.calls[0]?.[0]; + const sandboxOauth = + updateCall?.updates.processor_configs?.revenuecat?.sandbox_oauth; + + expect(sandboxOauth?.access_token).toBeDefined(); + expect(sandboxOauth?.refresh_token).toBeDefined(); + expect(sandboxOauth?.expires_at).toBeGreaterThan(Date.now()); + }); + + test("falls back to legacy api_key when oauth is absent", async () => { + const org = { + id: "org_123", + processor_configs: { + revenuecat: { + sandbox_api_key: encryptData("legacy_api_key"), + }, + }, + } as Organization; + + const token = await getRevenuecatAccessToken({ + db: {} as never, + org, + env: AppEnv.Sandbox, + }); + + expect(token).toBe("legacy_api_key"); + expect(mockRefreshRcTokens).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/revenuecat/handleLinkRevenueCat.test.ts b/server/tests/unit/revenuecat/handleLinkRevenueCat.test.ts new file mode 100644 index 000000000..eac3e6070 --- /dev/null +++ b/server/tests/unit/revenuecat/handleLinkRevenueCat.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const mockValidatePlatformOrg = mock( + (): Promise> => + Promise.resolve({ + id: "org_123", + slug: "test-org", + processor_configs: {}, + }), +); + +const mockGenerateOAuthState = mock( + (): Promise => Promise.resolve("state_123"), +); + +const mockCreateRcAuthorizationUrl = mock( + (): URL => + new URL("https://api.revenuecat.com/oauth2/authorize?state=state_123"), +); + +mock.module( + "@/internal/platform/platformBeta/utils/validatePlatformOrg.js", + () => ({ + validatePlatformOrg: mockValidatePlatformOrg, + }), +); + +mock.module( + "@/internal/platform/platformBeta/utils/oauthStateUtils.js", + () => ({ + generateOAuthState: mockGenerateOAuthState, + }), +); + +mock.module("@/external/revenueCat/misc/revenuecatOAuth.js", () => ({ + createRcAuthorizationUrl: mockCreateRcAuthorizationUrl, + generateCodeVerifier: () => "test-verifier", +})); + +const { handleLinkRevenueCat } = await import( + "@/internal/platform/platformBeta/handlers/handleLinkRevenueCat.js" +); + +const handler = handleLinkRevenueCat[handleLinkRevenueCat.length - 1] as ( + c: any, +) => Promise; + +const createContext = (body: Record) => { + let jsonResponse: unknown = null; + return { + req: { + valid: () => body, + query: () => ({}), + }, + json: (data: unknown) => { + jsonResponse = data; + return { status: 200 }; + }, + getJsonResponse: () => jsonResponse, + set: () => {}, + get: () => ({ + db: {}, + org: { id: "master_org_123", slug: "master-org" }, + logger: { info: () => {}, error: () => {} }, + }), + }; +}; + +describe("handleLinkRevenueCat", () => { + beforeEach(() => { + process.env.REVENUECAT_OAUTH_CLIENT_ID = "rc_client"; + process.env.REVENUECAT_OAUTH_CLIENT_SECRET = "rc_secret"; + process.env.BETTER_AUTH_URL = "https://auth.example.com"; + mockValidatePlatformOrg.mockClear(); + mockGenerateOAuthState.mockClear(); + mockCreateRcAuthorizationUrl.mockClear(); + }); + + test("errors when RevenueCat is already linked for env", async () => { + mockValidatePlatformOrg.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + processor_configs: { + revenuecat: { + sandbox_oauth: { + access_token: "encrypted", + refresh_token: "encrypted", + expires_at: Date.now() + 3600000, + }, + }, + }, + }); + + const ctx = createContext({ + organization_slug: "test-org", + env: "test", + project_name: "My Project", + redirect_url: "http://localhost:5173/callback", + }); + + await expect(handler(ctx as never)).rejects.toThrow(); + }); + + test("returns oauth_url and stores state with revenuecat_project_name", async () => { + mockValidatePlatformOrg.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + processor_configs: {}, + }); + + const ctx = createContext({ + organization_slug: "test-org", + env: "test", + project_name: "My Project", + redirect_url: "http://localhost:5173/callback", + }); + + await handler(ctx as never); + + expect(mockGenerateOAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + organizationSlug: "test-org", + env: "sandbox", + redirectUri: "http://localhost:5173/callback", + masterOrgId: "master_org_123", + provider: "revenuecat", + revenuecatProjectName: "My Project", + }), + ); + expect(mockCreateRcAuthorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ + state: "state_123", + codeVerifier: "test-verifier", + }), + ); + expect(ctx.getJsonResponse()).toEqual({ + oauth_url: "https://api.revenuecat.com/oauth2/authorize?state=state_123", + }); + }); +}); diff --git a/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts b/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts new file mode 100644 index 000000000..bba895e64 --- /dev/null +++ b/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts @@ -0,0 +1,401 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { OAuth2Tokens } from "arctic"; + +type MockOAuthState = { + organization_slug: string; + env: string; + redirect_uri: string; + master_org_id: string | null; + code_verifier?: string; + provider?: string; + revenuecat_project_name?: string; + migration?: boolean; +}; + +const mockConsumeOAuthState = mock( + (): Promise => Promise.resolve(null), +); +const mockExchangeRcCode = mock(() => + Promise.resolve( + new OAuth2Tokens({ + access_token: "atk_new", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rtk_new", + scope: + "project_configuration:projects:read_write customer_information:customers:read_write", + }), + ), +); +const mockOrgGetBySlug = mock( + (): Promise | null> => Promise.resolve(null), +); +const mockOrgUpdate = mock( + (_args: { updates: any }): Promise => Promise.resolve(null), +); +const mockClearOrgCache = mock((): Promise => Promise.resolve()); +const mockCreateProject = mock(() => + Promise.resolve({ id: "proj_123", name: "Test Project" }), +); +const mockListProjects = mock( + (): Promise<{ projects: { id: string; name: string }[] }> => + Promise.resolve({ projects: [] }), +); +const mockListProductStoreIdentifiers = mock( + (): Promise> => Promise.resolve(new Set()), +); +const mockMappingsGetAll = mock( + (): Promise<{ revenuecat_product_ids: string[] }[]> => Promise.resolve([]), +); + +mock.module("@/db/initDrizzle.js", () => ({ + initDrizzle: () => ({ db: {} }), +})); + +mock.module( + "@/internal/platform/platformBeta/utils/oauthStateUtils.js", + () => ({ + consumeOAuthState: mockConsumeOAuthState, + }), +); + +mock.module("@/external/revenueCat/misc/revenuecatOAuth.js", () => ({ + exchangeRcCode: mockExchangeRcCode, + RC_OAUTH_SCOPES: [ + "project_configuration:projects:read_write", + "customer_information:customers:read_write", + ], + findMissingRcScopes: (granted: string[]) => + [ + "project_configuration:projects:read_write", + "customer_information:customers:read_write", + ].filter( + (required) => + !granted.some((g) => g === required || g === "*:*:read_write"), + ), +})); + +mock.module("@/external/revenueCat/misc/initRevenuecatCli.js", () => ({ + initRevenuecatCli: () => ({ + createProject: mockCreateProject, + listProducts: async () => [], + listProjects: mockListProjects, + listProductStoreIdentifiers: mockListProductStoreIdentifiers, + }), +})); + +mock.module("@/external/revenueCat/misc/RCMappingService.js", () => ({ + RCMappingService: { getAll: mockMappingsGetAll }, +})); + +mock.module("@/internal/orgs/OrgService.js", () => ({ + OrgService: { + getBySlug: mockOrgGetBySlug, + update: mockOrgUpdate, + }, +})); + +mock.module("@/internal/orgs/orgUtils/clearOrgCache.js", () => ({ + clearOrgCache: mockClearOrgCache, +})); + +const { handleRevenueCatOAuthCallback } = await import( + "@/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.js" +); + +const createContext = (query: Record) => { + let redirectUrl = ""; + return { + req: { + query: () => query, + }, + redirect: (url: string) => { + redirectUrl = url; + return { status: 302, location: url }; + }, + getRedirectUrl: () => redirectUrl, + }; +}; + +describe("handleRevenueCatOAuthCallback", () => { + beforeEach(() => { + process.env.CLIENT_URL = "http://localhost:5173"; + process.env.ENCRYPTION_PASSWORD = "test-encryption-password"; + mockConsumeOAuthState.mockClear(); + mockExchangeRcCode.mockClear(); + mockOrgGetBySlug.mockClear(); + mockOrgUpdate.mockClear(); + mockClearOrgCache.mockClear(); + mockCreateProject.mockClear(); + mockListProjects.mockClear(); + mockListProjects.mockResolvedValue({ projects: [] }); + mockListProductStoreIdentifiers.mockClear(); + mockListProductStoreIdentifiers.mockResolvedValue(new Set()); + mockMappingsGetAll.mockClear(); + mockMappingsGetAll.mockResolvedValue([]); + }); + + afterEach(() => { + delete process.env.CLIENT_URL; + delete process.env.ENCRYPTION_PASSWORD; + }); + + test("redirects with error when OAuth provider returns error", async () => { + const ctx = createContext({ error: "access_denied" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(ctx.getRedirectUrl()).toContain("error=access_denied"); + expect(ctx.getRedirectUrl()).toContain("tab=revenuecat"); + }); + + test("redirects with missing_parameters when code or state absent", async () => { + const ctx = createContext({ code: "abc" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(ctx.getRedirectUrl()).toContain("error=missing_parameters"); + }); + + test("redirects with invalid_state when redis state is missing", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(null); + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(ctx.getRedirectUrl()).toContain("error=invalid_state"); + }); + + test("redirects with success and updates org on happy path (dashboard flow)", async () => { + mockConsumeOAuthState.mockResolvedValueOnce({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "http://localhost:5173/dev?tab=revenuecat", + master_org_id: null, + code_verifier: "verifier_123", + provider: "revenuecat", + }); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + processor_configs: { + revenuecat: { + webhook_secret: "whsec", + sandbox_webhook_secret: "whsec_sandbox", + }, + }, + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockExchangeRcCode).toHaveBeenCalledWith({ + code: "abc", + codeVerifier: "verifier_123", + }); + expect(mockOrgUpdate).toHaveBeenCalledTimes(1); + expect(mockClearOrgCache).toHaveBeenCalledTimes(1); + expect(ctx.getRedirectUrl()).toContain("success=true"); + }); + + test("platform flow: rejects when org.created_by does not match master_org_id", async () => { + mockConsumeOAuthState.mockResolvedValueOnce({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "https://platform.example.com/callback", + master_org_id: "master_123", + code_verifier: "verifier_123", + provider: "revenuecat", + revenuecat_project_name: "Test Project", + }); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + created_by: "other_master", + processor_configs: {}, + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockExchangeRcCode).not.toHaveBeenCalled(); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("success=false"); + expect(ctx.getRedirectUrl()).toContain("provider=revenuecat"); + expect(ctx.getRedirectUrl()).toContain("message=org_permission_denied"); + }); + + test("platform flow: creates project, persists config, and redirects with project id", async () => { + mockConsumeOAuthState.mockResolvedValueOnce({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "https://platform.example.com/callback", + master_org_id: "master_123", + code_verifier: "verifier_123", + provider: "revenuecat", + revenuecat_project_name: "Test Project", + }); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + created_by: "master_123", + processor_configs: {}, + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockExchangeRcCode).toHaveBeenCalledWith({ + code: "abc", + codeVerifier: "verifier_123", + }); + expect(mockCreateProject).toHaveBeenCalledWith({ name: "Test Project" }); + expect(mockOrgUpdate).toHaveBeenCalledTimes(1); + expect(mockClearOrgCache).toHaveBeenCalledTimes(1); + const updateCall = mockOrgUpdate.mock.calls[0]?.[0]; + const sandboxOauth = + updateCall?.updates.processor_configs?.revenuecat?.sandbox_oauth; + expect(sandboxOauth?.project_id).toBe("proj_123"); + // platform org had no webhook secret → callback generates + persists one + const rc = updateCall?.updates.processor_configs?.revenuecat; + expect(typeof rc?.sandbox_webhook_secret).toBe("string"); + expect(rc?.sandbox_webhook_secret?.length).toBe(64); + expect(ctx.getRedirectUrl()).toContain("success=true"); + expect(ctx.getRedirectUrl()).toContain("provider=revenuecat"); + expect(ctx.getRedirectUrl()).toContain("organization_slug=test-org"); + expect(ctx.getRedirectUrl()).toContain("env=test"); + expect(ctx.getRedirectUrl()).toContain("revenuecat_project_id=proj_123"); + }); + + test("platform flow: redirects with error when project creation fails", async () => { + mockConsumeOAuthState.mockResolvedValueOnce({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "https://platform.example.com/callback", + master_org_id: "master_123", + code_verifier: "verifier_123", + provider: "revenuecat", + revenuecat_project_name: "Test Project", + }); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + created_by: "master_123", + processor_configs: {}, + }); + mockCreateProject.mockRejectedValueOnce(new Error("RC API error")); + + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("success=false"); + expect(ctx.getRedirectUrl()).toContain("provider=revenuecat"); + expect(ctx.getRedirectUrl()).toContain("message=RC+API+error"); + }); + + // ── API-key → OAuth migration ──────────────────────────────────────────── + const migrationState = (): MockOAuthState => ({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "http://localhost:5173/dev?tab=revenuecat", + master_org_id: null, + code_verifier: "verifier_123", + provider: "revenuecat", + migration: true, + }); + + const legacyApiKeyOrg = () => ({ + id: "org_123", + slug: "test-org", + processor_configs: { + revenuecat: { + sandbox_api_key: "enc_sandbox_key", + sandbox_project_id: "proj_existing", + sandbox_webhook_secret: "whsec_sandbox", + }, + }, + }); + + test("migration: connects OAuth, keeps the project, and strips the legacy api key", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(migrationState()); + mockOrgGetBySlug.mockResolvedValueOnce(legacyApiKeyOrg()); + mockListProjects.mockResolvedValueOnce({ + projects: [{ id: "proj_existing", name: "Existing" }], + }); + mockMappingsGetAll.mockResolvedValueOnce([ + { revenuecat_product_ids: ["com.app.pro", "com.app.premium"] }, + ]); + mockListProductStoreIdentifiers.mockResolvedValueOnce( + new Set(["com.app.pro", "com.app.premium", "com.app.extra"]), + ); + + const ctx = createContext({ code: "abc", state: "state_123" }); + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).toHaveBeenCalledTimes(1); + const rc = + mockOrgUpdate.mock.calls[0]?.[0]?.updates.processor_configs?.revenuecat; + // OAuth connected against the existing project + expect(rc?.sandbox_oauth?.project_id).toBe("proj_existing"); + // legacy api key + project id stripped + expect(rc?.sandbox_api_key).toBeUndefined(); + expect(rc?.sandbox_project_id).toBeUndefined(); + // untouched legacy fields preserved + expect(rc?.sandbox_webhook_secret).toBe("whsec_sandbox"); + expect(ctx.getRedirectUrl()).toContain("success=true"); + }); + + test("migration: blocks when the OAuth account doesn't contain the project", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(migrationState()); + mockOrgGetBySlug.mockResolvedValueOnce(legacyApiKeyOrg()); + mockListProjects.mockResolvedValueOnce({ + projects: [{ id: "some_other_project", name: "Other" }], + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("error=project_not_in_account"); + }); + + test("migration: blocks when mapped products aren't all in the project", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(migrationState()); + mockOrgGetBySlug.mockResolvedValueOnce(legacyApiKeyOrg()); + mockListProjects.mockResolvedValueOnce({ + projects: [{ id: "proj_existing", name: "Existing" }], + }); + mockMappingsGetAll.mockResolvedValueOnce([ + { revenuecat_product_ids: ["com.app.pro", "com.app.missing"] }, + ]); + mockListProductStoreIdentifiers.mockResolvedValueOnce( + new Set(["com.app.pro"]), + ); + + const ctx = createContext({ code: "abc", state: "state_123" }); + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("error=products_mismatch"); + }); + + test("migration: blocks when there is no existing project id", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(migrationState()); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + processor_configs: { revenuecat: { sandbox_api_key: "enc_key" } }, + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("error=no_project_to_migrate"); + }); +}); diff --git a/server/tests/unit/revenuecat/initRevenuecatCli.test.ts b/server/tests/unit/revenuecat/initRevenuecatCli.test.ts new file mode 100644 index 000000000..4ab076e8e --- /dev/null +++ b/server/tests/unit/revenuecat/initRevenuecatCli.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js"; + +const mockFetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + object: "project", + id: "proj_123", + name: "Test Project", + created_at: Date.now(), + }), + { + status: 201, + headers: { "Content-Type": "application/json" }, + }, + ), + ), +); + +// Injected transport — the unit never touches global fetch. +const fetchImpl = mockFetch as unknown as typeof fetch; + +describe("initRevenuecatCli.createProject", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + test("POSTs /v2/projects with {name}", async () => { + const cli = initRevenuecatCli({ accessToken: "test-token", fetchImpl }); + const result = await cli.createProject({ name: "My Project" }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url.toString()).toBe("https://api.revenuecat.com/v2/projects"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ name: "My Project" }); + expect(result).toEqual({ + object: "project", + id: "proj_123", + name: "Test Project", + created_at: expect.any(Number), + }); + }); + + test("throws when API returns error", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response(JSON.stringify({ error: "invalid_name" }), { + status: 422, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const cli = initRevenuecatCli({ accessToken: "test-token", fetchImpl }); + await expect(cli.createProject({ name: "bad" })).rejects.toThrow(); + }); +}); + +const jsonResponse = (body: unknown, status = 200) => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + +describe("initRevenuecatCli.listProductPrices", () => { + beforeEach(() => mockFetch.mockClear()); + + test("parses RC's bare price array", async () => { + mockFetch.mockImplementationOnce(() => + jsonResponse([{ id: "prc1", amount_micros: 4_990_000, currency: "USD" }]), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + const prices = await cli.listProductPrices("prod_1"); + + const [url] = mockFetch.mock.calls[0] as unknown as [string]; + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_x/products/prod_1/prices", + ); + expect(prices).toEqual([ + { id: "prc1", amount_micros: 4_990_000, currency: "USD" }, + ]); + }); + + test("tolerates an { items } envelope", async () => { + mockFetch.mockImplementationOnce(() => + jsonResponse({ items: [{ id: "prc2", amount_micros: 1_000_000, currency: "EUR" }] }), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + expect(await cli.listProductPrices("prod_2")).toEqual([ + { id: "prc2", amount_micros: 1_000_000, currency: "EUR" }, + ]); + }); +}); + +describe("initRevenuecatCli.listAllProducts", () => { + beforeEach(() => mockFetch.mockClear()); + + test("follows next_page and concatenates items", async () => { + mockFetch + .mockImplementationOnce(() => + jsonResponse({ + object: "list", + items: [{ id: "p1", store_identifier: "a" }], + next_page: "/v2/projects/proj_x/products?page=2", + }), + ) + .mockImplementationOnce(() => + jsonResponse({ + object: "list", + items: [{ id: "p2", store_identifier: "b" }], + next_page: null, + }), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + const products = await cli.listAllProducts(); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(products.map((p) => p.id)).toEqual(["p1", "p2"]); + }); +}); + +describe("initRevenuecatCli webhook integrations", () => { + beforeEach(() => mockFetch.mockClear()); + + test("listWebhookIntegrations follows next_page", async () => { + mockFetch + .mockImplementationOnce(() => + jsonResponse({ + object: "list", + items: [{ id: "wh1", url: "https://a/1" }], + next_page: "/v2/projects/proj_x/integrations/webhooks?page=2", + }), + ) + .mockImplementationOnce(() => + jsonResponse({ + object: "list", + items: [{ id: "wh2", url: "https://a/2" }], + next_page: null, + }), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + const hooks = await cli.listWebhookIntegrations(); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const [firstUrl] = mockFetch.mock.calls[0] as unknown as [string]; + expect(firstUrl.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_x/integrations/webhooks?limit=100", + ); + expect(hooks.map((h) => h.id)).toEqual(["wh1", "wh2"]); + }); + + test("createWebhookIntegration POSTs the body", async () => { + mockFetch.mockImplementationOnce(() => + jsonResponse({ object: "webhook_integration", id: "wh_new" }, 201), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + const result = await cli.createWebhookIntegration({ + name: "Autumn (sandbox)", + url: "https://ngrok.test/webhooks/revenuecat/org_1/sandbox", + authorization_header: "whsec_abc", + environment: "sandbox", + }); + + const [url, init] = mockFetch.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_x/integrations/webhooks", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toMatchObject({ + authorization_header: "whsec_abc", + environment: "sandbox", + }); + expect(result.id).toBe("wh_new"); + }); +}); diff --git a/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts b/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts new file mode 100644 index 000000000..30b734d55 --- /dev/null +++ b/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js"; + +const mockFetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ object: "list", items: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), +); + +// Injected transport — the unit never touches global fetch. +const fetchImpl = mockFetch as unknown as typeof fetch; + +const lastCall = () => + mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as unknown as [ + string, + RequestInit, + ]; + +describe("initRevenuecatCli product/app methods", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + test("listApps GETs project apps and returns items", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response( + JSON.stringify({ + object: "list", + items: [{ object: "app", id: "app_1", type: "app_store" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl }); + const apps = await cli.listApps(); + + const [url, init] = lastCall(); + expect(url.toString()).toContain( + "https://api.revenuecat.com/v2/projects/proj_1/apps", + ); + expect(init?.method ?? "GET").toBe("GET"); + expect(apps).toHaveLength(1); + expect(apps[0].id).toBe("app_1"); + }); + + test("createProduct POSTs the body and returns the product", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response( + JSON.stringify({ object: "product", id: "prod_1" }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl }); + const result = await cli.createProduct({ + app_id: "app_1", + store_identifier: "autumn.live.acme.pro", + type: "subscription", + display_name: "Pro", + subscription: { duration: "P1M" }, + }); + + const [url, init] = lastCall(); + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_1/products", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toMatchObject({ + app_id: "app_1", + store_identifier: "autumn.live.acme.pro", + type: "subscription", + subscription: { duration: "P1M" }, + }); + expect(result.id).toBe("prod_1"); + }); + + test("updateProduct POSTs display_name to the product url", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response(JSON.stringify({ object: "product", id: "prod_1" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl }); + await cli.updateProduct("prod_1", { display_name: "Pro Plus" }); + + const [url, init] = lastCall(); + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_1/products/prod_1", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + display_name: "Pro Plus", + }); + }); + + test("createInStore POSTs store_information to the create_in_store url", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response(JSON.stringify({ created_product: { id: "1" } }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl }); + await cli.createInStore("prod_1", { + store_information: { + duration: "ONE_MONTH", + subscription_group_name: "Autumn - Default Group", + }, + }); + + const [url, init] = lastCall(); + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_1/products/prod_1/create_in_store", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + store_information: { + duration: "ONE_MONTH", + subscription_group_name: "Autumn - Default Group", + }, + }); + }); +}); diff --git a/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts b/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts new file mode 100644 index 000000000..2f453083c --- /dev/null +++ b/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts @@ -0,0 +1,126 @@ +/** + * Unit tests for registerRevenuecatWebhook — idempotent, one webhook per env, matched + * by URL, secret as the Authorization header. Base URL follows the NODE_ENV rule. + */ + +import { AppEnv } from "@autumn/shared"; +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import chalk from "chalk"; +import { + getRevenuecatWebhookUrl, + registerRevenuecatWebhook, +} from "@/external/revenueCat/misc/registerRevenuecatWebhook.js"; +import type { RevenueCatWebhookIntegration } from "@/external/revenueCat/revenuecatTypes.js"; + +const env = { + NODE_ENV: process.env.NODE_ENV, + NGROK_URL: process.env.NGROK_URL, + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, +}; + +beforeEach(() => { + process.env.NODE_ENV = "development"; + process.env.NGROK_URL = "https://ngrok.test"; + process.env.BETTER_AUTH_URL = "https://api.useautumn.com"; +}); + +afterEach(() => { + process.env.NODE_ENV = env.NODE_ENV; + process.env.NGROK_URL = env.NGROK_URL; + process.env.BETTER_AUTH_URL = env.BETTER_AUTH_URL; +}); + +const makeCli = (existing: RevenueCatWebhookIntegration[] = []) => { + const createWebhookIntegration = mock( + async (body: Record) => + ({ id: "wh_1", ...body }) as RevenueCatWebhookIntegration, + ); + const listWebhookIntegrations = mock(async () => existing); + return { + cli: { listWebhookIntegrations, createWebhookIntegration } as never, + listWebhookIntegrations, + createWebhookIntegration, + }; +}; + +test(`${chalk.yellowBright("webhook url: dev uses NGROK_URL + AppEnv segment")}`, () => { + expect(getRevenuecatWebhookUrl({ orgId: "org_1", env: AppEnv.Sandbox })).toBe( + "https://ngrok.test/webhooks/revenuecat/org_1/sandbox", + ); +}); + +test(`${chalk.yellowBright("webhook url: prod uses BETTER_AUTH_URL")}`, () => { + process.env.NODE_ENV = "production"; + expect(getRevenuecatWebhookUrl({ orgId: "org_1", env: AppEnv.Live })).toBe( + "https://api.useautumn.com/webhooks/revenuecat/org_1/live", + ); +}); + +test(`${chalk.yellowBright("register: no existing webhook → creates with secret + environment, no event/app scoping")}`, async () => { + const { cli, createWebhookIntegration } = makeCli([]); + const status = await registerRevenuecatWebhook({ + rcCli: cli, + orgId: "org_1", + env: AppEnv.Sandbox, + secret: "whsec_abc", + }); + + expect(status).toBe("created"); + const body = createWebhookIntegration.mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect(body).toMatchObject({ + url: "https://ngrok.test/webhooks/revenuecat/org_1/sandbox", + authorization_header: "whsec_abc", + environment: "sandbox", + }); + expect(body.event_types).toBeUndefined(); + expect(body.app_id).toBeUndefined(); +}); + +test(`${chalk.yellowBright("register: live env maps to environment=production")}`, async () => { + const { cli, createWebhookIntegration } = makeCli([]); + await registerRevenuecatWebhook({ + rcCli: cli, + orgId: "org_1", + env: AppEnv.Live, + secret: "whsec_live", + }); + expect( + (createWebhookIntegration.mock.calls[0]?.[0] as { environment: string }) + .environment, + ).toBe("production"); +}); + +test(`${chalk.yellowBright("register: existing webhook with same url → exists, no create")}`, async () => { + const { cli, createWebhookIntegration } = makeCli([ + { + id: "wh_existing", + name: "Autumn (sandbox)", + url: "https://ngrok.test/webhooks/revenuecat/org_1/sandbox", + }, + ]); + const status = await registerRevenuecatWebhook({ + rcCli: cli, + orgId: "org_1", + env: AppEnv.Sandbox, + secret: "whsec_abc", + }); + expect(status).toBe("exists"); + expect(createWebhookIntegration).not.toHaveBeenCalled(); +}); + +test(`${chalk.yellowBright("register: no base url → skipped, no list/create")}`, async () => { + delete process.env.NGROK_URL; + const { cli, listWebhookIntegrations, createWebhookIntegration } = makeCli([]); + const status = await registerRevenuecatWebhook({ + rcCli: cli, + orgId: "org_1", + env: AppEnv.Sandbox, + secret: "whsec_abc", + }); + expect(status).toBe("skipped"); + expect(listWebhookIntegrations).not.toHaveBeenCalled(); + expect(createWebhookIntegration).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/revenuecat/revenuecatMcp.test.ts b/server/tests/unit/revenuecat/revenuecatMcp.test.ts new file mode 100644 index 000000000..4c647ef90 --- /dev/null +++ b/server/tests/unit/revenuecat/revenuecatMcp.test.ts @@ -0,0 +1,63 @@ +/** + * Unit tests for callRcMcpTool — JSON-RPC tools/call against RC's MCP server, + * parsing the SSE (`data:`) response and surfacing tool errors. fetch is injected. + */ + +import { expect, mock, test } from "bun:test"; +import chalk from "chalk"; +import { callRcMcpTool } from "@/external/revenueCat/misc/revenuecatMcp.js"; + +const sse = (obj: unknown, status = 200) => + Promise.resolve( + new Response(`event: message\ndata: ${JSON.stringify(obj)}\n\n`, { + status, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + +test(`${chalk.yellowBright("callRcMcpTool: posts JSON-RPC tools/call with bearer + parses SSE result")}`, async () => { + const fetchImpl = mock(() => sse({ result: { isError: false, content: [] } })); + + const result = await callRcMcpTool({ + accessToken: "atk_abc", + name: "create-product-prices", + arguments: { project_id: "proj", product_id: "prod", prices: [] }, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url.toString()).toBe("https://mcp.revenuecat.ai/mcp"); + expect((init.headers as Record).Authorization).toBe("Bearer atk_abc"); + const sent = JSON.parse(init.body as string); + expect(sent).toMatchObject({ + method: "tools/call", + params: { name: "create-product-prices" }, + }); + expect(result).toEqual({ isError: false, content: [] }); +}); + +test(`${chalk.yellowBright("callRcMcpTool: throws when the tool reports isError")}`, async () => { + const fetchImpl = mock(() => + sse({ result: { isError: true, content: [{ type: "text", text: "nope" }] } }), + ); + await expect( + callRcMcpTool({ + accessToken: "t", + name: "create-product-prices", + arguments: {}, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toThrow(/create-product-prices/); +}); + +test(`${chalk.yellowBright("callRcMcpTool: throws on a JSON-RPC error")}`, async () => { + const fetchImpl = mock(() => sse({ error: { message: "bad token" } })); + await expect( + callRcMcpTool({ + accessToken: "t", + name: "x", + arguments: {}, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toThrow(/bad token/); +}); diff --git a/server/tests/unit/revenuecat/revenuecatOAuth.test.ts b/server/tests/unit/revenuecat/revenuecatOAuth.test.ts new file mode 100644 index 000000000..027dd1593 --- /dev/null +++ b/server/tests/unit/revenuecat/revenuecatOAuth.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { OAuth2Tokens } from "arctic"; + +const mockValidateAuthorizationCode = mock(() => + Promise.resolve( + new OAuth2Tokens({ + access_token: "atk_test_token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rtk_test_token", + scope: "project_configuration:projects:read", + }), + ), +); + +mock.module("arctic", () => ({ + OAuth2Client: class { + createAuthorizationURLWithPKCE() { + return new URL("https://api.revenuecat.com/oauth2/authorize?test=1"); + } + validateAuthorizationCode = mockValidateAuthorizationCode; + refreshAccessToken = mock(() => Promise.resolve(new OAuth2Tokens({}))); + }, + CodeChallengeMethod: { S256: 0, Plain: 1 }, + generateCodeVerifier: () => "test-code-verifier", + generateState: () => "test-state", + OAuth2Tokens, +})); + +const { exchangeRcCode } = await import( + "@/external/revenueCat/misc/revenuecatOAuth.js" +); + +describe("exchangeRcCode", () => { + beforeEach(() => { + process.env.REVENUECAT_OAUTH_CLIENT_ID = "rc_client_id"; + process.env.REVENUECAT_OAUTH_CLIENT_SECRET = "rc_client_secret"; + process.env.BETTER_AUTH_URL = "https://auth.example.com"; + mockValidateAuthorizationCode.mockClear(); + }); + + afterEach(() => { + delete process.env.REVENUECAT_OAUTH_CLIENT_ID; + delete process.env.REVENUECAT_OAUTH_CLIENT_SECRET; + }); + + test("exchanges authorization code for tokens", async () => { + const tokens = await exchangeRcCode({ + code: "auth_code_123", + codeVerifier: "verifier_abc", + }); + + expect(mockValidateAuthorizationCode).toHaveBeenCalledWith( + "https://api.revenuecat.com/oauth2/token", + "auth_code_123", + "verifier_abc", + ); + expect(tokens.accessToken()).toBe("atk_test_token"); + expect(tokens.refreshToken()).toBe("rtk_test_token"); + }); +}); diff --git a/server/tests/unit/revenuecat/syncRevenueCatProducts.test.ts b/server/tests/unit/revenuecat/syncRevenueCatProducts.test.ts new file mode 100644 index 000000000..337004651 --- /dev/null +++ b/server/tests/unit/revenuecat/syncRevenueCatProducts.test.ts @@ -0,0 +1,87 @@ +import { AppEnv, BillingInterval } from "@autumn/shared"; +import { describe, expect, test } from "bun:test"; +import { + autumnIntervalToRcDuration, + autumnIntervalToStoreDuration, + getRcStoreIdentifier, + getSubscriptionGroupName, + isRevenueCatPushEnabled, +} from "@/external/revenueCat/sync/revenuecatProductSyncUtils.js"; + +describe("autumnIntervalToRcDuration (ISO-8601, for createProduct)", () => { + test("maps supported intervals", () => { + expect( + autumnIntervalToRcDuration({ interval: BillingInterval.Month, intervalCount: 1 }), + ).toBe("P1M"); + expect( + autumnIntervalToRcDuration({ interval: BillingInterval.Year, intervalCount: 1 }), + ).toBe("P1Y"); + expect( + autumnIntervalToRcDuration({ interval: BillingInterval.Month, intervalCount: 12 }), + ).toBe("P1Y"); + }); + test("lossy → null", () => { + expect( + autumnIntervalToRcDuration({ interval: BillingInterval.Month, intervalCount: 4 }), + ).toBeNull(); + }); +}); + +describe("autumnIntervalToStoreDuration (enum, for create_in_store)", () => { + test("maps supported intervals to RC store enum", () => { + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Month, intervalCount: 1 }), + ).toBe("ONE_MONTH"); + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Month, intervalCount: 3 }), + ).toBe("THREE_MONTHS"); + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.SemiAnnual, intervalCount: 1 }), + ).toBe("SIX_MONTHS"); + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Year, intervalCount: 1 }), + ).toBe("ONE_YEAR"); + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Week, intervalCount: 1 }), + ).toBe("ONE_WEEK"); + }); + test("lossy → null", () => { + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Year, intervalCount: 2 }), + ).toBeNull(); + }); +}); + +describe("getRcStoreIdentifier", () => { + test("uses org id, env, plan id", () => { + expect( + getRcStoreIdentifier({ env: AppEnv.Live, orgId: "org_123", planId: "pro" }), + ).toBe("autumn.live.org_123.pro"); + expect( + getRcStoreIdentifier({ env: AppEnv.Sandbox, orgId: "org_123", planId: "pro" }), + ).toBe("autumn.sandbox.org_123.pro"); + }); +}); + +describe("getSubscriptionGroupName", () => { + test("default when group empty/null", () => { + expect(getSubscriptionGroupName()).toBe("Autumn - Default Group"); + expect(getSubscriptionGroupName(null)).toBe("Autumn - Default Group"); + expect(getSubscriptionGroupName("")).toBe("Autumn - Default Group"); + }); + test("uses the plan group when set", () => { + expect(getSubscriptionGroupName("Premium")).toBe("Autumn - Premium Group"); + }); +}); + +describe("isRevenueCatPushEnabled", () => { + const oauth = { access_token: "a", refresh_token: "r", expires_at: 0 }; + test("live needs oauth, sandbox needs sandbox_oauth", () => { + expect(isRevenueCatPushEnabled({ revenueCatConfig: { oauth }, env: AppEnv.Live })).toBe(true); + expect(isRevenueCatPushEnabled({ revenueCatConfig: {}, env: AppEnv.Live })).toBe(false); + expect( + isRevenueCatPushEnabled({ revenueCatConfig: { sandbox_oauth: oauth }, env: AppEnv.Sandbox }), + ).toBe(true); + expect(isRevenueCatPushEnabled({ revenueCatConfig: { oauth }, env: AppEnv.Sandbox })).toBe(false); + }); +}); diff --git a/server/tests/unit/shared/pricesAreSame.test.ts b/server/tests/unit/shared/pricesAreSame.test.ts index e81a24323..60f9da23b 100644 --- a/server/tests/unit/shared/pricesAreSame.test.ts +++ b/server/tests/unit/shared/pricesAreSame.test.ts @@ -5,6 +5,7 @@ import { BillingInterval, BillWhen, Infinite, + PriceSchema, } from "@autumn/shared"; import { pricesAreSame } from "@shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame"; @@ -17,15 +18,15 @@ const fixedPrice = { is_custom: false, entitlement_id: null, proration_config: null, - config: { - type: PriceType.Fixed, - amount: 10, - interval: BillingInterval.Month, - stripe_product_id: null, - feature_id: null, - internal_feature_id: null, - }, - } satisfies Price; + config: { + type: PriceType.Fixed, + amount: 10, + interval: BillingInterval.Month, + stripe_product_id: null, + feature_id: null, + internal_feature_id: null, + }, +} satisfies Price; const usagePrice = { id: "price_usage", @@ -48,6 +49,22 @@ const usagePrice = { } satisfies Price; describe("pricesAreSame", () => { + test("normalizes ignored fixed price metadata", () => { + const parsed = PriceSchema.parse({ + ...fixedPrice, + config: { + ...fixedPrice.config, + stripe_product_id: "prod_fixed", + feature_id: "base", + internal_feature_id: "internal_base", + }, + }); + + expect(parsed.config.stripe_product_id).toBeNull(); + expect(parsed.config.feature_id).toBeNull(); + expect(parsed.config.internal_feature_id).toBeNull(); + }); + test("returns false instead of throwing for fixed vs usage prices", () => { expect(pricesAreSame(fixedPrice, usagePrice)).toBe(false); }); diff --git a/server/tests/unit/utils/convert-amount-utils.test.ts b/server/tests/unit/utils/convert-amount-utils.test.ts new file mode 100644 index 000000000..574dcff2d --- /dev/null +++ b/server/tests/unit/utils/convert-amount-utils.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { atmnToStripeAmount } from "@autumn/shared"; + +describe("convertAmountUtils", () => { + test("converts decimal currencies to integer minor units", () => { + expect(atmnToStripeAmount({ amount: 10.235, currency: "USD" })).toBe(1024); + }); + + test("rounds zero-decimal currencies to integer Stripe units", () => { + expect(atmnToStripeAmount({ amount: 1000.5, currency: "JPY" })).toBe(1001); + }); +}); diff --git a/server/tests/utils/fixtures/items.ts b/server/tests/utils/fixtures/items.ts index 39e0b7ca7..131504a13 100644 --- a/server/tests/utils/fixtures/items.ts +++ b/server/tests/utils/fixtures/items.ts @@ -51,13 +51,16 @@ const adminRights = () => const free = ({ featureId, includedUsage = 100, + entityFeatureId, }: { featureId: string; includedUsage?: number; + entityFeatureId?: string; }): LimitedItem => constructFeatureItem({ featureId, includedUsage, + entityFeatureId, }) as LimitedItem; /** @@ -163,6 +166,16 @@ const monthlyCredits = ({ rolloverConfig, }) as LimitedItem; +/** + * Generic unlimited feature - no usage cap + * @param featureId - Feature ID + */ +const unlimited = ({ featureId }: { featureId: string }) => + constructFeatureItem({ + featureId, + unlimited: true, + }); + /** * Unlimited messages - no usage cap * @returns Unlimited messages feature item @@ -783,6 +796,7 @@ export const items = { freeUsers, freeAllocatedUsers, freeAllocatedWorkflows, + unlimited, unlimitedMessages, weeklyMessages, lifetimeMessages, diff --git a/server/tinybird/copies/events_org_hourly_mv_backfill.pipe b/server/tinybird/copies/events_org_hourly_mv_backfill.pipe new file mode 100644 index 000000000..46c586d91 --- /dev/null +++ b/server/tinybird/copies/events_org_hourly_mv_backfill.pipe @@ -0,0 +1,29 @@ +DESCRIPTION > + History backfill for events_org_hourly_mv (deployed with BACKFILL skip). Fills the window + before the MV's forward-materialization seam, in bounded half-open [start_date, end_date) + chunks on on-demand compute + (tb copy run events_org_hourly_mv_backfill --on-demand-compute --param start_date=... --param end_date=...). + SQL mirrors events_org_hourly_mv_pipe EXACTLY (same projection + GROUP BY, no ARRAY JOIN) so + backfilled rows are identical to forward-materialized ones. No fan-out, so chunks can be wide. + + Seam safety: MergeTree does NOT dedup — keep every end_date <= the MV's actual promote time + (capture it empirically: min(hour) once forward events land), else overlapping hours double-count. + +NODE migrate +SQL > + % + SELECT + org_id, + env, + event_name, + toStartOfHour(timestamp) as hour, + sum(toFloat64(coalesce(value, 1))) as total_value, + count() as event_count + FROM events + WHERE timestamp >= {{DateTime(start_date, '2026-06-01 00:00:00')}} + AND timestamp < {{DateTime(end_date, '2026-06-02 00:00:00')}} + GROUP BY org_id, env, event_name, hour + +TYPE COPY +TARGET_DATASOURCE events_org_hourly_mv +COPY_MODE append diff --git a/server/tinybird/copies/events_property_mv_backfill.pipe b/server/tinybird/copies/events_property_mv_backfill.pipe new file mode 100644 index 000000000..b8f081ffd --- /dev/null +++ b/server/tinybird/copies/events_property_mv_backfill.pipe @@ -0,0 +1,41 @@ +DESCRIPTION > + History backfill for events_property_mv (deployed with BACKFILL skip). Fills the window before + the MV's promote seam, in bounded chunks on on-demand compute + (tb copy run events_property_mv_backfill --on-demand-compute --param start_date=... --param end_date=...). + SQL mirrors events_property_mv_pipe EXACTLY (same ARRAY JOIN + value-shape gate) so backfilled + rows are identical to forward-materialized ones. Reads from `events`, writes only this rollup. + + Seam safety: MergeTree does not dedup; window is half-open [start_date, end_date). Keep every + end_date <= the MV's promote time, chunk by day/week, and watch memory + insert cost (the + ARRAY JOIN fans each event by its key count). + +NODE migrate +SQL > + % + SELECT + org_id, + env, + customer_id, + coalesce(entity_id, '') as entity_id, + event_name, + coalesce(internal_product_id, '') as internal_product_id, + kv.1 as property_key, + kv.2 as property_value, + toStartOfHour(timestamp) as hour, + sum(toFloat64(coalesce(value, 1))) as total_value, + count() as event_count + FROM events + ARRAY JOIN JSONExtractKeysAndValues(properties::String, 'String') as kv + WHERE timestamp >= {{DateTime(start_date, '2026-06-01 00:00:00')}} + AND timestamp < {{DateTime(end_date, '2026-06-02 00:00:00')}} + AND kv.2 != '' + AND length(kv.2) <= 64 + AND NOT match(kv.2, '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') + AND NOT match(kv.2, '^[0-9a-fA-F]{24,}$') + AND NOT match(kv.2, '^[A-Za-z0-9_-]{32,}$') + AND (toFloat64OrNull(kv.2) IS NULL OR position(kv.2, '.') = 0) + GROUP BY org_id, env, customer_id, entity_id, event_name, internal_product_id, property_key, property_value, hour + +TYPE COPY +TARGET_DATASOURCE events_property_mv +COPY_MODE append diff --git a/server/tinybird/materializations/events_org_hourly_mv.datasource b/server/tinybird/materializations/events_org_hourly_mv.datasource new file mode 100644 index 000000000..33b648114 --- /dev/null +++ b/server/tinybird/materializations/events_org_hourly_mv.datasource @@ -0,0 +1,27 @@ +DESCRIPTION > + Coarse org-grain hourly rollup of `events`, keyed by ONLY (org_id, env, event_name, hour) — + no customer_id / entity_id / properties. Serves the all-customers (aggregateAll) ungrouped + total + time-series in ms: a huge org over 90d collapses from millions of customer-grained + rows in events_hourly_no_properties_two_mv to a few thousand org-grained rows. + + Reads `events` directly (one row per event, no ARRAY JOIN), so totals reconcile exactly with + events_hourly_no_properties_two_mv and there is no property double-count. Only queries with no + customer_id, no entity_id and no property filter may read it — everything else keeps its table. + + Sort key leads org_id, env, event_name so the dominant scan (one org/env, few event_names, a + date range) hits one contiguous slice. BACKFILL skip: deploy empty + forward trigger only; + history filled in bounded chunks via events_org_hourly_mv_backfill. + +SCHEMA > + `org_id` String, + `env` String, + `event_name` String, + `hour` DateTime, + `total_value` Float64, + `event_count` UInt64 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(hour)" +ENGINE_SORTING_KEY "org_id, env, event_name, hour" + +BACKFILL skip diff --git a/server/tinybird/materializations/events_org_hourly_mv_pipe.pipe b/server/tinybird/materializations/events_org_hourly_mv_pipe.pipe new file mode 100644 index 000000000..3de7f6792 --- /dev/null +++ b/server/tinybird/materializations/events_org_hourly_mv_pipe.pipe @@ -0,0 +1,21 @@ +DESCRIPTION > + Materializes events into the coarse org-grain hourly rollup. Drops customer_id, entity_id, + internal_product_id and properties so rows collapse to one per (org, env, event, hour). No + ARRAY JOIN (reads events directly), so there is no property fan-out. total_value uses + sum(toFloat64(coalesce(value, 1))) — byte-identical to events_hourly_no_properties_two_mv — + so summing this rollup equals summing that one. + +NODE materialize +SQL > + SELECT + org_id, + env, + event_name, + toStartOfHour(timestamp) as hour, + sum(toFloat64(coalesce(value, 1))) as total_value, + count() as event_count + FROM events + GROUP BY org_id, env, event_name, hour + +TYPE materialized +DATASOURCE events_org_hourly_mv diff --git a/server/tinybird/materializations/events_property_mv.datasource b/server/tinybird/materializations/events_property_mv.datasource new file mode 100644 index 000000000..917b2d9aa --- /dev/null +++ b/server/tinybird/materializations/events_property_mv.datasource @@ -0,0 +1,34 @@ +DESCRIPTION > + Generic, org-independent hourly rollup for group-by on ANY low-cardinality event property. + One row per (dims…, property_key, property_value, hour) — produced by ARRAY JOIN-ing the + properties JSON in the materialization, so it covers EVERY key with no per-key column and no + curated list. A new hot key for any org becomes fast from its first event, zero human work. + + Cardinality safety = an INSERT-TIME value-shape gate in the materialization (see the pipe): + UUID / long-hex / long-opaque / >64-char VALUES (jobId, requestId, traceId) never produce a + row, so the tall rollup cannot fan to 1-row-per-event on the explosive keys. Row count tracks + distinct LOW-CARD values, not event volume. A cron-maintained property_gate (later) is the + measured backstop for keys that slip the shape heuristic. + + Sort key leads org_id, env, property_key so `GROUP BY properties.X` scans one contiguous slice; + customer_id next serves the per-customer hot path. BACKFILL skip: deploy empty + forward trigger + only; history filled in bounded chunks via events_property_mv_backfill. + +SCHEMA > + `org_id` String, + `env` String, + `customer_id` String, + `entity_id` String DEFAULT '', + `event_name` String, + `internal_product_id` String DEFAULT '', + `property_key` String, + `property_value` String, + `hour` DateTime, + `total_value` Float64, + `event_count` UInt64 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(hour)" +ENGINE_SORTING_KEY "org_id, env, property_key, customer_id, event_name, hour, property_value" + +BACKFILL skip diff --git a/server/tinybird/materializations/events_property_mv_pipe.pipe b/server/tinybird/materializations/events_property_mv_pipe.pipe new file mode 100644 index 000000000..146d75f2b --- /dev/null +++ b/server/tinybird/materializations/events_property_mv_pipe.pipe @@ -0,0 +1,34 @@ +DESCRIPTION > + Materializes events into the generic property rollup. ARRAY JOINs the properties JSON into + (property_key, property_value) pairs and aggregates per hour. The WHERE is the org-blind, + stateless value-shape gate: it DROPS high-entropy values (UUID / long-hex / long-opaque / + >64 chars) so jobId/requestId/traceId-class keys never write a row and cannot explode the + rollup. Low-card values (apiKeyId "68694", endpoint "scrape") pass and collapse to one row + per (dims, key, value, hour). + +NODE materialize +SQL > + SELECT + org_id, + env, + customer_id, + coalesce(entity_id, '') as entity_id, + event_name, + coalesce(internal_product_id, '') as internal_product_id, + kv.1 as property_key, + kv.2 as property_value, + toStartOfHour(timestamp) as hour, + sum(toFloat64(coalesce(value, 1))) as total_value, + count() as event_count + FROM events + ARRAY JOIN JSONExtractKeysAndValues(properties::String, 'String') as kv + WHERE kv.2 != '' + AND length(kv.2) <= 64 + AND NOT match(kv.2, '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') + AND NOT match(kv.2, '^[0-9a-fA-F]{24,}$') + AND NOT match(kv.2, '^[A-Za-z0-9_-]{32,}$') + AND (toFloat64OrNull(kv.2) IS NULL OR position(kv.2, '.') = 0) + GROUP BY org_id, env, customer_id, entity_id, event_name, internal_product_id, property_key, property_value, hour + +TYPE materialized +DATASOURCE events_property_mv diff --git a/server/tinybird/pipes/aggregate_groupable.pipe b/server/tinybird/pipes/aggregate_groupable.pipe index c69d2bd5e..c289a3489 100644 --- a/server/tinybird/pipes/aggregate_groupable.pipe +++ b/server/tinybird/pipes/aggregate_groupable.pipe @@ -16,6 +16,7 @@ SQL > % {% 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', 'plan_id']) and no_property_filters %} + {% set use_property_rollup = (String(group_column, 'property') == 'property') and no_property_filters %} SELECT {% if String(bin_size, 'day') == 'hour' %} @@ -32,6 +33,8 @@ SQL > entity_id as group_value, {% elif String(group_column, 'property') == 'plan_id' %} coalesce(internal_product_id, '') as group_value, + {% elif use_property_rollup %} + property_value as group_value, {% else %} {{ column('properties.' + String(property_key, '')) }}::String as group_value, {% end %} @@ -39,6 +42,8 @@ SQL > FROM {% if use_no_props %} events_hourly_no_properties_two_mv + {% elif use_property_rollup %} + events_property_mv {% else %} events_hourly_mv {% end %} @@ -48,6 +53,9 @@ SQL > AND event_name IN {{ Array(event_names, 'String', default='[]') }} AND hour >= toDateTime({{ String(start_date, '2024-01-01 00:00:00') }}) AND hour <= toDateTime({{ String(end_date, '2024-12-31 23:59:59') }}) + {% if use_property_rollup %} + AND property_key = {{ String(property_key, '') }} + {% end %} {% if defined(customer_id) and String(customer_id, '') != '' %} AND customer_id = {{ String(customer_id) }} {% end %} @@ -73,6 +81,8 @@ 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_property_rollup %} + AND property_value != '' {% 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/server/tinybird/pipes/aggregate_simple.pipe b/server/tinybird/pipes/aggregate_simple.pipe index f956f3cd2..b5dc9a086 100644 --- a/server/tinybird/pipes/aggregate_simple.pipe +++ b/server/tinybird/pipes/aggregate_simple.pipe @@ -8,6 +8,8 @@ NODE endpoint TYPE endpoint SQL > % + {% 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_org_rollup = no_property_filters and (not defined(customer_id) or String(customer_id, '') == '') and (not defined(entity_id) or String(entity_id, '') == '') %} SELECT {% if String(bin_size, 'day') == 'hour' %} formatDateTime(hour, '%F %T') as period, @@ -18,17 +20,17 @@ SQL > {% end %} event_name, sum(total_value) as total_value - FROM {% if defined(filter_key_0) and String(filter_key_0, '') != '' %}events_hourly_mv{% elif defined(filter_key_1) and String(filter_key_1, '') != '' %}events_hourly_mv{% elif defined(filter_key_2) and String(filter_key_2, '') != '' %}events_hourly_mv{% elif defined(filter_key_3) and String(filter_key_3, '') != '' %}events_hourly_mv{% elif defined(filter_key_4) and String(filter_key_4, '') != '' %}events_hourly_mv{% else %}events_hourly_no_properties_two_mv{% end %} + FROM {% if not no_property_filters %}events_hourly_mv{% elif use_org_rollup %}events_org_hourly_mv{% else %}events_hourly_no_properties_two_mv{% end %} WHERE org_id = {{ String(org_id, '') }} AND env = {{ String(env, 'test') }} AND event_name IN {{ Array(event_names, 'String', default='[]') }} AND hour >= toDateTime({{ String(start_date, '2024-01-01 00:00:00') }}) AND hour <= toDateTime({{ String(end_date, '2024-12-31 23:59:59') }}) - {% if defined(customer_id) and String(customer_id, '') != '' %} + {% if not use_org_rollup and defined(customer_id) and String(customer_id, '') != '' %} AND customer_id = {{ String(customer_id) }} {% end %} - {% if defined(entity_id) and String(entity_id, '') != '' %} + {% if not use_org_rollup and defined(entity_id) and String(entity_id, '') != '' %} AND entity_id = {{ String(entity_id) }} {% end %} {% if defined(filter_key_0) and String(filter_key_0, '') != '' %} diff --git a/shared/api/balances/index.ts b/shared/api/balances/index.ts index 16c09cc8a..7ce1a2bec 100644 --- a/shared/api/balances/index.ts +++ b/shared/api/balances/index.ts @@ -2,4 +2,5 @@ export * from "./common/lockParams"; export * from "./create/index"; export * from "./delete/index"; export * from "./finalizeLock/index"; +export * from "./recalculate/index"; export * from "./update/index"; diff --git a/shared/api/balances/recalculate/index.ts b/shared/api/balances/recalculate/index.ts new file mode 100644 index 000000000..12d72a15e --- /dev/null +++ b/shared/api/balances/recalculate/index.ts @@ -0,0 +1,2 @@ +export * from "./recalculateBalanceParams.js"; +export * from "./recalculateBalancePreview.js"; diff --git a/shared/api/balances/recalculate/recalculateBalanceParams.ts b/shared/api/balances/recalculate/recalculateBalanceParams.ts new file mode 100644 index 000000000..11431ed0c --- /dev/null +++ b/shared/api/balances/recalculate/recalculateBalanceParams.ts @@ -0,0 +1,21 @@ +import { z } from "zod/v4"; +import { ResetInterval } from "../../.."; +export const RecalculateBalanceParamsV0Schema = z.object({ + customer_id: z.string().meta({ + description: "The ID of the customer.", + }), + feature_id: z.string().meta({ + description: "The ID of the feature whose balances should be recalculated.", + }), + entity_id: z.string().optional().meta({ + description: "The ID of the entity.", + }), + interval: z.enum(ResetInterval).optional().meta({ + description: + "Target balances with a specific reset interval. Use when the customer has multiple balances for the same feature with different reset intervals.", + }), +}); + +export type RecalculateBalanceParamsV0 = z.infer< + typeof RecalculateBalanceParamsV0Schema +>; diff --git a/shared/api/balances/recalculate/recalculateBalancePreview.ts b/shared/api/balances/recalculate/recalculateBalancePreview.ts new file mode 100644 index 000000000..992feee18 --- /dev/null +++ b/shared/api/balances/recalculate/recalculateBalancePreview.ts @@ -0,0 +1,10 @@ +export interface RecalculateBalanceEntitlementPreview { + customer_entitlement_id: string; + before_remaining: number; + after_remaining: number; +} + +export interface RecalculateBalancePreview { + total_usage: number; + entitlements: RecalculateBalanceEntitlementPreview[]; +} diff --git a/shared/api/billing/attachV2/attachParamsV0.ts b/shared/api/billing/attachV2/attachParamsV0.ts index a927b95b1..cfba8affe 100644 --- a/shared/api/billing/attachV2/attachParamsV0.ts +++ b/shared/api/billing/attachV2/attachParamsV0.ts @@ -15,6 +15,8 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseV0Schema.extend({ invoice: z.boolean().optional(), enable_product_immediately: z.boolean().optional(), finalize_invoice: z.boolean().optional(), + invoice_template_id: z.string().optional(), + net_terms_days: z.number().int().positive().optional(), success_url: z.string().optional(), diff --git a/shared/api/billing/common/customerPlanChange.ts b/shared/api/billing/common/customerPlanChange.ts index 5d4736f7a..c8c24bb27 100644 --- a/shared/api/billing/common/customerPlanChange.ts +++ b/shared/api/billing/common/customerPlanChange.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { ApiPlanItemV1Schema } from "../../products/items/apiPlanItemV1.js"; export const PlanChangeActionEnum = z.enum([ "activated", @@ -65,6 +66,9 @@ export const CustomerPlanItemChangeSchema = z.object({ feature_id: z.string().meta({ description: "The ID of the feature that was added or removed.", }), + item: ApiPlanItemV1Schema.meta({ + description: "The item snapshot that was added or removed.", + }), }); export const CustomerPlanChangeSchema = z.object({ diff --git a/shared/api/billing/common/customizePlan/customizePlanV1.ts b/shared/api/billing/common/customizePlan/customizePlanV1.ts index fd4c34184..9ff2f6b7f 100644 --- a/shared/api/billing/common/customizePlan/customizePlanV1.ts +++ b/shared/api/billing/common/customizePlan/customizePlanV1.ts @@ -2,6 +2,7 @@ import { FreeTrialParamsV1Schema } from "@api/common/freeTrial/freeTrialParamsV1 import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice"; import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPlanItemParamsV1"; import { PlanItemFilterSchema } from "@api/products/items/filter/planItemFilter"; +import { ResetInterval } from "@models/productModels/intervals/resetInterval"; import { z } from "zod/v4"; export const UpdatePlanItemParamsV1Schema = z @@ -14,12 +15,17 @@ export const UpdatePlanItemParamsV1Schema = z description: "Override the matched item's included usage / allowance. Existing usage carries forward.", }), - }) - .meta({ - title: "UpdatePlanItem", - description: - "Patch an existing plan item in place. Phase 1 supports only `included`.", - }); + interval: z.enum(ResetInterval).optional().meta({ + description: + "Override the matched item's reset interval. Use 'one_off' for non-resetting balances.", + }), + }) + .meta({ + title: "UpdatePlanItem", + description: + "Deprecated. Use remove_items and add_items to replace plan items.", + deprecated: true, + }); export type UpdatePlanItemParamsV1 = z.infer; @@ -29,21 +35,22 @@ export const CustomizePlanV1Schema = z description: "Override the base price of the plan. Pass null to remove the base price.", }), - items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ - description: - "Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.", - }), + items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ + description: + "Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.", + }), add_items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ description: "Items to add to the plan.", }), remove_items: z.array(PlanItemFilterSchema).optional().meta({ description: "Filters selecting items to remove from the plan.", }), - update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({ - description: - "Patch existing matched plan items. Runs before add_items, after remove_items.", - internal: true, - }), + update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({ + description: + "Deprecated. Use remove_items and add_items to replace matched plan items.", + internal: true, + deprecated: true, + }), free_trial: FreeTrialParamsV1Schema.nullable().optional().meta({ description: "Override the plan's default free trial. Pass an object to set a custom trial, or null to remove the trial entirely.", @@ -57,10 +64,10 @@ export const CustomizePlanV1Schema = z data.add_items !== undefined || data.remove_items !== undefined || data.update_items !== undefined, - { - message: - "When using customize, at least one of price, items, add_items, remove_items, update_items, or free_trial must be provided", - }, + { + message: + "When using customize, at least one of price, items, add_items, remove_items, deprecated update_items, or free_trial must be provided", + }, ) .refine( (data) => @@ -70,10 +77,10 @@ export const CustomizePlanV1Schema = z data.remove_items !== undefined || data.update_items !== undefined) ), - { - message: - "customize.items (PUT-style) cannot be combined with add_items / remove_items / update_items (PATCH-style); pick one approach", - }, + { + message: + "customize.items (PUT-style) cannot be combined with add_items / remove_items / deprecated update_items (PATCH-style); pick one approach", + }, ) .meta({ title: "CustomizePlan", diff --git a/shared/api/billing/common/invoiceModeParams.ts b/shared/api/billing/common/invoiceModeParams.ts index 0dc357dbe..d21c552f9 100644 --- a/shared/api/billing/common/invoiceModeParams.ts +++ b/shared/api/billing/common/invoiceModeParams.ts @@ -13,6 +13,14 @@ export const InvoiceModeParamsSchema = z description: "If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.", }), + invoice_template_id: z.string().optional().meta({ + description: + "ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.", + }), + net_terms_days: z.number().int().positive().optional().meta({ + description: + "Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).", + }), }) .meta({ title: "InvoiceMode", diff --git a/shared/api/billing/common/mappers/billingParamsV0ToInvoiceModeParams.ts b/shared/api/billing/common/mappers/billingParamsV0ToInvoiceModeParams.ts index a048a12c0..ba08bd747 100644 --- a/shared/api/billing/common/mappers/billingParamsV0ToInvoiceModeParams.ts +++ b/shared/api/billing/common/mappers/billingParamsV0ToInvoiceModeParams.ts @@ -10,6 +10,8 @@ export const billingParamsV0ToInvoiceModeParams = ({ invoice?: boolean; enable_product_immediately?: boolean; finalize_invoice?: boolean; + invoice_template_id?: string; + net_terms_days?: number; }; }): InvoiceModeParams | undefined => { if (!input.invoice) return undefined; @@ -18,5 +20,7 @@ export const billingParamsV0ToInvoiceModeParams = ({ enabled: true, enable_plan_immediately: input.enable_product_immediately ?? false, finalize: input.finalize_invoice ?? true, + invoice_template_id: input.invoice_template_id, + net_terms_days: input.net_terms_days, }; }; diff --git a/shared/api/billing/createSchedule/createScheduleParamsV0.ts b/shared/api/billing/createSchedule/createScheduleParamsV0.ts index e40d86215..b47caa563 100644 --- a/shared/api/billing/createSchedule/createScheduleParamsV0.ts +++ b/shared/api/billing/createSchedule/createScheduleParamsV0.ts @@ -4,6 +4,7 @@ import { RedirectModeSchema } from "@api/billing/common/redirectMode"; import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice"; import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPlanItemParamsV1"; import { z } from "zod/v4"; +import { AttachDiscountSchema } from "../attachV2/attachDiscount"; import { BillingBehaviorSchema } from "../common/billingBehavior"; import { BillingCycleAnchorSchema } from "../common/billingCycleAnchor"; @@ -26,26 +27,25 @@ const CreateScheduleCustomizePlanSchema = z }, ); -export const CreateSchedulePlanSchema = z - .object({ - plan_id: z.string().meta({ - description: "The ID of the plan to schedule in this phase.", - }), - feature_quantities: z.array(FeatureQuantityParamsV0Schema).optional().meta({ - description: "Optional prepaid feature quantities for this phase's plan.", - }), - version: z.number().optional().meta({ - description: "Optional explicit plan version to schedule.", - }), - customize: CreateScheduleCustomizePlanSchema.optional().meta({ - description: - "Customize the plan to schedule. Can override the price, items, or both.", - }), - subscription_id: z.string().optional().meta({ - description: - "A unique ID to identify this subscription. Useful when scheduling the same plan multiple times.", - }), - }); +export const CreateSchedulePlanSchema = z.object({ + plan_id: z.string().meta({ + description: "The ID of the plan to schedule in this phase.", + }), + feature_quantities: z.array(FeatureQuantityParamsV0Schema).optional().meta({ + description: "Optional prepaid feature quantities for this phase's plan.", + }), + version: z.number().optional().meta({ + description: "Optional explicit plan version to schedule.", + }), + customize: CreateScheduleCustomizePlanSchema.optional().meta({ + description: + "Customize the plan to schedule. Can override the price, items, or both.", + }), + subscription_id: z.string().optional().meta({ + description: + "A unique ID to identify this subscription. Useful when scheduling the same plan multiple times.", + }), +}); export const CreateSchedulePhaseSchema = z.object({ starts_at: z.number().meta({ @@ -68,6 +68,10 @@ export const CreateScheduleParamsV0Schema = z description: "Invoice mode creates and sends an invoice instead of charging the customer's payment method immediately for the first phase.", }), + discounts: z.array(AttachDiscountSchema).optional().meta({ + description: + "List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code.", + }), success_url: z.string().optional().meta({ description: "URL to redirect to after successful checkout.", }), diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index ed1f2a26a..2583e0e04 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts @@ -17,6 +17,8 @@ export const ExtUpdateSubscriptionV0ParamsSchema = invoice: z.boolean().optional(), enable_product_immediately: z.boolean().optional(), finalize_invoice: z.boolean().optional(), + invoice_template_id: z.string().optional(), + net_terms_days: z.number().int().positive().optional(), // New diff --git a/shared/api/common/customerData.ts b/shared/api/common/customerData.ts index ff3a19635..a77175af0 100644 --- a/shared/api/common/customerData.ts +++ b/shared/api/common/customerData.ts @@ -77,7 +77,7 @@ export const CustomerDataSchema = z }), }) .meta({ - id: "CustomerData", + $id: "CustomerData", title: "CustomerData", description: "Customer details to set when creating a customer", }); diff --git a/shared/api/customers/cusPlans/apiSubscriptionV1.ts b/shared/api/customers/cusPlans/apiSubscriptionV1.ts index 022fe6e2e..947c7623d 100644 --- a/shared/api/customers/cusPlans/apiSubscriptionV1.ts +++ b/shared/api/customers/cusPlans/apiSubscriptionV1.ts @@ -52,6 +52,10 @@ export const ApiSubscriptionV1Schema = z.object({ quantity: z.number().meta({ description: "Number of units of this subscription (for per-seat plans).", }), + scope: z.enum(["customer", "entity"]).optional().meta({ + description: + "Whether this subscription is attached at the customer level or entity level.", + }), }); export const ApiPurchaseV0Schema = z.object({ @@ -71,6 +75,10 @@ export const ApiPurchaseV0Schema = z.object({ quantity: z.number().meta({ description: "Number of units purchased.", }), + scope: z.enum(["customer", "entity"]).optional().meta({ + description: + "Whether this purchase is attached at the customer level or entity level.", + }), }); export type ApiSubscriptionV1 = z.infer; diff --git a/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts b/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts index 113aa618f..0130e557f 100644 --- a/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts +++ b/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts @@ -13,5 +13,6 @@ export function apiSubscriptionV1ToPurchaseV0({ expires_at: input.expires_at, started_at: input.started_at, quantity: input.quantity, + scope: input.scope, }; } diff --git a/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts b/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts index 7d4f36961..1c472b37f 100644 --- a/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts +++ b/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts @@ -1,5 +1,5 @@ import type { FullCustomer } from "../../../../models/cusModels/fullCusModel.js"; -import { customerProductHasActiveStatus } from "../../../../utils/index.js"; +import { customerProductHasRelevantStatus } from "../../../../utils/index.js"; import type { CustomerFilter } from "../../../migrations/filters/customerFilter.js"; import { arrayFilterMatches, @@ -12,8 +12,8 @@ import { planFilterMatchesCustomerProduct } from "../../../products/utils/match/ * * JS-side mirror of the SQL compiler's `customerRegistry`. Used by the lazy * migration helper to skip non-matching customers without queueing work. - * Mirrors the `cp.status IN ACTIVE_STATUSES` ambient predicate baked into - * the SQL plan scope — non-active cusProducts are ignored. + * Mirrors the `cp.status IN RELEVANT_STATUSES` ambient predicate baked into + * the SQL plan scope — expired/paused cusProducts are ignored. * * Supports `customer_id` and `plan` (`$some` / `$every` / `$none` and the * implicit-`$some` bare form). `item` sugar throws to make the gap explicit, @@ -37,14 +37,13 @@ export const customerFilterMatchesFullCustomer = ({ } if (filter.plan !== undefined) { - const activeProducts = fullCustomer.customer_products.filter( - customerProductHasActiveStatus, + const relevantProducts = fullCustomer.customer_products.filter( + customerProductHasRelevantStatus, ); - const planFilter = filter.plan === "$none" ? { $none: {} } : filter.plan; if ( !arrayFilterMatches({ - filter: planFilter, - items: activeProducts, + filter: filter.plan, + items: relevantProducts, matchesElement: ({ filter: planFilter, item: customerProduct }) => planFilterMatchesCustomerProduct({ filter: planFilter, diff --git a/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts b/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts index a67ed73b4..e32d31a6e 100644 --- a/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts +++ b/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts @@ -17,9 +17,6 @@ export function parsePlanNav({ raw: NonNullable; ctx: ResolutionContext; }): IRNav { - if (raw === "$none") - return buildNav({ quantifier: "none", filter: {} as PlanFilter, ctx }); - if (!isQuantifierWrapper(raw)) return buildNav({ quantifier: "some", filter: raw as PlanFilter, ctx }); diff --git a/shared/api/migrations/compiler/registry/customerRegistry.ts b/shared/api/migrations/compiler/registry/customerRegistry.ts index f78f94e97..e7e2b52e1 100644 --- a/shared/api/migrations/compiler/registry/customerRegistry.ts +++ b/shared/api/migrations/compiler/registry/customerRegistry.ts @@ -1,4 +1,4 @@ -import { ACTIVE_STATUSES } from "../../../../utils/cusProductUtils/cusProductConstants.js"; +import { RELEVANT_STATUSES } from "../../../../utils/cusProductUtils/cusProductConstants.js"; import type { NavScope, RootScope } from "./registryTypes.js"; /** @@ -27,8 +27,8 @@ import type { NavScope, RootScope } from "./registryTypes.js"; * Ambient predicates push `org_id` / `env` down into every scope whose * table has those columns. Without this, multi-tenant scans bloat 10x+. * - * `cp.status IN ACTIVE_STATUSES` is also baked in — customer-rooted - * filters always operate on active plan instances. + * `cp.status IN RELEVANT_STATUSES` is also baked in — customer-rooted + * filters operate on active and scheduled plan instances. */ /** @@ -81,7 +81,7 @@ const planScope: NavScope = { ambient: [ { column: "cp.status", - source: { kind: "values", values: ACTIVE_STATUSES }, + source: { kind: "values", values: RELEVANT_STATUSES }, }, ], fields: { diff --git a/shared/api/migrations/filters/arrayFilter.ts b/shared/api/migrations/filters/arrayFilter.ts index 850ca08e2..79045328c 100644 --- a/shared/api/migrations/filters/arrayFilter.ts +++ b/shared/api/migrations/filters/arrayFilter.ts @@ -12,10 +12,21 @@ import { z } from "zod/v4"; */ export const arrayFilter = (element: T) => z.union([ + // Quantifier wrapper must come first and assert a `$`-key is present: + // `element` is a permissive object that would otherwise strip `$some`/ + // `$none`/`$every` down to `{}` and silently swallow the quantifier. + z + .object({ + $some: element.optional(), + $every: element.optional(), + $none: element.optional(), + }) + .refine( + (v) => + v.$some !== undefined || + v.$every !== undefined || + v.$none !== undefined, + { message: "quantifier object requires $some, $every, or $none" }, + ), element, - z.object({ - $some: element.optional(), - $every: element.optional(), - $none: element.optional(), - }), ]); diff --git a/shared/api/migrations/filters/customerFilter.ts b/shared/api/migrations/filters/customerFilter.ts index a49f6177f..79027f3e8 100644 --- a/shared/api/migrations/filters/customerFilter.ts +++ b/shared/api/migrations/filters/customerFilter.ts @@ -17,7 +17,7 @@ import { PlanItemFilterSchema } from "./planItemFilter.js"; */ export const CustomerFilterSchema = z.object({ customer_id: StringMatcherSchema.optional(), - plan: z.union([arrayFilter(PlanFilterSchema), z.literal("$none")]).optional(), + plan: arrayFilter(PlanFilterSchema).optional(), item: arrayFilter(PlanItemFilterSchema).optional(), }); diff --git a/shared/api/migrations/filters/planFilter.ts b/shared/api/migrations/filters/planFilter.ts index 73bec80a5..30b48e56e 100644 --- a/shared/api/migrations/filters/planFilter.ts +++ b/shared/api/migrations/filters/planFilter.ts @@ -12,8 +12,8 @@ import { PlanItemFilterSchema } from "./planItemFilter.js"; * Filter over a plan. Migration-scoped: stable contract decoupled from * `ApiPlanV1`. * - * Customer-rooted filters automatically scope to active customer-product - * status (`cp.status IN ACTIVE_STATUSES`). + * Customer-rooted filters automatically scope to relevant customer-product + * status (`cp.status IN RELEVANT_STATUSES`). * * `price` is the plan's BASE price (customer_price linked to a price with * `entitlement_id IS NULL`). Use `price: null` for free plans, diff --git a/shared/api/migrations/filters/planner/accessPaths/planPlanIdAccessPath.ts b/shared/api/migrations/filters/planner/accessPaths/planPlanIdAccessPath.ts new file mode 100644 index 000000000..c017c217b --- /dev/null +++ b/shared/api/migrations/filters/planner/accessPaths/planPlanIdAccessPath.ts @@ -0,0 +1,59 @@ +import { RELEVANT_STATUSES } from "../../../../../utils/cusProductUtils/cusProductConstants.js"; +import type { IRLeaf } from "../../../compiler/ir/irTypes.js"; +import type { CustomerAccessPath } from "../types.js"; + +export type PlanIdConstraint = Pick & { + field: "plan_id"; + op: "eq" | "in"; +}; + +export const planPlanIdAccessPath: CustomerAccessPath = { + id: "plan.plan_id", + buildSource: ({ constraint, ambient }) => { + const params: unknown[] = []; + const orgId = ambient.orgId; + const env = ambient.env; + if (orgId === undefined) throw new Error("Missing ambient orgId"); + if (env === undefined) throw new Error("Missing ambient env"); + + params.push(orgId, env); + const planPredicate = + constraint.op === "eq" + ? buildEqPredicate(constraint.value, params) + : buildInPredicate(constraint.value, params); + params.push(...RELEVANT_STATUSES, orgId, env); + const statusPlaceholders = RELEVANT_STATUSES.map(() => "?").join(", "); + + return { + sql: [ + "(WITH plan_products AS MATERIALIZED (", + "SELECT p.internal_id FROM products p", + "WHERE p.org_id = ? AND p.env = ?", + `AND ${planPredicate}`, + ") SELECT DISTINCT c.internal_id, c.id, c.name, c.email, c.org_id, c.env", + "FROM plan_products pp", + "JOIN customer_products cp ON cp.internal_product_id = pp.internal_id", + "JOIN customers c ON c.internal_id = cp.internal_customer_id", + `WHERE cp.status IN (${statusPlaceholders})`, + "AND c.org_id = ?", + "AND c.env = ?) c", + ].join(" "), + params, + }; + }, +}; + +const buildEqPredicate = (value: PlanIdConstraint["value"], params: unknown[]) => { + if (typeof value !== "string") + throw new Error("plan.plan_id eq access path requires a string value"); + params.push(value); + return "p.id = ?"; +}; + +const buildInPredicate = (value: PlanIdConstraint["value"], params: unknown[]) => { + if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) + throw new Error("plan.plan_id in access path requires string values"); + if (value.length === 0) return "FALSE"; + params.push(...value); + return `p.id IN (${value.map(() => "?").join(", ")})`; +}; diff --git a/shared/api/migrations/filters/planner/buildCustomerCandidateQuery.ts b/shared/api/migrations/filters/planner/buildCustomerCandidateQuery.ts new file mode 100644 index 000000000..ffe9bbd2e --- /dev/null +++ b/shared/api/migrations/filters/planner/buildCustomerCandidateQuery.ts @@ -0,0 +1,50 @@ +import type { CustomerFilter } from "../customerFilter.js"; +import { filterToIr } from "../../compiler/filterToIr/filterToIr.js"; +import type { ResolutionContext } from "../../compiler/filterToIr/resolutionContext.js"; +import { + type AmbientContext, + irToSql, +} from "../../compiler/irToSql/irToSql.js"; +import { customerRegistry } from "../../compiler/registry/customerRegistry.js"; +import { planPlanIdAccessPath } from "./accessPaths/planPlanIdAccessPath.js"; +import { chooseCustomerAccessPath } from "./chooseCustomerAccessPath.js"; +import type { CustomerCandidateQuery } from "./types.js"; + +export const buildCustomerCandidateQuery = ({ + filter, + ctx, + ambient, +}: { + filter: CustomerFilter; + ctx: ResolutionContext; + ambient: AmbientContext; +}): CustomerCandidateQuery => { + const ir = filterToIr({ filter, ctx }); + const fallbackWhere = irToSql({ ir, root: customerRegistry, ambient }); + const accessPath = chooseCustomerAccessPath(ir); + + if (!accessPath) { + return { + source: { sql: "customers c", params: [] }, + where: fallbackWhere, + accessPath: { kind: "fallback" }, + }; + } + + if (accessPath.id === "plan.plan_id") { + return { + source: planPlanIdAccessPath.buildSource({ + constraint: accessPath.constraint, + ambient, + }), + where: fallbackWhere, + accessPath: { kind: "planned", id: accessPath.id }, + }; + } + + return { + source: { sql: "customers c", params: [] }, + where: fallbackWhere, + accessPath: { kind: "fallback" }, + }; +}; diff --git a/shared/api/migrations/filters/planner/chooseCustomerAccessPath.ts b/shared/api/migrations/filters/planner/chooseCustomerAccessPath.ts new file mode 100644 index 000000000..3d26d1cde --- /dev/null +++ b/shared/api/migrations/filters/planner/chooseCustomerAccessPath.ts @@ -0,0 +1,60 @@ +import type { IRLeaf, IRNav, IRNode } from "../../compiler/ir/irTypes.js"; +import type { PlanIdConstraint } from "./accessPaths/planPlanIdAccessPath.js"; + +export type ChosenCustomerAccessPath = { + id: "plan.plan_id"; + constraint: PlanIdConstraint; +}; + +export const chooseCustomerAccessPath = ( + ir: IRNode, +): ChosenCustomerAccessPath | undefined => { + const planNav = findNecessaryPlanNav(ir); + if (!planNav) return undefined; + + const planIdLeaf = findNecessaryPlanIdLeaf(planNav.child); + if (!planIdLeaf) return undefined; + + return { + id: "plan.plan_id", + constraint: { + field: "plan_id", + op: planIdLeaf.op, + value: planIdLeaf.value, + }, + }; +}; + +const findNecessaryPlanNav = (node: IRNode): IRNav | undefined => { + const children = node.kind === "and" ? node.children : [node]; + return children.find( + (child): child is IRNav => + child.kind === "nav" && + child.name === "plan" && + child.quantifier === "some", + ); +}; + +const findNecessaryPlanIdLeaf = (node: IRNode): PlanIdConstraint | undefined => { + const children = node.kind === "and" ? node.children : [node]; + const leaf = children.find( + (child): child is IRLeaf => + child.kind === "leaf" && + child.field === "plan_id" && + (child.op === "eq" || child.op === "in"), + ); + + if (!leaf) return undefined; + if (leaf.op === "eq" && typeof leaf.value === "string") { + return { field: "plan_id", op: "eq", value: leaf.value }; + } + if ( + leaf.op === "in" && + Array.isArray(leaf.value) && + leaf.value.length > 0 && + leaf.value.every((value) => typeof value === "string") + ) { + return { field: "plan_id", op: "in", value: leaf.value }; + } + return undefined; +}; diff --git a/shared/api/migrations/filters/planner/types.ts b/shared/api/migrations/filters/planner/types.ts new file mode 100644 index 000000000..2fea20277 --- /dev/null +++ b/shared/api/migrations/filters/planner/types.ts @@ -0,0 +1,21 @@ +import type { CompiledSql } from "../../compiler/irToSql/irToSql.js"; + +export type CustomerAccessPathId = "plan.plan_id"; + +export type CustomerCandidateQuery = { + /** SQL source for FROM. It must expose a `c` alias with customer columns. */ + source: CompiledSql; + /** Final customer predicate. Planned paths keep the fallback predicate here. */ + where: CompiledSql; + accessPath: + | { kind: "fallback" } + | { kind: "planned"; id: CustomerAccessPathId }; +}; + +export type CustomerAccessPath = { + id: CustomerAccessPathId; + buildSource: (args: { + constraint: TConstraint; + ambient: Record; + }) => CompiledSql; +}; diff --git a/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts b/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts index 611e2b42e..4f6f76171 100644 --- a/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts +++ b/shared/api/migrations/operations/customer/updatePlan/updatePlanOp.ts @@ -7,10 +7,14 @@ import { PlanFilterSchema } from "../../../filters/planFilter.js"; export const MigrationUpdatePlanCustomizeSchema = z .object({ - price: BasePriceParamsSchema.nullable().optional(), - add_items: z.array(CreatePlanItemParamsV1Schema).optional(), - remove_items: z.array(PlanItemFilterSchema).optional(), - update_items: z.array(UpdatePlanItemParamsV1Schema).optional(), + price: BasePriceParamsSchema.nullable().optional(), + add_items: z.array(CreatePlanItemParamsV1Schema).optional(), + remove_items: z.array(PlanItemFilterSchema).optional(), + update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({ + description: + "Deprecated. Use remove_items and add_items to replace matched plan items.", + deprecated: true, + }), }) .refine( (data) => @@ -18,11 +22,11 @@ export const MigrationUpdatePlanCustomizeSchema = z data.add_items !== undefined || data.remove_items !== undefined || data.update_items !== undefined, - { - message: - "update_plan.customize requires at least one of price, add_items, remove_items, or update_items", - }, - ); + { + message: + "update_plan.customize requires at least one of price, add_items, remove_items, or deprecated update_items", + }, + ); /** * Ordered customer operation: update every customer product matched by diff --git a/shared/api/platform/platformModels.ts b/shared/api/platform/platformModels.ts index a5970c591..78ff7351e 100644 --- a/shared/api/platform/platformModels.ts +++ b/shared/api/platform/platformModels.ts @@ -88,3 +88,130 @@ export const ListPlatformOrgsResponseSchema = z.object({ export type ListPlatformOrgsResponse = z.infer< typeof ListPlatformOrgsResponseSchema >; + +/** + * Request body for POST /platform.link_revenuecat + */ +export const LinkRevenueCatSchema = z.object({ + organization_slug: z.string().min(1), + env: z.enum(["test", "live"]), + project_name: z.string().min(1).max(255), + redirect_url: z.string().url(), +}); + +export type LinkRevenueCat = z.infer; + +/** + * Response schema for POST /platform.link_revenuecat + */ +export const LinkRevenueCatResponseSchema = z.object({ + oauth_url: z.string(), +}); + +export type LinkRevenueCatResponse = z.infer< + typeof LinkRevenueCatResponseSchema +>; + +/** + * Request body for POST /platform.sync_revenuecat + */ +export const SyncRevenueCatSchema = z.object({ + organization_slug: z.string().min(1), + env: z + .enum(["test", "sandbox", "live"]) + .describe('"test" and "sandbox" both target the sandbox environment'), + product_ids: z + .array(z.string()) + .optional() + .describe("Plans to push. Omit to sync every plan in the org/env."), +}); + +export type SyncRevenueCat = z.infer; + +/** + * Per-app result of a single plan's sync. + */ +export const RevenueCatSyncAppResultSchema = z.object({ + app_id: z.string(), + app_type: z.string(), + product: z.enum(["created", "updated", "exists"]), + store_push: z.enum(["pushed", "failed", "skipped"]).optional(), + price: z.enum(["set", "skipped", "failed"]).optional(), + message: z.string().optional(), +}); + +/** + * Per-plan result of POST /platform.sync_revenuecat. + */ +export const RevenueCatSyncResultSchema = z.object({ + plan_id: z.string(), + status: z.enum(["synced", "skipped", "error"]), + store_identifier: z.string().optional(), + apps: z.array(RevenueCatSyncAppResultSchema).optional(), + message: z.string().optional(), +}); + +/** + * Response schema for POST /platform.sync_revenuecat + */ +export const SyncRevenueCatResponseSchema = z.object({ + results: z.array(RevenueCatSyncResultSchema), +}); + +export type SyncRevenueCatResponse = z.infer< + typeof SyncRevenueCatResponseSchema +>; + +/** + * Request body for POST /platform.get_revenuecat_keys + */ +export const GetRevenueCatKeysSchema = z.object({ + organization_slug: z.string().min(1), + env: z + .enum(["test", "sandbox", "live"]) + .describe('"test" and "sandbox" both target the sandbox environment'), +}); + +export type GetRevenueCatKeys = z.infer; + +/** + * A RevenueCat public (SDK) API key. + */ +export const RevenueCatPublicApiKeySchema = z + .object({ + id: z.string(), + key: z.string().describe("The public SDK API key value"), + environment: z.string().nullish().describe('e.g. "production" / "sandbox"'), + app_id: z.string().nullish(), + created_at: z.number().optional(), + }) + .loose(); + +/** + * Per-app public API keys for a managed org's RevenueCat project. + */ +export const RevenueCatAppKeysSchema = z.object({ + app_id: z.string(), + app_type: z + .string() + .describe("RevenueCat store type, e.g. test_store / app_store / play_store"), + name: z.string(), + api_keys: z.array(RevenueCatPublicApiKeySchema), +}); + +/** + * Response schema for POST /platform.get_revenuecat_keys + */ +export const GetRevenueCatKeysResponseSchema = z.object({ + apps: z.array(RevenueCatAppKeysSchema), + oauth_access_token: z + .string() + .nullable() + .describe( + "Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token.", + ), +}); + +export type GetRevenueCatKeysResponse = z.infer< + typeof GetRevenueCatKeysResponseSchema +>; diff --git a/shared/api/products/crud/updatePlanParamsV1.ts b/shared/api/products/crud/updatePlanParamsV1.ts index cee104e6a..aefadc53d 100644 --- a/shared/api/products/crud/updatePlanParamsV1.ts +++ b/shared/api/products/crud/updatePlanParamsV1.ts @@ -35,13 +35,9 @@ export const UpdatePlanParamsV1Schema = export const UpdatePlanParamsV2Schema = z .object({ - plan_id: z - .string() - .nonempty() - .regex(idRegex) - .meta({ - description: "The ID of the plan to update.", - }), + plan_id: z.string().nonempty().regex(idRegex).meta({ + description: "The ID of the plan to update.", + }), }) .extend(UpdatePlanParamsV1Schema.omit({ id: true }).shape) .extend({ @@ -52,19 +48,20 @@ export const UpdatePlanParamsV2Schema = z description: "Whether the plan is automatically enabled.", }), - new_plan_id: z - .string() - .nonempty() - .regex(idRegex) - .optional() - .meta({ - description: - "The new ID to use for the plan. Can only be updated if the plan has not been used by any customers.", - }), + new_plan_id: z.string().nonempty().regex(idRegex).optional().meta({ + description: + "The new ID to use for the plan. Can only be updated if the plan has not been used by any customers.", + }), description: z.string().optional().meta({ internal: true, }), + + // Edit the current version in place instead of creating a new one when + // customers exist. Existing customers keep their current rows. + disable_version: z.boolean().optional().meta({ + internal: true, + }), }); export const UpdatePlanQuerySchema = z.object({ diff --git a/shared/api/products/items/crud/createPlanItemParamsV1.ts b/shared/api/products/items/crud/createPlanItemParamsV1.ts index ac0be2752..e5a8872ae 100644 --- a/shared/api/products/items/crud/createPlanItemParamsV1.ts +++ b/shared/api/products/items/crud/createPlanItemParamsV1.ts @@ -70,9 +70,9 @@ export const CreatePlanItemParamsV1Schema = z description: "'prepaid' for upfront payment (seats), 'usage_based' for pay-as-you-go.", }), - max_purchase: z.number().optional().meta({ + max_purchase: z.number().nullish().meta({ description: - "Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total.", + "Max units purchasable beyond included. E.g. included=100, max_purchase=300 allows 400 total. Null for no limit.", }), }) .optional() diff --git a/shared/api/products/items/filter/planItemFilter.ts b/shared/api/products/items/filter/planItemFilter.ts index 0d0e8d1e2..755140ba9 100644 --- a/shared/api/products/items/filter/planItemFilter.ts +++ b/shared/api/products/items/filter/planItemFilter.ts @@ -1,5 +1,6 @@ import { BillingMethod } from "@api/products/components/billingMethod"; import { BillingInterval } from "@models/productModels/intervals/billingInterval"; +import { ResetInterval } from "@models/productModels/intervals/resetInterval"; import { z } from "zod/v4"; export const PlanItemFilterSchema = z @@ -11,15 +12,24 @@ export const PlanItemFilterSchema = z description: "Match items with this billing method (prepaid or usage_based).", }), - interval: z.enum(BillingInterval).optional().meta({ - description: "Match items with this interval.", + interval: z + .union([z.enum(BillingInterval), z.enum(ResetInterval)]) + .optional() + .meta({ + description: + "Match items with this interval. Accepts either a BillingInterval (price-side) or a ResetInterval (reset-side, includes day/hour/minute) so price-less items keyed by reset.interval can be disambiguated.", + }), + interval_count: z.number().int().positive().optional().meta({ + description: + "Match items with this interval_count. Disambiguates between items that share an interval but differ in count.", }), }) .refine( (filter) => filter.feature_id !== undefined || filter.billing_method !== undefined || - filter.interval !== undefined, + filter.interval !== undefined || + filter.interval_count !== undefined, { message: "PlanItemFilter must have at least one field set." }, ) .meta({ diff --git a/shared/api/publicApiSchemas.ts b/shared/api/publicApiSchemas.ts index ff281e85d..649ee373a 100644 --- a/shared/api/publicApiSchemas.ts +++ b/shared/api/publicApiSchemas.ts @@ -1,6 +1,14 @@ +export { CreateBalanceParamsV0Schema } from "./balances/create/createBalanceParams.js"; export { AttachParamsV1Schema } from "./billing/attachV2/attachParamsV1.js"; +export { + CreateScheduleParamsV0Schema, + CreateSchedulePhaseSchema, +} from "./billing/createSchedule/createScheduleParamsV0.js"; export { UpdateSubscriptionV1ParamsSchema } from "./billing/updateSubscription/updateSubscriptionV1Params.js"; +export { CreateCustomerParamsV1Schema } from "./customers/crud/createCustomerParams.js"; export { GetCustomerParamsV1Schema } from "./customers/crud/getCustomerParams.js"; export { ListCustomersV2_3ParamsSchema } from "./customers/crud/listCustomersParamsV2_3.js"; +export { UpdateCustomerParamsV1Schema } from "./customers/crud/updateCustomerParams.js"; +export { CreatePlanParamsV2Schema } from "./products/crud/createPlanParamsV1.js"; export { GetPlanParamsV0Schema } from "./products/crud/getPlanParamsV0.js"; export { ListPlanParamsSchema } from "./products/crud/listPlanParams.js"; diff --git a/shared/db/auth-schema.ts b/shared/db/auth-schema.ts index 3a1a3d902..908d66670 100644 --- a/shared/db/auth-schema.ts +++ b/shared/db/auth-schema.ts @@ -9,6 +9,7 @@ import { text, timestamp, } from "drizzle-orm/pg-core"; +import type { AppEnv } from "../models/genModels/genEnums.js"; import { type Organization, organizations, @@ -215,6 +216,7 @@ export const oauthRefreshToken = pgTable("oauth_refresh_token", { expiresAt: timestamp("expires_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }), revoked: timestamp("revoked", { withTimezone: true }), + authTime: timestamp("auth_time", { withTimezone: true }), scopes: text("scopes").array().notNull(), }).enableRLS(); @@ -245,6 +247,12 @@ export const oauthConsent = pgTable("oauth_consent", { userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), referenceId: text("reference_id"), scopes: text("scopes").array().notNull(), + env: text("env").$type(), + redirectUri: text("redirect_uri"), + oauthApiKeyId: text("oauth_api_key_id"), + metadata: jsonb("metadata") + .$type | null>() + .default({}), createdAt: timestamp("created_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }), }).enableRLS(); diff --git a/shared/db/schema.ts b/shared/db/schema.ts index fbc2d22a6..1a30ade3e 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -2,6 +2,11 @@ // Analytics Tables import { actions } from "../models/analyticsModels/actionTable.js"; +import { + chatApprovals, + chatInstallations, + chatOAuthCredentials, +} from "../models/chatModels/chatTable.js"; import { chatResults } from "../models/chatResultModels/chatResultTable.js"; import { checkoutsRelations } from "../models/checkouts/checkoutRelations.js"; import { checkouts } from "../models/checkouts/checkoutTable.js"; @@ -33,6 +38,7 @@ import { apiKeys } from "../models/devModels/apiKeyTable.js"; import { events } from "../models/eventModels/eventTable.js"; import { featureRelations } from "../models/featureModels/featureRelations.js"; import { features } from "../models/featureModels/featureTable.js"; +import { invoiceTemplates } from "../models/invoiceTemplateModels/invoiceTemplateTable.js"; // Migration Relations import { migrationErrorRelations } from "../models/migrationModels/migrationErrorRelations.js"; import { migrationErrors } from "../models/migrationModels/migrationErrorTable.js"; @@ -44,6 +50,7 @@ import { migrationRunsRelations } from "../models/migrationV2Models/migrationRun import { migrationRuns } from "../models/migrationV2Models/migrationRunTable.js"; import { migrations } from "../models/migrationV2Models/migrationTable.js"; /* RELATIONS */ +import { agentRules } from "../models/orgModels/agent/agentRulesTable.js"; import { organizationsRelations } from "../models/orgModels/orgRelations.js"; import { organizations } from "../models/orgModels/orgTable.js"; import { metadata } from "../models/otherModels/metadataTable.js"; @@ -63,10 +70,10 @@ import { referralCodeRelations } from "../models/rewardModels/referralModels/ref import { referralCodes } from "../models/rewardModels/referralModels/referralCodeTable.js"; import { rewardRedemptionRelations } from "../models/rewardModels/referralModels/rewardRedemptionRelations.js"; import { rewardRedemptions } from "../models/rewardModels/referralModels/rewardRedemptionTable.js"; -// Reward Tables -import { rewards } from "../models/rewardModels/rewardModels/rewardTable.js"; // Reward Relations import { rewardRelations } from "../models/rewardModels/rewardModels/rewardRelations.js"; +// Reward Tables +import { rewards } from "../models/rewardModels/rewardModels/rewardTable.js"; import { rewardProgramRelations } from "../models/rewardModels/rewardProgramModels/rewardProgramRelations.js"; import { rewardPrograms } from "../models/rewardModels/rewardProgramModels/rewardProgramTable.js"; import { @@ -100,6 +107,9 @@ export { apiKeyRelations, apiKeys, autoTopupLimitStates as autoTopupLimits, + chatApprovals, + chatInstallations, + chatOAuthCredentials, chatResults, checkouts, checkoutsRelations, @@ -124,6 +134,7 @@ export { inviteRelations, invoiceLineItems, invoiceRelations, + invoiceTemplates, invoices, // OAuth Provider jwks, @@ -141,6 +152,7 @@ export { oauthClient, oauthConsent, oauthRefreshToken, + agentRules, // Tables organizations, passkey, diff --git a/shared/drizzle/0001_concerned_ravenous.sql b/shared/drizzle/0001_concerned_ravenous.sql index ca5b510ce..e27559963 100644 --- a/shared/drizzle/0001_concerned_ravenous.sql +++ b/shared/drizzle/0001_concerned_ravenous.sql @@ -16,10 +16,10 @@ CREATE TABLE "passkey" ( ALTER TABLE "passkey" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint ALTER TABLE "migration_runs" ADD COLUMN "target_limit" numeric;--> statement-breakpoint ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint -CREATE INDEX "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint -CREATE INDEX "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint -CREATE INDEX "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint -CREATE INDEX "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL;--> statement-breakpoint -CREATE UNIQUE INDEX "vercel_resources_installation_name_unique_idx" ON "vercel_resources" USING btree ("org_id","env","installation_id","name") WHERE status <> 'uninstalled'; \ No newline at end of file +CREATE INDEX CONCURRENTLY "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX CONCURRENTLY "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY "vercel_resources_installation_name_unique_idx" ON "vercel_resources" USING btree ("org_id","env","installation_id","name") WHERE status <> 'uninstalled'; \ No newline at end of file diff --git a/shared/drizzle/0001_talented_thor.sql b/shared/drizzle/0001_talented_thor.sql new file mode 100644 index 000000000..0fe098099 --- /dev/null +++ b/shared/drizzle/0001_talented_thor.sql @@ -0,0 +1,25 @@ +CREATE TABLE "passkey" ( + "id" text PRIMARY KEY NOT NULL, + "name" text, + "public_key" text NOT NULL, + "user_id" text NOT NULL, + "credential_id" text NOT NULL, + "counter" integer NOT NULL, + "device_type" text NOT NULL, + "backed_up" boolean NOT NULL, + "transports" text, + "created_at" timestamp with time zone, + "aaguid" text, + CONSTRAINT "passkey_credential_id_unique" UNIQUE("credential_id") +); +--> statement-breakpoint +ALTER TABLE "passkey" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "migration_runs" ADD COLUMN "target_limit" numeric;--> statement-breakpoint +ALTER TABLE "migrations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint +CREATE INDEX "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint +CREATE INDEX "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint +CREATE INDEX "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint +CREATE INDEX "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL; \ No newline at end of file diff --git a/shared/drizzle/0002_aromatic_johnny_blaze.sql b/shared/drizzle/0002_aromatic_johnny_blaze.sql new file mode 100644 index 000000000..669a6313e --- /dev/null +++ b/shared/drizzle/0002_aromatic_johnny_blaze.sql @@ -0,0 +1,45 @@ +CREATE TABLE "chat_approvals" ( + "id" text PRIMARY KEY NOT NULL, + "org_id" text NOT NULL, + "provider" text NOT NULL, + "workspace_id" text NOT NULL, + "channel_id" text NOT NULL, + "message_ts" text, + "provider_user_id" text NOT NULL, + "env" text NOT NULL, + "run_id" text, + "tool_call_id" text, + "tool_name" text NOT NULL, + "tool_args" jsonb NOT NULL, + "preview" jsonb, + "status" text NOT NULL, + "created_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL, + "expires_at" numeric NOT NULL, + "decided_at" numeric, + "decided_by_provider_user_id" text +); +--> statement-breakpoint +CREATE TABLE "chat_installations" ( + "id" text PRIMARY KEY NOT NULL, + "org_id" text NOT NULL, + "provider" text NOT NULL, + "workspace_id" text NOT NULL, + "workspace_name" text NOT NULL, + "bot_user_id" text, + "bot_access_token" text NOT NULL, + "scopes" jsonb NOT NULL, + "default_env" text NOT NULL, + "sandbox_api_key_id" text, + "sandbox_api_key" text, + "live_api_key_id" text, + "live_api_key" text, + "installed_by_user_id" text, + "installed_by_provider_user_id" text, + "created_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL, + "updated_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL, + CONSTRAINT "chat_installations_org_provider_key" UNIQUE("org_id","provider"), + CONSTRAINT "chat_installations_provider_workspace_key" UNIQUE("provider","workspace_id") +); +--> statement-breakpoint +ALTER TABLE "chat_approvals" ADD CONSTRAINT "chat_approvals_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_installations" ADD CONSTRAINT "chat_installations_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; diff --git a/shared/drizzle/0003_short_gargoyle.sql b/shared/drizzle/0003_short_gargoyle.sql new file mode 100644 index 000000000..08847587d --- /dev/null +++ b/shared/drizzle/0003_short_gargoyle.sql @@ -0,0 +1,2 @@ +CREATE INDEX CONCURRENTLY "idx_customer_prices_internal_customer_id" ON "customer_prices" USING btree ("internal_customer_id" COLLATE "C") WHERE "customer_prices"."internal_customer_id" IS NOT NULL;--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_invoices_internal_entity_id" ON "invoices" USING btree ("internal_entity_id") WHERE "invoices"."internal_entity_id" IS NOT NULL; \ No newline at end of file diff --git a/shared/drizzle/0004_lucky_electro.sql b/shared/drizzle/0004_lucky_electro.sql new file mode 100644 index 000000000..9dbd9531d --- /dev/null +++ b/shared/drizzle/0004_lucky_electro.sql @@ -0,0 +1,14 @@ +CREATE TABLE "invoice_templates" ( + "internal_id" text PRIMARY KEY NOT NULL, + "id" text, + "org_id" text NOT NULL, + "created_at" numeric, + "name" text NOT NULL, + "footer" text, + "memo" text, + "net_terms_days" integer, + CONSTRAINT "invoice_templates_id_unique" UNIQUE("id") +); +--> statement-breakpoint +ALTER TABLE "invoice_templates" ADD CONSTRAINT "invoice_templates_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_invoice_templates_org_id" ON "invoice_templates" USING btree ("org_id"); \ No newline at end of file diff --git a/shared/drizzle/0005_fresh_runaways.sql b/shared/drizzle/0005_fresh_runaways.sql new file mode 100644 index 000000000..b9d89b2fb --- /dev/null +++ b/shared/drizzle/0005_fresh_runaways.sql @@ -0,0 +1 @@ +ALTER TABLE "oauth_refresh_token" ADD COLUMN "auth_time" timestamp with time zone; \ No newline at end of file diff --git a/shared/drizzle/0006_sad_madrox.sql b/shared/drizzle/0006_sad_madrox.sql new file mode 100644 index 000000000..895b1fe67 --- /dev/null +++ b/shared/drizzle/0006_sad_madrox.sql @@ -0,0 +1,3 @@ +ALTER TABLE "oauth_consent" ADD COLUMN "env" text;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD COLUMN "redirect_uri" text;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD COLUMN "oauth_api_key_id" text; diff --git a/shared/drizzle/0007_cute_ikaris.sql b/shared/drizzle/0007_cute_ikaris.sql new file mode 100644 index 000000000..043f3063a --- /dev/null +++ b/shared/drizzle/0007_cute_ikaris.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY "idx_invoice_line_items_customer_product_ids" ON "invoice_line_items" USING gin ("customer_product_ids"); \ No newline at end of file diff --git a/shared/drizzle/0008_premium_pet_avengers.sql b/shared/drizzle/0008_premium_pet_avengers.sql new file mode 100644 index 000000000..710fe4e91 --- /dev/null +++ b/shared/drizzle/0008_premium_pet_avengers.sql @@ -0,0 +1,18 @@ +CREATE TABLE "chat_oauth_credentials" ( + "id" text PRIMARY KEY NOT NULL, + "chat_installation_id" text NOT NULL, + "org_id" text NOT NULL, + "env" text NOT NULL, + "oauth_client_id" text NOT NULL, + "oauth_consent_id" text, + "access_token" text NOT NULL, + "refresh_token" text NOT NULL, + "access_token_expires_at" numeric NOT NULL, + "scopes" jsonb NOT NULL, + "created_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL, + "updated_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL, + CONSTRAINT "chat_oauth_credentials_installation_env_key" UNIQUE("chat_installation_id","env") +); +--> statement-breakpoint +ALTER TABLE "chat_oauth_credentials" ADD CONSTRAINT "chat_oauth_credentials_installation_id_fkey" FOREIGN KEY ("chat_installation_id") REFERENCES "public"."chat_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_oauth_credentials" ADD CONSTRAINT "chat_oauth_credentials_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/shared/drizzle/0008_slippery_william_stryker.sql b/shared/drizzle/0008_slippery_william_stryker.sql new file mode 100644 index 000000000..ecd4a4d1b --- /dev/null +++ b/shared/drizzle/0008_slippery_william_stryker.sql @@ -0,0 +1 @@ +ALTER TABLE "migrations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/shared/drizzle/0009_perpetual_wonder_man.sql b/shared/drizzle/0009_perpetual_wonder_man.sql new file mode 100644 index 000000000..b39544c7b --- /dev/null +++ b/shared/drizzle/0009_perpetual_wonder_man.sql @@ -0,0 +1 @@ +ALTER TABLE "oauth_consent" ADD COLUMN "metadata" jsonb DEFAULT '{}'::jsonb; diff --git a/shared/drizzle/0010_magenta_misty_knight.sql b/shared/drizzle/0010_magenta_misty_knight.sql new file mode 100644 index 000000000..0431421ea --- /dev/null +++ b/shared/drizzle/0010_magenta_misty_knight.sql @@ -0,0 +1,12 @@ +CREATE TABLE "agent_rules" ( + "org_id" text PRIMARY KEY NOT NULL, + "org_slug" text NOT NULL, + "entity_rules" jsonb NOT NULL, + "credit_rules" jsonb NOT NULL, + "notes" text DEFAULT '' NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL, + "updated_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL +); +--> statement-breakpoint +ALTER TABLE "agent_rules" ADD CONSTRAINT "agent_rules_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; diff --git a/shared/drizzle/meta/0002_snapshot.json b/shared/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..1466adb6b --- /dev/null +++ b/shared/drizzle/meta/0002_snapshot.json @@ -0,0 +1,7222 @@ +{ + "id": "b5a8ee16-450e-44e1-8494-a73b87ac3c90", + "prevId": "3d398bca-e922-45ac-bdc7-52f121289654", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/shared/drizzle/meta/0003_snapshot.json b/shared/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..af446d227 --- /dev/null +++ b/shared/drizzle/meta/0003_snapshot.json @@ -0,0 +1,7254 @@ +{ + "id": "525b9068-d426-4a6e-8457-56db4367d0d1", + "prevId": "b5a8ee16-450e-44e1-8494-a73b87ac3c90", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/drizzle/meta/0004_snapshot.json b/shared/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..cfde2bbe6 --- /dev/null +++ b/shared/drizzle/meta/0004_snapshot.json @@ -0,0 +1,7353 @@ +{ + "id": "20fbfba1-ef02-4637-b7f8-4ae1ee5983d7", + "prevId": "525b9068-d426-4a6e-8457-56db4367d0d1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/drizzle/meta/0005_snapshot.json b/shared/drizzle/meta/0005_snapshot.json new file mode 100644 index 000000000..b7b081947 --- /dev/null +++ b/shared/drizzle/meta/0005_snapshot.json @@ -0,0 +1,7359 @@ +{ + "id": "3ee43a45-bd02-43e2-a2d1-2080d51b5674", + "prevId": "20fbfba1-ef02-4637-b7f8-4ae1ee5983d7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/drizzle/meta/0006_snapshot.json b/shared/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..c4b1f542f --- /dev/null +++ b/shared/drizzle/meta/0006_snapshot.json @@ -0,0 +1,6987 @@ +{ + "id": "eadc8c95-3f6f-4643-abc3-e90cd56d5ed1", + "prevId": "3ee43a45-bd02-43e2-a2d1-2080d51b5674", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": ["hashed_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": ["org_id", "provider"] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": ["provider", "workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": ["internal_feature_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": ["customer_product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": ["entitlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": ["customer_product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": ["price_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": ["free_trial_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": ["org_id", "id", "env"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": ["internal_feature_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": ["org_id", "env", "internal_customer_id", "id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": ["internal_feature_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": ["internal_reward_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": ["org_id", "id", "env"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": ["invoice_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": ["stripe_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": ["stripe_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": ["migration_job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": ["internal_customer_id", "migration_job_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": ["from_internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": ["to_internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": ["migration_internal_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_api_key_id": { + "name": "oauth_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": ["test_pkey"] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": ["live_pkey"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": ["entitlement_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": ["org_id", "id", "env", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": ["internal_reward_program_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": ["code", "org_id", "env"] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": ["cus_ent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": ["org_id", "env", "autumn_product_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": ["internal_reward_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": ["internal_reward_program_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": ["referral_code_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": ["cus_ent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": ["schedule_id", "starts_at"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": ["stripe_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/shared/drizzle/meta/0007_snapshot.json b/shared/drizzle/meta/0007_snapshot.json new file mode 100644 index 000000000..bea55d4a2 --- /dev/null +++ b/shared/drizzle/meta/0007_snapshot.json @@ -0,0 +1,7393 @@ +{ + "id": "9e1bb4b2-1869-4ca9-ba67-8fbcea263c37", + "prevId": "eadc8c95-3f6f-4643-abc3-e90cd56d5ed1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoice_line_items_customer_product_ids": { + "name": "idx_invoice_line_items_customer_product_ids", + "columns": [ + { + "expression": "customer_product_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_api_key_id": { + "name": "oauth_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/shared/drizzle/meta/0008_snapshot.json b/shared/drizzle/meta/0008_snapshot.json new file mode 100644 index 000000000..b4d482f8a --- /dev/null +++ b/shared/drizzle/meta/0008_snapshot.json @@ -0,0 +1,7516 @@ +{ + "id": "40c5361a-8cff-473f-93c1-4dfbc06b00d7", + "prevId": "9e1bb4b2-1869-4ca9-ba67-8fbcea263c37", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_oauth_credentials": { + "name": "chat_oauth_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_installation_id": { + "name": "chat_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_consent_id": { + "name": "oauth_consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_oauth_credentials_installation_id_fkey": { + "name": "chat_oauth_credentials_installation_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "chat_installations", + "columnsFrom": [ + "chat_installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_oauth_credentials_org_id_fkey": { + "name": "chat_oauth_credentials_org_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_oauth_credentials_installation_env_key": { + "name": "chat_oauth_credentials_installation_env_key", + "nullsNotDistinct": false, + "columns": [ + "chat_installation_id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoice_line_items_customer_product_ids": { + "name": "idx_invoice_line_items_customer_product_ids", + "columns": [ + { + "expression": "customer_product_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_api_key_id": { + "name": "oauth_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/drizzle/meta/0009_snapshot.json b/shared/drizzle/meta/0009_snapshot.json new file mode 100644 index 000000000..7cd090acb --- /dev/null +++ b/shared/drizzle/meta/0009_snapshot.json @@ -0,0 +1,7523 @@ +{ + "id": "39539832-23e8-42d6-84f4-402b99f7ba86", + "prevId": "40c5361a-8cff-473f-93c1-4dfbc06b00d7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_oauth_credentials": { + "name": "chat_oauth_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_installation_id": { + "name": "chat_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_consent_id": { + "name": "oauth_consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_oauth_credentials_installation_id_fkey": { + "name": "chat_oauth_credentials_installation_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "chat_installations", + "columnsFrom": [ + "chat_installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_oauth_credentials_org_id_fkey": { + "name": "chat_oauth_credentials_org_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_oauth_credentials_installation_env_key": { + "name": "chat_oauth_credentials_installation_env_key", + "nullsNotDistinct": false, + "columns": [ + "chat_installation_id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoice_line_items_customer_product_ids": { + "name": "idx_invoice_line_items_customer_product_ids", + "columns": [ + { + "expression": "customer_product_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_api_key_id": { + "name": "oauth_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/shared/drizzle/meta/0010_snapshot.json b/shared/drizzle/meta/0010_snapshot.json new file mode 100644 index 000000000..b22072913 --- /dev/null +++ b/shared/drizzle/meta/0010_snapshot.json @@ -0,0 +1,7197 @@ +{ + "id": "c5275384-0822-47e9-b14d-d23cd14d42cc", + "prevId": "39539832-23e8-42d6-84f4-402b99f7ba86", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.agent_rules": { + "name": "agent_rules", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_rules": { + "name": "entity_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credit_rules": { + "name": "credit_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_rules_org_id_fkey": { + "name": "agent_rules_org_id_fkey", + "tableFrom": "agent_rules", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": ["hashed_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": ["org_id", "provider"] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": ["provider", "workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_oauth_credentials": { + "name": "chat_oauth_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_installation_id": { + "name": "chat_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_consent_id": { + "name": "oauth_consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_oauth_credentials_installation_id_fkey": { + "name": "chat_oauth_credentials_installation_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "chat_installations", + "columnsFrom": ["chat_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_oauth_credentials_org_id_fkey": { + "name": "chat_oauth_credentials_org_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_oauth_credentials_installation_env_key": { + "name": "chat_oauth_credentials_installation_env_key", + "nullsNotDistinct": false, + "columns": ["chat_installation_id", "env"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": ["internal_feature_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": ["customer_product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": ["entitlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": ["customer_product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": ["price_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": ["free_trial_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": ["org_id", "id", "env"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": ["internal_feature_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": ["org_id", "env", "internal_customer_id", "id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": ["internal_feature_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": ["internal_reward_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": ["org_id", "id", "env"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoice_line_items_customer_product_ids": { + "name": "idx_invoice_line_items_customer_product_ids", + "columns": [ + { + "expression": "customer_product_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": ["invoice_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": ["stripe_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": ["stripe_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": ["migration_job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": ["internal_customer_id", "migration_job_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": ["from_internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": ["to_internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": ["migration_internal_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_api_key_id": { + "name": "oauth_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": ["test_pkey"] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": ["live_pkey"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": ["entitlement_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": ["org_id", "id", "env", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": ["internal_reward_program_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": ["code", "org_id", "env"] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": ["cus_ent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": ["org_id", "env", "autumn_product_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": ["internal_reward_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": ["internal_reward_program_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": ["referral_code_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": ["cus_ent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": ["schedule_id", "starts_at"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": ["stripe_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/shared/drizzle/meta/_journal.json b/shared/drizzle/meta/_journal.json index 49d8d0828..08f5ab85f 100644 --- a/shared/drizzle/meta/_journal.json +++ b/shared/drizzle/meta/_journal.json @@ -15,6 +15,69 @@ "when": 1779971507895, "tag": "0001_concerned_ravenous", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1780329256103, + "tag": "0002_aromatic_johnny_blaze", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1780423680857, + "tag": "0003_short_gargoyle", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1780493543535, + "tag": "0004_lucky_electro", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1780582242747, + "tag": "0005_fresh_runaways", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1780584591419, + "tag": "0006_sad_madrox", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1780655063264, + "tag": "0007_cute_ikaris", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1780679328640, + "tag": "0008_premium_pet_avengers", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1780687569277, + "tag": "0009_perpetual_wonder_man", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1780990532501, + "tag": "0010_magenta_misty_knight", + "breakpoints": true } ] } \ No newline at end of file diff --git a/shared/index.ts b/shared/index.ts index 4ced77c7e..2b7b4ffa3 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -48,6 +48,7 @@ export * from "./models/attachModels/attachEnums/AttachConfig"; export * from "./models/attachModels/attachEnums/AttachFunction"; export * from "./models/attachModels/attachPreviewModels"; export * from "./models/authModels/membership"; +export * from "./models/chatModels/chatTable"; export * from "./models/chatResultModels/chatResultFeature"; export * from "./models/chatResultModels/chatResultFeature"; // 4. Chat Result Models @@ -60,6 +61,7 @@ export * from "./models/cusModels/billingControls/purchaseLimitInterval"; export * from "./models/cusModels/cusModels"; // Processor Models export * from "./models/processorModels/processorModels"; +export * from "./utils/chatState"; export { schemas }; // Cus response @@ -118,6 +120,8 @@ export * from "./models/featureModels/featureTable"; // Gen Models export * from "./models/genModels/genEnums"; export * from "./models/genModels/processorSchemas"; +export * from "./models/invoiceTemplateModels/invoiceTemplate"; +export * from "./models/invoiceTemplateModels/invoiceTemplateTable"; export * from "./models/migrationModels/migrationErrorTable"; export * from "./models/migrationModels/migrationJobTable"; export * from "./models/migrationModels/migrationModels"; @@ -130,7 +134,8 @@ export * from "./models/orgModels/frontendOrg"; // 1. Org Models export * from "./models/orgModels/frontendOrg"; export * from "./models/orgModels/fullOrgModel"; -export * from "./models/orgModels/orgConfig"; +export * from "./models/orgModels/agent/agentRules"; +export * from "./models/orgModels/agent/agentRulesTable"; export * from "./models/orgModels/orgConfig"; export * from "./models/orgModels/orgTable"; export * from "./models/otherModels/metadataTable"; @@ -218,6 +223,7 @@ export * from "./utils/fullSubjectUtils"; export * from "./utils/index"; export * from "./utils/intervalUtils"; export * from "./utils/invoices/index"; +export * from "./utils/leafOAuthScopes"; export * from "./utils/planFeatureUtils/planToDbFreeTrial"; export * from "./utils/productDisplayUtils"; export * from "./utils/productDisplayUtils/sortProductItems"; diff --git a/shared/models/billingModels/context/billingContext.ts b/shared/models/billingModels/context/billingContext.ts index 1c66c16f8..5cb392d24 100644 --- a/shared/models/billingModels/context/billingContext.ts +++ b/shared/models/billingModels/context/billingContext.ts @@ -6,10 +6,12 @@ import type { FeatureOptions, FreeTrial, Price, + ProcessorType, TrialOnEnd, } from "@autumn/shared"; import type { PaymentBehaviorIntent } from "@models/billingModels/context/paymentBehaviorIntent"; import type { TransitionConfig } from "@models/billingModels/context/transitionConfig"; +import type { DbInvoiceLineItem } from "@models/cusModels/invoiceModels/invoiceLineItemTable"; import type { EntInterval } from "@models/productModels/intervals/entitlementInterval"; import type Stripe from "stripe"; import { z } from "zod/v4"; @@ -20,6 +22,9 @@ import type { StripeDiscountWithCoupon } from "../stripe/stripeDiscountWithCoupo const InvoiceModeSchema = z.object({ finalizeInvoice: z.boolean().default(false), enableProductImmediately: z.boolean().default(true), + footer: z.string().optional(), + memo: z.string().optional(), + daysUntilDue: z.number().optional(), }); export type InvoiceMode = z.infer; @@ -60,6 +65,8 @@ export interface BillingContext { currentEpochMs: number; billingCycleAnchorMs: number | "now"; resetCycleAnchorMs: number | "now"; + billingStartsAt?: number; + subscriptionBackdateStartMs?: number; requestedBillingCycleAnchor?: number | "now"; requestedProrationBehavior?: BillingBehavior; @@ -107,9 +114,18 @@ export interface BillingContext { anchorResetRefund?: AnchorResetRefund; + storedChargeLineItems?: DbInvoiceLineItem[]; + storedRefundLineItems?: DbInvoiceLineItem[]; + refundLastPayment?: "prorated" | "full"; paymentBehaviorIntent?: PaymentBehaviorIntent; shouldFinalizeFirstInvoice?: boolean; skipCustomPaymentMethodGuard?: boolean; + + /** See `BillingContextOverride.skipExternalPSPGuard`. */ + skipExternalPSPGuard?: boolean; + + /** See `BillingContextOverride.processorTypeOverride`. */ + processorTypeOverride?: ProcessorType; } diff --git a/shared/models/billingModels/context/billingContextOverride.ts b/shared/models/billingModels/context/billingContextOverride.ts index a0d7e8914..2ab6bc3e6 100644 --- a/shared/models/billingModels/context/billingContextOverride.ts +++ b/shared/models/billingModels/context/billingContextOverride.ts @@ -7,6 +7,7 @@ import type { FeatureOptions, FullCusProduct, } from "@models/cusProductModels/cusProductModels"; +import type { ProcessorType } from "@models/genModels/genEnums"; import type { Entitlement } from "@models/productModels/entModels/entModels"; import type { Price } from "@models/productModels/priceModels/priceModels"; import type { FullProduct } from "@models/productModels/productModels"; @@ -50,6 +51,31 @@ export interface BillingContextOverride { * public API schema. */ skipCustomPaymentMethodGuard?: boolean; + + /** + * Skips fetching Stripe state (customer/subscription/schedule/discounts/PM) + * during attach setup. Used by external-PSP origin callers (e.g. RevenueCat + * webhook handlers) whose customers don't have a meaningful Stripe presence. + * Independent from `params.no_billing_changes`, which only blocks writes. + */ + skipBillingFetching?: boolean; + + /** + * Skips the external-PSP guard (`handleExternalPSPErrors`) and the + * "paid current product but no Stripe sub linked" guard. Used by callers + * that ARE the external origin platform (e.g. RevenueCat webhook handlers) + * and so must be allowed to attach onto their own existing non-Stripe + * cus_products. Not exposed via any public API schema. + */ + skipExternalPSPGuard?: boolean; + + /** + * Tags the newly-inserted customer_product's `processor.type` field. Used + * by external-PSP origin callers to mark the cus_product as managed by a + * non-Stripe processor (e.g. RevenueCat). Defaults to leaving `processor` + * unset, which `cusProductToProcessorType` resolves to Stripe. + */ + processorTypeOverride?: ProcessorType; } export interface UpdateSubscriptionBillingContextOverride diff --git a/shared/models/billingModels/context/updateSubscriptionBillingContext.ts b/shared/models/billingModels/context/updateSubscriptionBillingContext.ts index a6c7fa2b7..24ba9dcca 100644 --- a/shared/models/billingModels/context/updateSubscriptionBillingContext.ts +++ b/shared/models/billingModels/context/updateSubscriptionBillingContext.ts @@ -30,6 +30,11 @@ export type PatchContext = { deleteCustomerEntitlements: FullCustomerEntitlement[]; customPrices: Price[]; customEntitlements: Entitlement[]; + /** Explicit source-to-replacement entitlement carries for items whose identity changes. */ + updateItemCarryLinks: { + fromCustomerEntitlementId: string; + toEntitlementId: string; + }[]; }; export interface UpdateSubscriptionBillingContext extends BillingContext { diff --git a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts index fb8c6cd36..52b559186 100644 --- a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts +++ b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts @@ -15,6 +15,7 @@ import type { FeatureOptions, FullCusProduct, } from "../../cusProductModels/cusProductModels"; +import type { ProcessorType } from "../../genModels/genEnums"; import type { FullProduct } from "../../productModels/productModels"; export interface ExistingUsagesConfig { @@ -93,4 +94,12 @@ export interface InitFullCustomerProductOptions { previousCustomerProductId?: string; onTrialEnd?: TrialOnEnd; + + /** + * Tags the customer_product's `processor.type` field. When omitted, the + * processor column is left unwritten (defaults to null in the DB, which + * `cusProductToProcessorType` resolves to Stripe). Used by non-Stripe + * origin flows (e.g. RevenueCat) to mark cus_products explicitly. + */ + processorType?: ProcessorType; } diff --git a/shared/models/billingModels/lineItem/lineItemContext.ts b/shared/models/billingModels/lineItem/lineItemContext.ts index 6457515f3..4cca8e981 100644 --- a/shared/models/billingModels/lineItem/lineItemContext.ts +++ b/shared/models/billingModels/lineItem/lineItemContext.ts @@ -12,6 +12,11 @@ export const BillingPeriodSchema = z.object({ end: z.number(), }); +export const LineItemBackdateSchema = z.object({ + startsAt: z.number(), + cycleCount: z.number(), +}); + export const LineItemContextSchema = z.object({ price: PriceSchema, product: ProductSchema, @@ -24,6 +29,7 @@ export const LineItemContextSchema = z.object({ now: z.number(), billingTiming: z.enum(["in_arrear", "in_advance"]), discountable: z.boolean().optional(), // If true, let Stripe auto-apply discounts to this line item + backdate: LineItemBackdateSchema.optional(), // Entity references (optional - not all line items have these) entity: EntitySchema.optional(), diff --git a/shared/models/billingModels/plan/autumnBillingPlan.ts b/shared/models/billingModels/plan/autumnBillingPlan.ts index 349d4230b..9e8415523 100644 --- a/shared/models/billingModels/plan/autumnBillingPlan.ts +++ b/shared/models/billingModels/plan/autumnBillingPlan.ts @@ -83,6 +83,17 @@ export const AutumnBillingPlanSchema = z.object({ }) .optional(), + schedulePhaseCustomerProductReplacements: z + .array( + z.object({ + oldCustomerProductId: z.string(), + newCustomerProductId: z.string(), + internalCustomerId: z.string(), + internalEntityId: z.string().nullish(), + }), + ) + .optional(), + deleteCustomerProduct: FullCusProductSchema.optional(), // Scheduled product to delete (e.g., when updating while canceling) deleteCustomerProducts: z.array(FullCusProductSchema).optional(), diff --git a/shared/models/chatModels/chatTable.ts b/shared/models/chatModels/chatTable.ts new file mode 100644 index 000000000..170eb8643 --- /dev/null +++ b/shared/models/chatModels/chatTable.ts @@ -0,0 +1,126 @@ +import { + foreignKey, + jsonb, + numeric, + pgTable, + text, + unique, +} from "drizzle-orm/pg-core"; +import { sqlNow } from "../../db/utils.js"; +import type { AppEnv } from "../genModels/genEnums.js"; +import { organizations } from "../orgModels/orgTable.js"; + +export type ChatProvider = + | "slack" + | "slack_admin" + | `slack_admin:${string}` + | "discord"; + +export const chatInstallations = pgTable( + "chat_installations", + { + id: text().primaryKey().notNull(), + org_id: text("org_id").notNull(), + provider: text("provider").$type().notNull(), + workspace_id: text("workspace_id").notNull(), + workspace_name: text("workspace_name").notNull(), + bot_user_id: text("bot_user_id"), + bot_access_token: text("bot_access_token").notNull(), + scopes: jsonb().$type().notNull(), + default_env: text("default_env").$type().notNull(), + sandbox_api_key_id: text("sandbox_api_key_id"), + sandbox_api_key: text("sandbox_api_key"), + live_api_key_id: text("live_api_key_id"), + live_api_key: text("live_api_key"), + installed_by_user_id: text("installed_by_user_id"), + installed_by_provider_user_id: text("installed_by_provider_user_id"), + created_at: numeric({ mode: "number" }).notNull().default(sqlNow), + updated_at: numeric({ mode: "number" }).notNull().default(sqlNow), + }, + (table) => [ + foreignKey({ + columns: [table.org_id], + foreignColumns: [organizations.id], + name: "chat_installations_org_id_fkey", + }).onDelete("cascade"), + unique("chat_installations_org_provider_key").on( + table.org_id, + table.provider, + ), + unique("chat_installations_provider_workspace_key").on( + table.provider, + table.workspace_id, + ), + ], +); + +export const chatApprovals = pgTable( + "chat_approvals", + { + id: text().primaryKey().notNull(), + org_id: text("org_id").notNull(), + provider: text("provider").$type().notNull(), + workspace_id: text("workspace_id").notNull(), + channel_id: text("channel_id").notNull(), + message_ts: text("message_ts"), + provider_user_id: text("provider_user_id").notNull(), + env: text("env").$type().notNull(), + run_id: text("run_id"), + tool_call_id: text("tool_call_id"), + tool_name: text("tool_name").notNull(), + tool_args: jsonb("tool_args").$type>().notNull(), + preview: jsonb().$type(), + status: text("status").notNull(), + created_at: numeric({ mode: "number" }).notNull().default(sqlNow), + expires_at: numeric("expires_at", { mode: "number" }).notNull(), + decided_at: numeric("decided_at", { mode: "number" }), + decided_by_provider_user_id: text("decided_by_provider_user_id"), + }, + (table) => [ + foreignKey({ + columns: [table.org_id], + foreignColumns: [organizations.id], + name: "chat_approvals_org_id_fkey", + }).onDelete("cascade"), + ], +); + +export const chatOAuthCredentials = pgTable( + "chat_oauth_credentials", + { + id: text().primaryKey().notNull(), + chat_installation_id: text("chat_installation_id").notNull(), + org_id: text("org_id").notNull(), + env: text("env").$type().notNull(), + oauth_client_id: text("oauth_client_id").notNull(), + oauth_consent_id: text("oauth_consent_id"), + access_token: text("access_token").notNull(), + refresh_token: text("refresh_token").notNull(), + access_token_expires_at: numeric("access_token_expires_at", { + mode: "number", + }).notNull(), + scopes: jsonb().$type().notNull(), + created_at: numeric({ mode: "number" }).notNull().default(sqlNow), + updated_at: numeric({ mode: "number" }).notNull().default(sqlNow), + }, + (table) => [ + foreignKey({ + columns: [table.chat_installation_id], + foreignColumns: [chatInstallations.id], + name: "chat_oauth_credentials_installation_id_fkey", + }).onDelete("cascade"), + foreignKey({ + columns: [table.org_id], + foreignColumns: [organizations.id], + name: "chat_oauth_credentials_org_id_fkey", + }).onDelete("cascade"), + unique("chat_oauth_credentials_installation_env_key").on( + table.chat_installation_id, + table.env, + ), + ], +); + +export type ChatInstallation = typeof chatInstallations.$inferSelect; +export type ChatApproval = typeof chatApprovals.$inferSelect; +export type ChatOAuthCredential = typeof chatOAuthCredentials.$inferSelect; diff --git a/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts index 929aa1f14..80647fbb6 100644 --- a/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts +++ b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts @@ -2,6 +2,7 @@ import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; import { boolean, foreignKey, + index, jsonb, numeric, pgTable, @@ -82,6 +83,11 @@ export const invoiceLineItems = pgTable( }).onDelete("cascade"), // Unique partial index on stripe_id for upsert support unique("invoice_line_items_stripe_id_unique").on(table.stripe_id), + // GIN index for jsonb containment / array-overlap lookups by customer product + index("idx_invoice_line_items_customer_product_ids").using( + "gin", + table.customer_product_ids, + ), ], ); diff --git a/shared/models/cusModels/invoiceModels/invoiceTable.ts b/shared/models/cusModels/invoiceModels/invoiceTable.ts index f719fdbcd..6dbf89920 100644 --- a/shared/models/cusModels/invoiceModels/invoiceTable.ts +++ b/shared/models/cusModels/invoiceModels/invoiceTable.ts @@ -52,6 +52,11 @@ export const invoices = pgTable( sql`${table.created_at} DESC`, sql`${table.id} DESC`, ), + // Serves the entities.internal_id delete cascade (both default collation). + index("idx_invoices_internal_entity_id") + .on(table.internal_entity_id) + .where(sql`${table.internal_entity_id} IS NOT NULL`) + .concurrently(), ], ); diff --git a/shared/models/cusProductModels/cusPriceModels/cusPriceTable.ts b/shared/models/cusProductModels/cusPriceModels/cusPriceTable.ts index 2e1832310..2f46f6d71 100644 --- a/shared/models/cusProductModels/cusPriceModels/cusPriceTable.ts +++ b/shared/models/cusProductModels/cusPriceModels/cusPriceTable.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { foreignKey, index, @@ -39,6 +40,12 @@ export const customerPrices = pgTable( }), index("idx_customer_prices_product_id").on(table.customer_product_id), index("idx_customer_prices_price_id").on(table.price_id), + // Serves the customers.internal_id (collation C) delete cascade. A plain + // index can't be used when the comparison collation is C. + index("idx_customer_prices_internal_customer_id") + .on(sql`${table.internal_customer_id} COLLATE "C"`) + .where(sql`${table.internal_customer_id} IS NOT NULL`) + .concurrently(), ], ); diff --git a/shared/models/genModels/processorSchemas.ts b/shared/models/genModels/processorSchemas.ts index 47ca55539..b7a6dc72f 100644 --- a/shared/models/genModels/processorSchemas.ts +++ b/shared/models/genModels/processorSchemas.ts @@ -91,17 +91,28 @@ export const UpsertVercelProcessorConfigSchema = z.object({ marketplace_mode: z.enum(VercelMarketplaceMode).optional(), }); +export const RevenueCatOAuthConfigSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + expires_at: z.number(), + scope: z.string().optional(), + project_id: z.string().optional(), + connected_at: z.number().optional(), +}); + /** * Organization-level RevenueCat processor configuration * Stores API key, project ID, and webhook secret */ export const RevenueCatProcessorConfigSchema = z.object({ - api_key: z.string(), + api_key: z.string().optional(), sandbox_api_key: z.string().optional(), project_id: z.string().optional(), sandbox_project_id: z.string().optional(), - webhook_secret: z.string(), + webhook_secret: z.string().optional(), sandbox_webhook_secret: z.string().optional(), + oauth: RevenueCatOAuthConfigSchema.optional(), + sandbox_oauth: RevenueCatOAuthConfigSchema.optional(), }); export const UpsertRevenueCatProcessorConfigSchema = z.object({ @@ -132,6 +143,7 @@ export type VercelProcessorConfig = z.infer; export type UpsertVercelProcessorConfig = z.infer< typeof UpsertVercelProcessorConfigSchema >; +export type RevenueCatOAuthConfig = z.infer; export type RevenueCatProcessorConfig = z.infer< typeof RevenueCatProcessorConfigSchema >; diff --git a/shared/models/invoiceTemplateModels/invoiceTemplate.ts b/shared/models/invoiceTemplateModels/invoiceTemplate.ts new file mode 100644 index 000000000..9191f0525 --- /dev/null +++ b/shared/models/invoiceTemplateModels/invoiceTemplate.ts @@ -0,0 +1,28 @@ +import { z } from "zod/v4"; + +export const InvoiceTemplateSchema = z.object({ + id: z.string().meta({ + description: "Unique identifier for the invoice template.", + }), + name: z.string().meta({ + description: + "User-defined name to distinguish this template when sending an invoice.", + }), + footer: z.string().optional().meta({ + description: + "Footer text rendered on the invoice, typically bank details so customers can pay directly.", + }), + memo: z.string().optional().meta({ + description: + "Memo shown near the top of the invoice (Stripe invoice description), e.g. a contact line for payment questions.", + }), + net_terms_days: z.number().int().positive().optional().meta({ + description: + "Default number of days the customer has to pay before the invoice is due. Overridable when sending.", + }), + created_at: z.number().optional().meta({ + description: "Timestamp (ms) when the template was created.", + }), +}); + +export type InvoiceTemplate = z.infer; diff --git a/shared/models/invoiceTemplateModels/invoiceTemplateTable.ts b/shared/models/invoiceTemplateModels/invoiceTemplateTable.ts new file mode 100644 index 000000000..f7dae24e9 --- /dev/null +++ b/shared/models/invoiceTemplateModels/invoiceTemplateTable.ts @@ -0,0 +1,37 @@ +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { + foreignKey, + index, + integer, + numeric, + pgTable, + text, +} from "drizzle-orm/pg-core"; +import { organizations } from "../orgModels/orgTable.js"; + +export const invoiceTemplates = pgTable( + "invoice_templates", + { + internal_id: text("internal_id").primaryKey().notNull(), + id: text().unique(), + org_id: text("org_id").notNull(), + created_at: numeric({ mode: "number" }), + name: text().notNull(), + footer: text(), + memo: text(), + net_terms_days: integer("net_terms_days"), + }, + (table) => [ + foreignKey({ + columns: [table.org_id], + foreignColumns: [organizations.id], + name: "invoice_templates_org_id_fkey", + }).onDelete("cascade"), + index("idx_invoice_templates_org_id").on(table.org_id), + ], +); + +export type InvoiceTemplateRow = InferSelectModel; +export type InsertInvoiceTemplateRow = InferInsertModel< + typeof invoiceTemplates +>; diff --git a/shared/models/migrationV2Models/migrationTable.ts b/shared/models/migrationV2Models/migrationTable.ts index 0124dd10c..ed7d40150 100644 --- a/shared/models/migrationV2Models/migrationTable.ts +++ b/shared/models/migrationV2Models/migrationTable.ts @@ -47,6 +47,7 @@ export const migrations = pgTable( // `false` → force Stripe path even when inference would say DB-only. no_billing_changes: boolean(), retry_failed: boolean().notNull().default(false), + archived: boolean().notNull().default(false), created_at: numeric({ mode: "number" }).notNull(), updated_at: numeric({ mode: "number" }), diff --git a/shared/models/orgModels/agent/agentRules.ts b/shared/models/orgModels/agent/agentRules.ts new file mode 100644 index 000000000..e78fede73 --- /dev/null +++ b/shared/models/orgModels/agent/agentRules.ts @@ -0,0 +1,84 @@ +import { z } from "zod/v4"; + +export const DEFAULT_ENTITY_RULES = { + attach_to_entities: false, + entity_feature_id: "", +} satisfies { + attach_to_entities: boolean; + entity_feature_id: string; +}; + +export const DEFAULT_CREDIT_RULES = { + credit_feature_id: "", +} satisfies { + credit_feature_id: string; +}; + +export const EntityRulesSchema = z + .object({ + attach_to_entities: z.boolean().default(false), + entity_feature_id: z.string().default(""), + }) + .default(DEFAULT_ENTITY_RULES); + +export const CreditRulesSchema = z + .object({ + credit_feature_id: z.string().default(""), + }) + .default(DEFAULT_CREDIT_RULES); + +export const AgentRulesSchema = z.object({ + entity_rules: EntityRulesSchema, + credit_rules: CreditRulesSchema, + notes: z.string().default(""), +}); + +export const PartialAgentRulesSchema = z.object({ + entity_rules: z + .object({ + attach_to_entities: z.boolean().optional(), + entity_feature_id: z.string().optional(), + }) + .optional(), + credit_rules: z + .object({ + credit_feature_id: z.string().optional(), + }) + .optional(), + notes: z.string().optional(), +}); + +export type EntityRules = z.infer; +export type CreditRules = z.infer; +export type AgentRules = z.infer; +export type PartialAgentRules = z.infer; + +export const defaultAgentRules = (): AgentRules => + AgentRulesSchema.parse({ + credit_rules: DEFAULT_CREDIT_RULES, + entity_rules: DEFAULT_ENTITY_RULES, + notes: "", + }); + +export const mergeAgentRules = ({ + base, + updates, +}: { + base?: AgentRules | null; + updates: PartialAgentRules; +}) => + AgentRulesSchema.parse({ + ...defaultAgentRules(), + ...(base ?? {}), + ...updates, + credit_rules: { + ...DEFAULT_CREDIT_RULES, + ...(base?.credit_rules ?? {}), + ...(updates.credit_rules ?? {}), + }, + entity_rules: { + ...DEFAULT_ENTITY_RULES, + ...(base?.entity_rules ?? {}), + ...(updates.entity_rules ?? {}), + }, + }); diff --git a/shared/models/orgModels/agent/agentRulesTable.ts b/shared/models/orgModels/agent/agentRulesTable.ts new file mode 100644 index 000000000..a6e3a644d --- /dev/null +++ b/shared/models/orgModels/agent/agentRulesTable.ts @@ -0,0 +1,31 @@ +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { foreignKey, jsonb, numeric, pgTable, text } from "drizzle-orm/pg-core"; +import { sqlNow } from "../../../db/utils.js"; +import { organizations } from "../orgTable.js"; +import type { CreditRules, EntityRules } from "./agentRules.js"; + +export type AgentRulesMetadata = Record; + +export const agentRules = pgTable( + "agent_rules", + { + org_id: text("org_id").primaryKey().notNull(), + org_slug: text("org_slug").notNull(), + entity_rules: jsonb().$type().notNull(), + credit_rules: jsonb().$type().notNull(), + notes: text().notNull().default(""), + metadata: jsonb().$type().notNull().default({}), + created_at: numeric({ mode: "number" }).notNull().default(sqlNow), + updated_at: numeric({ mode: "number" }).notNull().default(sqlNow), + }, + (table) => [ + foreignKey({ + columns: [table.org_id], + foreignColumns: [organizations.id], + name: "agent_rules_org_id_fkey", + }).onDelete("cascade"), + ], +); + +export type AgentRulesRow = InferSelectModel; +export type InsertAgentRulesRow = InferInsertModel; diff --git a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts index 8a8e8236e..1c72fc7c7 100644 --- a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts @@ -2,6 +2,12 @@ import { z } from "zod/v4"; import { BillingInterval } from "../../intervals/billingInterval"; import { UsageTierSchema } from "./usagePriceConfig"; +/** Imported fixed prices may carry usage metadata; fixed configs ignore it. */ +const IgnoredFixedPriceMetadataSchema = z.preprocess( + (value) => (typeof value === "string" ? null : value), + z.null().or(z.undefined()), +); + export const FixedPriceConfigSchema = z.object({ type: z.string(), amount: z.number().min(0), @@ -13,9 +19,9 @@ export const FixedPriceConfigSchema = z.object({ usage_tiers: z.array(UsageTierSchema).nullish(), stripe_price_id: z.string().nullish(), stripe_empty_price_id: z.string().nullish(), - stripe_product_id: z.null().or(z.undefined()), - feature_id: z.null().or(z.undefined()), - internal_feature_id: z.null().or(z.undefined()), + stripe_product_id: IgnoredFixedPriceMetadataSchema, + feature_id: IgnoredFixedPriceMetadataSchema, + internal_feature_id: IgnoredFixedPriceMetadataSchema, }); export type FixedPriceConfig = z.infer; diff --git a/shared/package.json b/shared/package.json index 925010bd7..71d1a78eb 100644 --- a/shared/package.json +++ b/shared/package.json @@ -15,6 +15,11 @@ "import": "./utils/scopeDefinitions.ts", "default": "./utils/scopeDefinitions.ts" }, + "./leafOAuthScopes": { + "types": "./utils/leafOAuthScopes.ts", + "import": "./utils/leafOAuthScopes.ts", + "default": "./utils/leafOAuthScopes.ts" + }, "./unixUtils": { "types": "./utils/common/unixUtils.ts", "import": "./utils/common/unixUtils.ts", @@ -24,6 +29,16 @@ "types": "./api/publicApiSchemas.ts", "import": "./api/publicApiSchemas.ts", "default": "./api/publicApiSchemas.ts" + }, + "./utils/chatState": { + "types": "./utils/chatState.ts", + "import": "./utils/chatState.ts", + "default": "./utils/chatState.ts" + }, + "./utils/infisical": { + "types": "./utils/infisical.ts", + "import": "./utils/infisical.ts", + "default": "./utils/infisical.ts" } }, "author": "Recase Inc.", @@ -41,6 +56,7 @@ "@orpc/contract": "catalog:", "@orpc/openapi": "^1.13.4", "@orpc/zod": "^1.13.4", + "better-auth": "catalog:", "date-fns": "^4.1.0", "decimal.js": "^10.5.0", "dotenv": "^16.5.0", diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 8f60998c9..c9e856d0e 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -6,6 +6,8 @@ export * from "./intervalUtils/intervalArithmetic"; // Invoicing utils +export * from "./invoicingUtils/backdateUtils/applyBackdatedLineItemAmount.js"; +export * from "./invoicingUtils/billingConstants.js"; export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js"; export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js"; export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem.js"; diff --git a/shared/utils/billingUtils/invoicingUtils/backdateUtils/applyBackdatedLineItemAmount.ts b/shared/utils/billingUtils/invoicingUtils/backdateUtils/applyBackdatedLineItemAmount.ts new file mode 100644 index 000000000..7d1dd7db5 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/backdateUtils/applyBackdatedLineItemAmount.ts @@ -0,0 +1,17 @@ +import { Decimal } from "decimal.js"; +import type { LineItemContext } from "../../../../models/billingModels/lineItem/lineItemContext"; + +export const applyBackdatedLineItemAmount = ({ + amount, + context, +}: { + amount: number; + context: LineItemContext; +}) => { + const cycleCount = context.backdate?.cycleCount; + if (!cycleCount) return amount; + if (context.direction !== "charge") return amount; + if (context.billingTiming !== "in_advance") return amount; + + return new Decimal(amount).mul(cycleCount).toDP(2).toNumber(); +}; diff --git a/shared/utils/billingUtils/invoicingUtils/billingConstants.ts b/shared/utils/billingUtils/invoicingUtils/billingConstants.ts new file mode 100644 index 000000000..03a33a51c --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/billingConstants.ts @@ -0,0 +1 @@ +export const BILLING_AMOUNT_EPSILON = 0.01; diff --git a/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts b/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts index 6ee8317b1..cb63d2bed 100644 --- a/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts +++ b/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts @@ -1,4 +1,5 @@ import type { LineItem } from "@models/billingModels/lineItem/lineItem"; +import { BILLING_AMOUNT_EPSILON } from "./billingConstants"; /** * Filters out line item pairs where a refund and charge item have the same price ID @@ -32,10 +33,9 @@ export const filterUnchangedPricesFromLineItems = ({ if (matchingChargeIndex !== -1) { const matchingChargeItem = chargeItems[matchingChargeIndex]; - const total = refundItem.amount + matchingChargeItem.amount; + const netAmount = Math.abs(refundItem.amount + matchingChargeItem.amount); - if (total === 0) { - // Amounts cancel out - mark charge item as matched (both will be removed) + if (netAmount < BILLING_AMOUNT_EPSILON) { matchedChargeIndices.add(matchingChargeIndex); continue; } diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts index 8b98907da..aa5ca47a0 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts @@ -7,6 +7,7 @@ import { LineItemSchema, } from "../../../../models/billingModels/lineItem/lineItem"; import type { LineItemContext } from "../../../../models/billingModels/lineItem/lineItemContext"; +import { applyBackdatedLineItemAmount } from "../backdateUtils/applyBackdatedLineItemAmount"; import { applyProration } from "../prorationUtils/applyProration"; import { getEffectivePeriod } from "../prorationUtils/getEffectivePeriod"; @@ -66,6 +67,13 @@ export const buildLineItem = ({ amount = -amount; } + if (chargeImmediately) { + amount = applyBackdatedLineItemAmount({ + amount, + context: updatedContext, + }); + } + const entityLabel = context.entity?.name || context.entity?.id; const finalDescription = entityLabel ? `${description} (${entityLabel})` diff --git a/shared/utils/chatState.test.ts b/shared/utils/chatState.test.ts new file mode 100644 index 000000000..f2612c722 --- /dev/null +++ b/shared/utils/chatState.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { addSeconds, subMilliseconds } from "date-fns"; +import { AppEnv } from "../models/genModels/genEnums"; +import { createChatInstallState, verifyChatInstallState } from "./chatState"; + +describe("chat OAuth state", () => { + test("round-trips a signed install state", () => { + const state = createChatInstallState({ + secret: "secret", + provider: "slack", + orgId: "org_123", + userId: "user_123", + env: AppEnv.Live, + expiresAt: addSeconds(Date.now(), 1).getTime(), + nonce: "nonce", + }); + + expect(verifyChatInstallState(state, "secret")).toMatchObject({ + provider: "slack", + orgId: "org_123", + userId: "user_123", + env: AppEnv.Live, + }); + }); + + test("rejects invalid or expired state", () => { + const expired = createChatInstallState({ + secret: "secret", + provider: "discord", + orgId: "org_123", + userId: "user_123", + env: AppEnv.Sandbox, + expiresAt: subMilliseconds(Date.now(), 1).getTime(), + nonce: "nonce", + }); + + expect(verifyChatInstallState(expired, "secret")).toBeNull(); + expect(verifyChatInstallState("not-valid", "secret")).toBeNull(); + expect(verifyChatInstallState(expired, "wrong-secret")).toBeNull(); + }); +}); diff --git a/shared/utils/chatState.ts b/shared/utils/chatState.ts new file mode 100644 index 000000000..350102130 --- /dev/null +++ b/shared/utils/chatState.ts @@ -0,0 +1,54 @@ +import crypto from "node:crypto"; +import { isFuture } from "date-fns"; +import { z } from "zod"; +import { AppEnv } from "../models/genModels/genEnums.js"; + +const chatInstallStateSchema = z.strictObject({ + provider: z.union([ + z.enum(["slack", "slack_admin", "discord"]), + z.string().regex(/^slack_admin:.+$/), + ]), + orgId: z.string(), + userId: z.string(), + env: z.nativeEnum(AppEnv), + expiresAt: z.number(), + nonce: z.string(), +}); + +export type ChatInstallState = z.infer; + +const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + +const sign = (payload: string, secret: string) => + crypto.createHmac("sha256", secret).update(payload).digest("base64url"); + +export const createChatInstallState = ({ + secret, + ...state +}: ChatInstallState & { secret: string }) => { + const payload = encode(state); + return `${payload}.${sign(payload, secret)}`; +}; + +export const verifyChatInstallState = (state: string, secret: string) => { + const [payload, signature] = state.split("."); + if (!payload || !signature) return null; + + const expected = Buffer.from(sign(payload, secret)); + const actual = Buffer.from(signature); + if ( + expected.length !== actual.length || + !crypto.timingSafeEqual(expected, actual) + ) + return null; + + try { + const parsed = chatInstallStateSchema.parse( + JSON.parse(Buffer.from(payload, "base64url").toString()), + ); + return isFuture(parsed.expiresAt) ? parsed : null; + } catch { + return null; + } +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/recalculateScopeUtils.ts b/shared/utils/cusEntUtils/balanceUtils/recalculateScopeUtils.ts new file mode 100644 index 000000000..b1203fe84 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/recalculateScopeUtils.ts @@ -0,0 +1,65 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { cusEntsToBalance } from "./cusEntsToBalance"; + +export const RECALCULATE_CUSTOMER_SCOPE = "__customer__"; + +/** + * Balances are only ever recalculated against siblings with the same scope: a + * balance owned by an entity stays within that entity, and customer-level + * balances stay at the customer level. + */ +export const cusEntToRecalculateScopeKey = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}): string => + cusEnt.internal_entity_id ?? + cusEnt.customer_product?.internal_entity_id ?? + RECALCULATE_CUSTOMER_SCOPE; + +/** + * Returns the scope keys that can be recalculated - i.e. scopes that contain + * both an overdrawn balance and a balance with remaining to absorb it. Uses the + * main balance (not rollovers) because recalculation redistributes the main + * balance, so this matches exactly what a recalculation would change. Shared by + * the dashboard (to decide whether to offer the action) and the backend (to + * decide which scopes to redistribute) so the two never disagree. + */ +export const getRecalculableScopeKeys = ({ + cusEnts, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; +}): Set => { + const scopes = new Map< + string, + { hasNegative: boolean; hasPositive: boolean } + >(); + for (const cusEnt of cusEnts) { + const key = cusEntToRecalculateScopeKey({ cusEnt }); + const scope = scopes.get(key) ?? { + hasNegative: false, + hasPositive: false, + }; + const remaining = cusEntsToBalance({ cusEnts: [cusEnt], entityId }); + if (remaining < 0) scope.hasNegative = true; + if (remaining > 0) scope.hasPositive = true; + scopes.set(key, scope); + } + const recalculable = new Set(); + for (const [key, scope] of scopes) { + if (scope.hasNegative && scope.hasPositive) { + recalculable.add(key); + } + } + return recalculable; +}; + +export const hasRecalculableScope = ({ + cusEnts, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; +}): boolean => getRecalculableScopeKeys({ cusEnts, entityId }).size > 0; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/customerEntitlementToPlanItemV1.ts b/shared/utils/cusEntUtils/convertCusEntUtils/customerEntitlementToPlanItemV1.ts new file mode 100644 index 000000000..31796a1ec --- /dev/null +++ b/shared/utils/cusEntUtils/convertCusEntUtils/customerEntitlementToPlanItemV1.ts @@ -0,0 +1,41 @@ +import type { ApiPlanItemV1 } from "@api/products/items/apiPlanItemV1"; +import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels"; +import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels"; +import type { FullCusProduct } from "@models/cusProductModels/cusProductModels"; +import { mapToProductItems } from "@utils/productV2Utils/mapToProductV2"; +import { productItemsToPlanItemsV1 } from "@utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1"; +import { cusEntToCusPrice } from "./cusEntToCusPrice"; + +export const customerEntitlementToFeatureId = ( + customerEntitlement: FullCustomerEntitlement, +) => customerEntitlement.entitlement?.feature?.id ?? customerEntitlement.feature_id; + +export const customerEntitlementToPlanItemV1 = ({ + customerEntitlement, + customerProduct, + customerPrices = [], +}: { + customerEntitlement: FullCustomerEntitlement; + customerProduct: FullCusProduct; + customerPrices?: FullCustomerPrice[]; +}): ApiPlanItemV1 => { + const effectiveCustomerProduct = { + ...customerProduct, + customer_prices: [...customerProduct.customer_prices, ...customerPrices], + }; + const customerPrice = cusEntToCusPrice({ + cusEnt: { + ...customerEntitlement, + customer_product: effectiveCustomerProduct, + }, + errorOnNotFound: false, + }); + const features = [customerEntitlement.entitlement.feature]; + const items = mapToProductItems({ + entitlements: [customerEntitlement.entitlement], + prices: customerPrice ? [customerPrice.price] : [], + features, + }); + + return productItemsToPlanItemsV1({ items, features })[0]; +}; diff --git a/shared/utils/cusEntUtils/index.ts b/shared/utils/cusEntUtils/index.ts index 0d61db2bd..1d112b115 100644 --- a/shared/utils/cusEntUtils/index.ts +++ b/shared/utils/cusEntUtils/index.ts @@ -19,6 +19,7 @@ export * from "./balanceUtils/customerEntitlementToBalancePrice"; export * from "./balanceUtils/grantedBalanceUtils/cusEntsToAdjustment"; export * from "./balanceUtils/grantedBalanceUtils/cusEntsToAllowance"; export * from "./balanceUtils/grantedBalanceUtils/cusEntsToGrantedBalance"; +export * from "./balanceUtils/recalculateScopeUtils"; export * from "./balanceUtils/rollovers/cusEntsToRolloverBalance"; export * from "./balanceUtils/rollovers/cusEntsToRolloverGranted"; export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage"; @@ -36,6 +37,7 @@ export * from "./convertCusEntUtils/cusEntToBillingObjects"; export * from "./convertCusEntUtils/cusEntToCusPrice"; export * from "./convertCusEntUtils/cusEntToKey"; export * from "./convertCusEntUtils/cusEntToStripeIds"; +export * from "./convertCusEntUtils/customerEntitlementToPlanItemV1"; // Convert utils barrel export * from "./convertCusEntUtils/customerEntitlementToOptions"; // Core utils diff --git a/shared/utils/cusProductUtils/getCusProductFromCustomer.ts b/shared/utils/cusProductUtils/getCusProductFromCustomer.ts index 8e6880d03..2bb4c26a2 100644 --- a/shared/utils/cusProductUtils/getCusProductFromCustomer.ts +++ b/shared/utils/cusProductUtils/getCusProductFromCustomer.ts @@ -167,6 +167,14 @@ export const getTargetSubscriptionScheduleCusProduct = ({ return hasSubscriptionSchedule; }); + if (cusProductId) { + const targetCusProduct = cusProducts.find((cp) => cp.id === cusProductId); + const targetExists = fullCus.customer_products.some( + (cp) => cp.id === cusProductId, + ); + if (targetExists && !targetCusProduct) return undefined; + } + // Sort by merge order: // 1. Entity match (highest priority) // 2. Main product (add-ons lowest priority) diff --git a/shared/utils/featureUtils/index.ts b/shared/utils/featureUtils/index.ts index 3860239c2..c94f96905 100644 --- a/shared/utils/featureUtils/index.ts +++ b/shared/utils/featureUtils/index.ts @@ -9,6 +9,7 @@ export * from "./apiFeatureToDbFeature"; export * from "./convertFeatureUtils"; export * from "./creditSystemUtils"; export * from "./findFeatureUtils"; +export * from "./sortFeatures"; export { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem"; export { isAnyCreditSystem } from "@utils/featureUtils/classifyFeature/isAnyCreditSystem"; diff --git a/shared/utils/featureUtils/sortFeatures.ts b/shared/utils/featureUtils/sortFeatures.ts new file mode 100644 index 000000000..6e978a611 --- /dev/null +++ b/shared/utils/featureUtils/sortFeatures.ts @@ -0,0 +1,13 @@ +import type { Feature } from "../../models/featureModels/featureModels.js"; + +export const sortFeatures = ({ features }: { features?: Feature[] }) => { + if (!features) return features; + + features.sort((a, b) => { + if (a.archived && !b.archived) return 1; + if (!a.archived && b.archived) return -1; + return 0; + }); + + return features; +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 23d5d0423..7a0482cd4 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -65,6 +65,10 @@ export * from "./productV2Utils/productV2ToFrontendProduct"; export * from "./productV2Utils/productV2ToV1"; export * from "./productV3Utils/productItemUtils/productV3ItemUtils"; +// Plan V1 diff/apply utils +export * from "./planV1Utils/diff/diffPlanV1"; +export * from "./planV1Utils/diff/applyDiff"; + // Stripe resource utils export * from "./stripeUtils/classifyStripeResource/isPreviewStripeId"; diff --git a/shared/utils/infisical.ts b/shared/utils/infisical.ts new file mode 100644 index 000000000..135151f46 --- /dev/null +++ b/shared/utils/infisical.ts @@ -0,0 +1,43 @@ +export const initInfisical = async () => { + const clientId = process.env.INFISICAL_CLIENT_ID; + const clientSecret = process.env.INFISICAL_CLIENT_SECRET; + const projectId = process.env.INFISICAL_PROJECT_ID; + const environment = process.env.INFISICAL_ENVIRONMENT; + if (!clientId || !clientSecret || !projectId || !environment) return; + + const auth = await fetch("https://app.infisical.com/api/v1/auth/universal-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId, clientSecret }), + }); + if (!auth.ok) throw new Error(`Infisical auth failed: ${auth.status}`); + const { accessToken } = (await auth.json()) as { accessToken: string }; + + const params = new URLSearchParams({ + environment, + workspaceId: projectId, + secretPath: process.env.INFISICAL_SECRET_PATH ?? "/", + includeImports: "true", + recursive: "true", + }); + const secrets = await fetch(`https://app.infisical.com/api/v3/secrets/raw?${params}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!secrets.ok) throw new Error(`Infisical secrets failed: ${secrets.status}`); + + const body = (await secrets.json()) as { + secrets: Array<{ secretKey: string; secretValue: string }>; + imports?: Array<{ secrets: Array<{ secretKey: string; secretValue: string }> }>; + }; + let loaded = 0; + for (const secret of [ + ...body.secrets, + ...(body.imports ?? []).flatMap((group) => group.secrets), + ]) { + if (!process.env[secret.secretKey]) { + process.env[secret.secretKey] = secret.secretValue; + loaded++; + } + } + console.log(`Infisical loaded ${loaded} secrets`); +}; diff --git a/shared/utils/leafOAuthScopes.test.ts b/shared/utils/leafOAuthScopes.test.ts new file mode 100644 index 000000000..68ba75a37 --- /dev/null +++ b/shared/utils/leafOAuthScopes.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { LEAF_OAUTH_SCOPES } from "./leafOAuthScopes"; +import { Scopes } from "./scopeDefinitions"; + +describe("LEAF_OAUTH_SCOPES", () => { + test("contains the exact Leaf Slack and MCP OAuth allowlist", () => { + expect(LEAF_OAUTH_SCOPES).toEqual([ + Scopes.Organisation.Read, + Scopes.Customers.Read, + Scopes.Customers.Write, + Scopes.Features.Read, + Scopes.Features.Write, + Scopes.Plans.Read, + Scopes.Plans.Write, + Scopes.Balances.Read, + Scopes.Balances.Write, + Scopes.Billing.Read, + Scopes.Billing.Write, + Scopes.Analytics.Read, + ]); + }); + + test("does not include elevated or unrelated product scopes", () => { + expect(LEAF_OAUTH_SCOPES).not.toEqual( + expect.arrayContaining([ + Scopes.Organisation.Write, + Scopes.ApiKeys.Read, + Scopes.ApiKeys.Write, + Scopes.Migrations.Read, + Scopes.Migrations.Write, + Scopes.Platform.Read, + Scopes.Platform.Write, + Scopes.Rewards.Read, + Scopes.Rewards.Write, + Scopes.Admin, + Scopes.Owner, + Scopes.Superuser, + ]), + ); + }); +}); diff --git a/shared/utils/leafOAuthScopes.ts b/shared/utils/leafOAuthScopes.ts new file mode 100644 index 000000000..079f8d7aa --- /dev/null +++ b/shared/utils/leafOAuthScopes.ts @@ -0,0 +1,16 @@ +import { type ScopeString, Scopes } from "./scopeDefinitions"; + +export const LEAF_OAUTH_SCOPES = [ + Scopes.Organisation.Read, + Scopes.Customers.Read, + Scopes.Customers.Write, + Scopes.Features.Read, + Scopes.Features.Write, + Scopes.Plans.Read, + Scopes.Plans.Write, + Scopes.Balances.Read, + Scopes.Balances.Write, + Scopes.Billing.Read, + Scopes.Billing.Write, + Scopes.Analytics.Read, +] as const satisfies readonly ScopeString[]; diff --git a/shared/utils/planV1Utils/diff/applyDiff.ts b/shared/utils/planV1Utils/diff/applyDiff.ts new file mode 100644 index 000000000..2338983ed --- /dev/null +++ b/shared/utils/planV1Utils/diff/applyDiff.ts @@ -0,0 +1,95 @@ +import type { + ApiPlanV1, + CreatePlanItemParamsV1, + PlanItemFilter, +} from "@autumn/shared"; +import type { DiffedCustomizePlanV1 } from "./diffPlanV1.js"; + +export type ApplyDiffOutput = { + price: ApiPlanV1["price"]; + items: ApiPlanV1["items"]; + free_trial: ApiPlanV1["free_trial"]; +}; + +type ApiPlanItem = ApiPlanV1["items"][number]; + +const applyPrice = ( + base: ApiPlanV1["price"], + diff: DiffedCustomizePlanV1["price"], +): ApiPlanV1["price"] => { + if (diff === undefined) return base; + if (diff === null) return null; + return { ...diff }; +}; + +const itemMatchesFilter = ( + item: ApiPlanItem, + filter: PlanItemFilter, +): boolean => { + if (filter.feature_id !== undefined && item.feature_id !== filter.feature_id) + return false; + if (filter.billing_method !== undefined) { + if (item.price?.billing_method !== filter.billing_method) + return false; + } else if (item.price?.billing_method !== undefined) { + return false; + } + if (filter.interval !== undefined) { + const itemInterval = item.price?.interval ?? item.reset?.interval; + if (String(itemInterval) !== String(filter.interval)) return false; + } + if (filter.interval_count !== undefined) { + const itemCount = + item.price?.interval_count ?? item.reset?.interval_count; + if ((itemCount ?? 1) !== filter.interval_count) return false; + } + return true; +}; + +const removeItems = ( + items: ApiPlanV1["items"], + removeFilters: PlanItemFilter[], +): ApiPlanV1["items"] => { + return items.filter( + (item) => !removeFilters.some((filter) => itemMatchesFilter(item, filter)), + ); +}; + +const toApiPlanItem = (params: CreatePlanItemParamsV1): ApiPlanItem => { + return { ...params } as ApiPlanItem; +}; + +const applyItems = ( + baseItems: ApiPlanV1["items"], + diff: DiffedCustomizePlanV1, +): ApiPlanV1["items"] => { + let items = [...baseItems]; + if (diff.remove_items) { + items = removeItems(items, diff.remove_items); + } + if (diff.add_items) { + items = [...items, ...diff.add_items.map(toApiPlanItem)]; + } + return items; +}; + +const applyFreeTrial = ( + base: ApiPlanV1["free_trial"], + diff: DiffedCustomizePlanV1["free_trial"], +): ApiPlanV1["free_trial"] => { + if (diff === undefined) return base; + if (diff === null) return undefined; + return { ...diff } as ApiPlanV1["free_trial"]; +}; + +export const applyDiff = ({ + base, + diff, +}: { + base: ApiPlanV1; + diff: DiffedCustomizePlanV1; +}): ApplyDiffOutput => ({ + price: applyPrice(base.price, diff.price), + items: applyItems(base.items, diff), + free_trial: applyFreeTrial(base.free_trial, diff.free_trial), +}); diff --git a/shared/utils/planV1Utils/diff/diffPlanV1.ts b/shared/utils/planV1Utils/diff/diffPlanV1.ts new file mode 100644 index 000000000..e7661c867 --- /dev/null +++ b/shared/utils/planV1Utils/diff/diffPlanV1.ts @@ -0,0 +1,142 @@ +import type { BasePriceParams } from "@api/products/components/basePrice/basePrice.js"; +import { + type ApiPlanV1, + type CreatePlanItemParamsV1, + CustomizePlanV1Schema, + type PlanItemFilter, +} from "@autumn/shared"; +import type { z } from "zod/v4"; + +export const DiffedCustomizePlanV1Schema = CustomizePlanV1Schema.omit({ + items: true, +}); + +export type DiffedCustomizePlanV1 = z.infer; + +type ApiPlanItem = ApiPlanV1["items"][number]; + +const toBasePriceParams = ( + price: NonNullable, +): BasePriceParams => ({ + amount: price.amount, + interval: price.interval, + ...(price.interval_count !== undefined + ? { interval_count: price.interval_count } + : {}), +}); + +const toCreatePlanItemParams = (item: ApiPlanItem): CreatePlanItemParamsV1 => { + const out: CreatePlanItemParamsV1 = { feature_id: item.feature_id }; + if (item.included !== undefined && item.included !== null) + out.included = item.included; + if (item.unlimited !== undefined && item.unlimited !== null) + out.unlimited = item.unlimited; + if (item.reset) out.reset = item.reset; + if (item.price) out.price = item.price as CreatePlanItemParamsV1["price"]; + if (item.rollover) { + out.rollover = { + expiry_duration_type: item.rollover.expiry_duration_type, + ...(item.rollover.max != null ? { max: item.rollover.max } : {}), + ...(item.rollover.max_percentage != null + ? { max_percentage: item.rollover.max_percentage } + : {}), + ...(item.rollover.expiry_duration_length !== undefined + ? { expiry_duration_length: item.rollover.expiry_duration_length } + : {}), + }; + } + return out; +}; + +const composeMatchKey = (item: ApiPlanItem): string => { + const billingMethod = item.price?.billing_method ?? ""; + const interval = item.price?.interval ?? item.reset?.interval ?? ""; + const intervalCount = + item.price?.interval_count ?? item.reset?.interval_count ?? ""; + return `${item.feature_id}|${billingMethod}|${interval}|${intervalCount}`; +}; + +const buildRemoveFilter = (item: ApiPlanItem): PlanItemFilter => { + const filter: PlanItemFilter = { feature_id: item.feature_id }; + if (item.price?.billing_method !== undefined) + filter.billing_method = item.price.billing_method; + const interval = item.price?.interval ?? item.reset?.interval; + if (interval !== undefined) + filter.interval = interval as PlanItemFilter["interval"]; + const intervalCount = + item.price?.interval_count ?? item.reset?.interval_count; + if (intervalCount !== undefined) filter.interval_count = intervalCount; + return filter; +}; + +const pricesEqual = (a: ApiPlanV1["price"], b: ApiPlanV1["price"]): boolean => { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + return ( + a.amount === b.amount && + a.interval === b.interval && + (a.interval_count ?? 1) === (b.interval_count ?? 1) + ); +}; + +const freeTrialsEqual = ( + a: ApiPlanV1["free_trial"], + b: ApiPlanV1["free_trial"], +): boolean => { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + return JSON.stringify(a) === JSON.stringify(b); +}; + +// Equality ignores `display` (UI-derived) and `feature` (join, not user input). +const itemsEqual = (a: ApiPlanItem, b: ApiPlanItem): boolean => { + const strip = ({ display: _d, feature: _f, ...rest }: ApiPlanItem) => rest; + return JSON.stringify(strip(a)) === JSON.stringify(strip(b)); +}; + +// Modify-in-place is expressed as remove + add ("out with the old, in with the new"). +export const diffPlanV1 = ({ + from, + to, +}: { + from: ApiPlanV1; + to: ApiPlanV1; +}): DiffedCustomizePlanV1 => { + const diff: DiffedCustomizePlanV1 = {}; + + if (!pricesEqual(from.price, to.price)) { + diff.price = to.price === null ? null : toBasePriceParams(to.price); + } + + const fromByKey = new Map(from.items.map((i) => [composeMatchKey(i), i])); + const toByKey = new Map(to.items.map((i) => [composeMatchKey(i), i])); + + const addItems: CreatePlanItemParamsV1[] = []; + for (const toItem of to.items) { + const fromItem = fromByKey.get(composeMatchKey(toItem)); + if (!fromItem || !itemsEqual(fromItem, toItem)) { + addItems.push(toCreatePlanItemParams(toItem)); + } + } + if (addItems.length > 0) diff.add_items = addItems; + + const removeItems: PlanItemFilter[] = []; + for (const fromItem of from.items) { + const toItem = toByKey.get(composeMatchKey(fromItem)); + if (!toItem || !itemsEqual(fromItem, toItem)) { + removeItems.push(buildRemoveFilter(fromItem)); + } + } + if (removeItems.length > 0) diff.remove_items = removeItems; + + if (!freeTrialsEqual(from.free_trial, to.free_trial)) { + if (to.free_trial == null) { + diff.free_trial = null; + } else { + const { on_end, ...rest } = to.free_trial; + diff.free_trial = on_end == null ? rest : { ...rest, on_end }; + } + } + + return diff; +}; diff --git a/shared/utils/productUtils/entUtils/classifyEntUtils.ts b/shared/utils/productUtils/entUtils/classifyEntUtils.ts index 1e21007ce..5d3632f3a 100644 --- a/shared/utils/productUtils/entUtils/classifyEntUtils.ts +++ b/shared/utils/productUtils/entUtils/classifyEntUtils.ts @@ -36,7 +36,7 @@ export const isLifetimeEntitlement = ({ }: { entitlement: EntitlementWithFeature; }) => { - return entitlement.interval === EntInterval.Lifetime; + return !entitlement.interval || entitlement.interval === EntInterval.Lifetime; }; export const entitlementHasEntityFeature = ({ diff --git a/shared/utils/productUtils/priceUtils/convertAmountUtils.ts b/shared/utils/productUtils/priceUtils/convertAmountUtils.ts index ad67a7808..919164465 100644 --- a/shared/utils/productUtils/priceUtils/convertAmountUtils.ts +++ b/shared/utils/productUtils/priceUtils/convertAmountUtils.ts @@ -26,7 +26,7 @@ const ZERO_DECIMAL_CURRENCIES = [ /** * Converts an Autumn amount to a Stripe amount. * For most currencies, multiplies by 100 (e.g., $1.00 -> 100 cents). - * For zero-decimal currencies like JPY, returns the amount as-is. + * For zero-decimal currencies like JPY, rounds to the nearest integer. */ export const atmnToStripeAmount = ({ amount, @@ -36,7 +36,7 @@ export const atmnToStripeAmount = ({ currency?: string; }): number => { if (ZERO_DECIMAL_CURRENCIES.includes(currency.toUpperCase())) { - return amount; + return new Decimal(amount).round().toNumber(); } return new Decimal(amount).mul(100).round().toNumber(); }; diff --git a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts index 81a9e2d3f..a58047276 100644 --- a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts +++ b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts @@ -1,11 +1,10 @@ import type { Organization } from "@models/orgModels/orgTable"; import type { Entitlement } from "@models/productModels/entModels/entModels"; -import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; import type { Price } from "@models/productModels/priceModels/priceModels"; import { orgToCurrency } from "@utils/orgUtils/convertOrgUtils"; import { - isFinalTier, isNotFinalTier, + isPrepaidPrice, } from "@utils/productUtils/priceUtils/classifyPriceUtils"; import { atmnToStripeAmountDecimal } from "@utils/productUtils/priceUtils/convertAmountUtils"; import { Decimal } from "decimal.js"; @@ -33,8 +32,13 @@ export const priceToStripePrepaidV2Tiers = ({ price: Price; entitlement: Entitlement; org: Organization; -}) => { - const config = price.config as UsagePriceConfig; +}): Stripe.PriceCreateParams.Tier[] => { + if (!isPrepaidPrice(price)) { + throw new Error( + `priceToStripePrepaidV2Tiers requires a prepaid price, got price ${price.id}`, + ); + } + const config = price.config; const tiers: Stripe.PriceCreateParams.Tier[] = []; @@ -47,9 +51,8 @@ export const priceToStripePrepaidV2Tiers = ({ }); } - for (let i = 0; i < config.usage_tiers.length; i++) { - const tier = config.usage_tiers[i]; - const atmnUnitAmount = new Decimal(tier.amount).div( + for (const tier of config.usage_tiers) { + const atmnUnitAmount = new Decimal(tier.amount ?? 0).div( config.billing_units ?? 1, ); @@ -58,14 +61,14 @@ export const priceToStripePrepaidV2Tiers = ({ currency: orgToCurrency({ org }), }); - let upTo = tier.to; - if (isNotFinalTier(tier) && entitlement.allowance) { - upTo = tier.to + entitlement.allowance; + let upTo: Stripe.PriceCreateParams.Tier["up_to"] = "inf"; + if (isNotFinalTier(tier)) { + upTo = entitlement.allowance ? tier.to + entitlement.allowance : tier.to; } const stripeTier: Stripe.PriceCreateParams.Tier = { unit_amount_decimal: stripeUnitAmountDecimal, - up_to: isFinalTier(tier) ? "inf" : upTo, + up_to: upTo, }; if (tier.flat_amount) { @@ -79,13 +82,13 @@ export const priceToStripePrepaidV2Tiers = ({ } // Divide all tiers by billing units - const dividedTiers = tiers.map((tier, index: number) => ({ + return tiers.map((tier, index) => ({ ...tier, up_to: - index === tiers.length - 1 + index === tiers.length - 1 || tier.up_to === "inf" ? "inf" - : new Decimal(tier.up_to ?? 0) + : new Decimal(tier.up_to) .div(config.billing_units ?? 1) .ceil() .toNumber(), @@ -94,6 +97,4 @@ export const priceToStripePrepaidV2Tiers = ({ .mul(config.billing_units ?? 1) .toString(), })); - - return dividedTiers; }; diff --git a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts index 7297ca444..a1bfc7ec7 100644 --- a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts @@ -1,7 +1,9 @@ import { InternalError } from "@api/errors/base/InternalError"; +import { BillingMethod } from "@api/products/components/billingMethod"; import type { Feature } from "@models/featureModels/featureModels"; import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels"; import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; +import { BillingType } from "@models/productModels/priceModels/priceEnums"; import type { Price } from "@models/productModels/priceModels/priceModels"; import { OnDecrease, @@ -13,6 +15,7 @@ import { shouldProrate, shouldSkipLineItems, } from "@utils/billingUtils"; +import { getBillingType } from "@utils/productUtils/priceUtils"; import { priceToEnt } from "@utils/productUtils/convertProductUtils"; // Overload: errorOnNotFound = true → guaranteed Feature @@ -94,3 +97,21 @@ export const priceToProrationConfig = ({ shouldCreateReplaceables: shouldCreateReplaceables(prorationBehaviorConfig), }; }; + +export const priceToBillingMethod = ({ + price, +}: { + price?: Price; +}): BillingMethod | undefined => { + if (!price) return undefined; + + const billingType = getBillingType(price.config); + if (billingType === BillingType.UsageInAdvance) return BillingMethod.Prepaid; + if ( + billingType === BillingType.UsageInArrear || + billingType === BillingType.InArrearProrated + ) + return BillingMethod.UsageBased; + + return undefined; +}; diff --git a/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts b/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts index d6db73a01..b4bdbe606 100644 --- a/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts +++ b/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts @@ -34,5 +34,11 @@ export const matchesPlanItemFilter = ({ ) return false; + if ( + filter.interval_count !== undefined && + (item.interval_count ?? 1) !== filter.interval_count + ) + return false; + return true; }; diff --git a/shared/utils/scopeDefinitions.test.ts b/shared/utils/scopeDefinitions.test.ts index 5139645bb..0ac1f1ba8 100644 --- a/shared/utils/scopeDefinitions.test.ts +++ b/shared/utils/scopeDefinitions.test.ts @@ -599,15 +599,16 @@ describe("ROLE_SCOPES", () => { expect(ROLE_SCOPES.sales.length).toBe(7); }); - test("member contains all :read scopes, no :write", () => { - expect(ROLE_SCOPES.member.length).toBe(RESOURCES.length); + test("member contains expected :read scopes, no :write", () => { + expect(ROLE_SCOPES.member.length).toBe(RESOURCES.length - 1); for (const s of ROLE_SCOPES.member) { expect(s.endsWith(":read")).toBe(true); expect(s.endsWith(":write")).toBe(false); } - for (const r of RESOURCES) { + for (const r of RESOURCES.filter((r) => r !== "migrations")) { expect(ROLE_SCOPES.member).toContain(`${r}:read` as ScopeString); } + expect(ROLE_SCOPES.member).not.toContain(Scopes.Migrations.Read); }); }); diff --git a/shared/utils/scopeDefinitions.ts b/shared/utils/scopeDefinitions.ts index 6aac7915f..616c405fb 100644 --- a/shared/utils/scopeDefinitions.ts +++ b/shared/utils/scopeDefinitions.ts @@ -402,7 +402,6 @@ export const ROLE_SCOPES: Record = { Scopes.Rewards.Read, Scopes.Balances.Read, Scopes.Billing.Read, - Scopes.Migrations.Read, Scopes.Analytics.Read, Scopes.ApiKeys.Read, Scopes.Platform.Read, @@ -675,6 +674,17 @@ function requirementMentions( return needles.some((n) => hay.includes(n as ScopeString)); } +/** + * Rewrite a legacy CRUDL requirement scope to its modern R/W equivalent so it + * can be matched against an expanded grant (which only ever holds modern + meta + * scopes). Modern and meta scopes pass through unchanged. Deliberately applies + * only LEGACY_SCOPE_ALIASES, not expandScopes, so a required `admin`/`owner` + * is never blown up into "every modern scope". + */ +function normaliseRequiredScope(scope: ScopeString): ScopeString { + return (LEGACY_SCOPE_ALIASES[scope] ?? scope) as ScopeString; +} + /** * Check whether a set of granted scopes satisfies a route's requirement. * @@ -705,7 +715,9 @@ export function checkScopes( // Shorthand: a plain array means ALL required. if (Array.isArray(required)) { - const missing = required.filter((s) => !expanded.has(s)); + const missing = required + .map(normaliseRequiredScope) + .filter((s) => !expanded.has(s)); return { allowed: missing.length === 0, missing }; } @@ -714,8 +726,8 @@ export function checkScopes( ANY?: readonly ScopeString[]; }; - const allList = req.ALL ?? []; - const anyList = req.ANY ?? []; + const allList = (req.ALL ?? []).map(normaliseRequiredScope); + const anyList = (req.ANY ?? []).map(normaliseRequiredScope); const missingAll = allList.filter((s) => !expanded.has(s)); const anySatisfied = diff --git a/trigger.config.ts b/trigger.config.ts index 7f9f2cc1a..44c33df5e 100644 --- a/trigger.config.ts +++ b/trigger.config.ts @@ -15,9 +15,13 @@ const workspacePackageJsonPaths = [ "apps/checkout/package.json", "apps/docs/package.json", "apps/website/package.json", + "apps/leaf/package.json", "apps/sdk-test/package.json", "packages/atmn/package.json", "packages/atmn-tests/package.json", + "packages/auth/package.json", + "packages/logging/package.json", + "packages/mcp/package.json", "packages/sdk/package.json", "packages/autumn-js/package.json", "packages/openapi/package.json", diff --git a/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx b/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx index 581adc099..073b44fa2 100644 --- a/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx @@ -36,11 +36,12 @@ import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import type { FormCustomLineItem } from "../attachFormSchema"; import { useAttachFormContext } from "../context/AttachFormProvider"; import { useAttachBillingOptionsState } from "../hooks/useAttachBillingOptionsState"; -import { addDiscount } from "../utils/discountUtils"; import { getAttachScheduledStartDate } from "../utils/buildAttachPreviewTotals"; +import { addDiscount } from "../utils/discountUtils"; import { AttachDiscountRow } from "./AttachDiscountRow"; let customLineItemCounter = 0; +const BACKDATE_START_YEAR_LOOKBACK = 25; function createCustomLineItem(): FormCustomLineItem { return { @@ -171,6 +172,9 @@ export function AttachAdvancedSection() { isPaidRecurringProduct && !trialEnabled && effectivePlanSchedule !== "end_of_cycle"; + const createsNewStripeSubscription = + !hasActiveSubscription || newBillingSubscription; + const allowBackdatedStartDate = showStartDate && createsNewStripeSubscription; const showEndDate = !!product && !isFreeProductV2({ items: product.items }); const attachStartsAt = effectivePlanSchedule === "end_of_cycle" @@ -222,7 +226,11 @@ export function AttachAdvancedSection() { {showStartDate && ( form.setFieldValue("startDate", value)} - disablePastDates - minUnixDate={Date.now()} + disablePastDates={!allowBackdatedStartDate} + minUnixDate={allowBackdatedStartDate ? undefined : Date.now()} + fromYear={ + allowBackdatedStartDate + ? new Date().getFullYear() - BACKDATE_START_YEAR_LOOKBACK + : undefined + } withTime /> diff --git a/vite/src/components/forms/attach-v2/components/AttachFooterV3.tsx b/vite/src/components/forms/attach-v2/components/AttachFooterV3.tsx index a22a694e2..f2d285690 100644 --- a/vite/src/components/forms/attach-v2/components/AttachFooterV3.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachFooterV3.tsx @@ -97,10 +97,7 @@ export function AttachFooterV3() { {invoiceDisabledReason && ( - + {invoiceDisabledReason} )} diff --git a/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx index 8e70e4a38..6c967a04b 100644 --- a/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx +++ b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx @@ -90,6 +90,8 @@ interface AttachFormContextValue { handleInvoiceAttach: (params: { enableProductImmediately: boolean; finalizeInvoice: boolean; + invoiceTemplateId?: string; + netTermsDays?: number; }) => Promise<{ stripeId: string | undefined; hostedInvoiceUrl: string | null | undefined; @@ -350,10 +352,7 @@ export function AttachFormProvider({ "trialCardRequired", Boolean(product.free_trial.card_required), ); - form.setFieldValue( - "trialOnEnd", - product.free_trial.on_end ?? "bill", - ); + form.setFieldValue("trialOnEnd", product.free_trial.on_end ?? "bill"); } } }, [productId, product, form, resetGrantFree]); @@ -396,6 +395,7 @@ export function AttachFormProvider({ product: effectiveProduct, prepaidOptions, items, + grantFree, version, trialLength, trialDuration, @@ -419,7 +419,6 @@ export function AttachFormProvider({ customLineItems, disableProration, }); - const previewQuery = useAttachPreview({ requestBody, enabled: disablePreview ? false : undefined, diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachBillingOptionsState.ts b/vite/src/components/forms/attach-v2/hooks/useAttachBillingOptionsState.ts index dc17aac28..e1c9f0bb5 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachBillingOptionsState.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachBillingOptionsState.ts @@ -1,5 +1,4 @@ import { - ACTIVE_STATUSES, type BillingBehavior, BillingInterval, type CusProduct, @@ -8,7 +7,7 @@ import { hasActivePaidSubscription, type PlanTiming, } from "@autumn/shared"; -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { useAttachFormContext } from "../context/AttachFormProvider"; import { @@ -121,12 +120,23 @@ export function useAttachBillingOptionsState() { const isImmediateSelected = effectivePlanSchedule === "immediate"; const isEndOfCycleSelected = effectivePlanSchedule === "end_of_cycle"; + const movePastStartDateToNow = useCallback(() => { + if (startDate !== null && startDate < Date.now()) { + form.setFieldValue("startDate", Date.now()); + } + }, [form, startDate]); useEffect(() => { if (canChooseBillingCycle) return; if (!newBillingSubscription) return; form.setFieldValue("newBillingSubscription", false); - }, [canChooseBillingCycle, form, newBillingSubscription]); + movePastStartDateToNow(); + }, [ + canChooseBillingCycle, + form, + movePastStartDateToNow, + newBillingSubscription, + ]); useEffect(() => { if (showProrationBehavior) return; @@ -163,6 +173,7 @@ export function useAttachBillingOptionsState() { createNewCycle: boolean; }) => { form.setFieldValue("newBillingSubscription", createNewCycle); + if (!createNewCycle) movePastStartDateToNow(); form.setFieldValue( "prorationBehavior", normalizeAttachProrationBehavior({ diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts b/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts index 0fe1fcba2..c58b9a19e 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts @@ -15,6 +15,8 @@ export function useAttachMutation({ useInvoice?: boolean; enableProductImmediately?: boolean; finalizeInvoice?: boolean; + invoiceTemplateId?: string; + netTermsDays?: number; }) => AttachParamsV0 | null; onCheckoutRedirect?: (checkoutUrl: string) => void; onSuccess?: () => void; @@ -27,11 +29,15 @@ export function useAttachMutation({ useInvoice, enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, skipDefaultSuccess, }: { useInvoice?: boolean; enableProductImmediately?: boolean; finalizeInvoice?: boolean; + invoiceTemplateId?: string; + netTermsDays?: number; skipDefaultSuccess?: boolean; }) => { if (!customerId) { @@ -42,6 +48,8 @@ export function useAttachMutation({ useInvoice, enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, }); if (!requestBody) { @@ -102,14 +110,20 @@ export function useAttachMutation({ const handleInvoiceAttach = async ({ enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, }: { enableProductImmediately: boolean; finalizeInvoice: boolean; + invoiceTemplateId?: string; + netTermsDays?: number; }) => { const result = await mutation.mutateAsync({ useInvoice: true, enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, }); return { stripeId: result.data?.invoice?.stripe_id, diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts index f129cf70d..5d904abd2 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts @@ -10,6 +10,7 @@ import type { TrialOnEnd, } from "@autumn/shared"; import { useMemo } from "react"; +import { normalizeBillingRequestItems } from "@/components/forms/shared/utils/normalizeBillingRequestItems"; import { getFreeTrial } from "@/components/forms/update-subscription-v2/utils/getFreeTrial"; import { convertPrepaidOptionsToFeatureOptions } from "@/utils/billing/prepaidQuantityUtils"; import type { FormCustomLineItem } from "../attachFormSchema"; @@ -25,6 +26,7 @@ export interface BuildAttachRequestBodyParams { product: ProductV2 | undefined; prepaidOptions: Record; items: ProductItem[] | null; + grantFree: boolean; version: number | undefined; trialLength: number | null; trialDuration: FreeTrialDuration; @@ -56,6 +58,7 @@ export function buildAttachRequestBody({ product, prepaidOptions, items, + grantFree, version, trialLength, trialDuration, @@ -103,10 +106,17 @@ export function buildAttachRequestBody({ } if (items !== null) { - body.items = items.map((item) => ({ - ...item, - interval: (item.interval ?? null) as ProductItemInterval | null, - })); + const normalizedItems = normalizeBillingRequestItems({ items }); + if (normalizedItems) { + body.items = normalizedItems.map((item) => ({ + ...item, + interval: (item.interval ?? null) as ProductItemInterval | null, + })); + } else if (grantFree) { + // Send explicit `[]` so the backend overrides the product's default + // (paid) items; omitting `items` falls back to them. See useGrantFree. + body.items = []; + } } if (version !== undefined) { @@ -202,6 +212,7 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) { product, prepaidOptions, items, + grantFree, version, trialLength, trialDuration, @@ -234,6 +245,7 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) { product, prepaidOptions, items, + grantFree, version, trialLength, trialDuration, @@ -263,6 +275,7 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) { product, prepaidOptions, items, + grantFree, version, trialLength, trialDuration, @@ -294,10 +307,14 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) { useInvoice, enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, }: { useInvoice?: boolean; enableProductImmediately?: boolean; finalizeInvoice?: boolean; + invoiceTemplateId?: string; + netTermsDays?: number; } = {}): AttachParamsV0 | null => { if (!requestBody) return null; @@ -306,6 +323,8 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) { if (useInvoice) { body.invoice = true; body.finalize_invoice = finalizeInvoice ?? false; + body.invoice_template_id = invoiceTemplateId; + body.net_terms_days = netTermsDays; } // `enable_product_immediately` applies to both invoice mode and the diff --git a/vite/src/components/forms/create-schedule/components/CreateScheduleSheetContent.tsx b/vite/src/components/forms/create-schedule/components/CreateScheduleSheetContent.tsx index dc010c87c..2340fb4e5 100644 --- a/vite/src/components/forms/create-schedule/components/CreateScheduleSheetContent.tsx +++ b/vite/src/components/forms/create-schedule/components/CreateScheduleSheetContent.tsx @@ -29,7 +29,7 @@ export function CreateScheduleSheetContent() { const { form, formValues, entityId, handleAddPhase, error, onScopeChange } = useCreateScheduleFormContext(); const { closeSheet, setSheet } = useSheetStore(); - const hasSchedule = useHasSchedule(); + const hasSchedule = useHasSchedule({ entityId }); const { customer } = useCusQuery(); const entities = (customer as FullCustomer | null)?.entities ?? []; @@ -136,10 +136,10 @@ function getConfirmLabel({ } export function CreateScheduleReviewContent() { - const { handleSubmit, isPending, isPreviewLoading, preview, error } = + const { handleSubmit, isPending, isPreviewLoading, preview, error, entityId } = useCreateScheduleFormContext(); const { setSheet } = useSheetStore(); - const hasSchedule = useHasSchedule(); + const hasSchedule = useHasSchedule({ entityId }); const confirmLabel = getConfirmLabel({ preview }); const isZeroAmount = preview && preview.total <= 0; diff --git a/vite/src/components/forms/create-schedule/components/SchedulePhaseCard.tsx b/vite/src/components/forms/create-schedule/components/SchedulePhaseCard.tsx index 906c00d96..5d4ac4e8b 100644 --- a/vite/src/components/forms/create-schedule/components/SchedulePhaseCard.tsx +++ b/vite/src/components/forms/create-schedule/components/SchedulePhaseCard.tsx @@ -20,6 +20,7 @@ import { SchedulePlanRow } from "./SchedulePlanRow"; const LOCKED_PHASE_MESSAGE = "This phase has passed and can't be edited."; const CURRENT_PHASE_TIME_LOCKED_MESSAGE = "You can't edit the time of the current phase."; +const BACKDATE_START_YEAR_LOOKBACK = 25; interface SchedulePhaseCardProps { phaseIndex: number; @@ -36,6 +37,7 @@ export function SchedulePhaseCard({ nowMs, products, isExistingSchedule, + allowFirstPhaseBackdate, isPhaseLocked, handleAddPlan, handleInsertPhase, @@ -69,22 +71,44 @@ export function SchedulePhaseCard({ nowMs, }); + const isNewFirstPhase = !isExistingSchedule && isFirstPhase; + const nowChip = ( +
+ + Now + + + + + + The first phase of a schedule starts immediately + + +
+ ); + const phaseHeader = - !isExistingSchedule && isFirstPhase ? ( -
- - Now - - - - - - The first phase of a schedule starts immediately - - + isNewFirstPhase && !allowFirstPhaseBackdate ? ( + nowChip + ) : isNewFirstPhase ? ( +
+ { + form.setFieldValue(`phases[${phaseIndex}].startsAt`, value); + }} + disableFutureDates + maxUnixDate={nowMs} + fromYear={ + new Date(nowMs).getFullYear() - BACKDATE_START_YEAR_LOOKBACK + } + placeholder="Now" + withTime + className="group-hover/phase-date:border-primary" + />
) : ( <> diff --git a/vite/src/components/forms/create-schedule/components/ScheduledPlanGuard.tsx b/vite/src/components/forms/create-schedule/components/ScheduledPlanGuard.tsx index fa9267047..a4c1dd4d2 100644 --- a/vite/src/components/forms/create-schedule/components/ScheduledPlanGuard.tsx +++ b/vite/src/components/forms/create-schedule/components/ScheduledPlanGuard.tsx @@ -5,8 +5,14 @@ import { Button } from "@/components/v2/buttons/Button"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useHasSchedule } from "../hooks/useHasSchedule"; -export function ScheduledPlanGuard({ children }: { children: ReactNode }) { - const hasSchedule = useHasSchedule(); +export function ScheduledPlanGuard({ + children, + entityId, +}: { + children: ReactNode; + entityId?: string | null; +}) { + const hasSchedule = useHasSchedule({ entityId }); const { setSheet } = useSheetStore(); if (!hasSchedule) return <>{children}; diff --git a/vite/src/components/forms/create-schedule/context/CreateScheduleFormProvider.tsx b/vite/src/components/forms/create-schedule/context/CreateScheduleFormProvider.tsx index 4165dc24f..4b20a6107 100644 --- a/vite/src/components/forms/create-schedule/context/CreateScheduleFormProvider.tsx +++ b/vite/src/components/forms/create-schedule/context/CreateScheduleFormProvider.tsx @@ -1,20 +1,30 @@ import type { BillingPreviewResponse, Feature, + FullCusProduct, + FullCustomer, ProductV2, } from "@autumn/shared"; +import { + ACTIVE_STATUSES, + CusProductStatus, + isFreeProductV2, + isOneOffProductV2, +} from "@autumn/shared"; import { useStore } from "@tanstack/react-form"; import { createContext, type ReactNode, useCallback, useContext, + useEffect, useMemo, useState, } from "react"; import type { SendInvoiceSubmitParams } from "@/components/forms/shared/SendInvoiceStage"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { type CreateScheduleForm, getCreateSchedulePhaseTimingError, @@ -47,6 +57,8 @@ interface CreateScheduleFormContextValue { products: ProductV2[]; features: Feature[]; isExistingSchedule: boolean; + /** First phase may start in the past — only when a new Stripe subscription will be created. */ + allowFirstPhaseBackdate: boolean; isPhaseLocked: ({ phaseIndex }: { phaseIndex: number }) => boolean; handleAddPhase: () => void; @@ -122,6 +134,49 @@ export function CreateScheduleFormProvider({ [formValues.phases], ); + const { customer } = useCusQuery(); + const fullCustomer = customer as FullCustomer | null; + + // Mirrors the attach flow: a brand-new Stripe subscription is created when the + // (scoped) customer has no active/trialing subscription. Only then can the + // immediate phase be backdated (the server enforces the same rule). + const hasActiveSubscription = useMemo(() => { + const cusProducts = (fullCustomer?.customer_products ?? + []) as FullCusProduct[]; + return cusProducts.some((cusProduct) => { + const activeOrTrialing = + ACTIVE_STATUSES.includes(cusProduct.status) || + cusProduct.status === CusProductStatus.Trialing; + if (!activeOrTrialing) return false; + if (!cusProduct.subscription_ids?.length) return false; + const entityMatches = entityId + ? cusProduct.entity_id === entityId || + cusProduct.internal_entity_id === entityId + : !cusProduct.internal_entity_id; + return entityMatches; + }); + }, [fullCustomer?.customer_products, entityId]); + + const immediatePlansPaidRecurring = useMemo(() => { + const plans = (formValues.phases[0]?.plans ?? []).filter( + (plan) => plan.productId, + ); + if (plans.length === 0) return false; + return plans.every((plan) => { + const product = products.find((p) => p.id === plan.productId); + if (!product) return false; + return ( + !isFreeProductV2({ items: product.items }) && + !isOneOffProductV2({ items: product.items }) + ); + }); + }, [formValues.phases, products]); + + const allowFirstPhaseBackdate = + !isExistingSchedule && + !hasActiveSubscription && + immediatePlansPaidRecurring; + const editingPlanValue = useMemo(() => { if (!editingPlan) return null; return ( @@ -161,6 +216,11 @@ export function CreateScheduleFormProvider({ [form.store], ); + const getAllowFirstPhaseBackdate = useCallback( + () => allowFirstPhaseBackdate, + [allowFirstPhaseBackdate], + ); + const buildRequestBody = useBuildCreateScheduleRequestBody({ customerId, entityId, @@ -171,6 +231,7 @@ export function CreateScheduleFormProvider({ getBillingBehavior, getResetBillingCycle, getEnablePlanImmediately, + getAllowFirstPhaseBackdate, }); const previewRequestBody = useCreateScheduleRequestBody({ @@ -182,8 +243,19 @@ export function CreateScheduleFormProvider({ nowMs, billingBehavior: formValues.billingBehavior, resetBillingCycle: formValues.resetBillingCycle, + allowFirstPhaseBackdate, }); + // When backdating is no longer allowed (e.g. plan changed to free, or scope + // switched to one with an active subscription), drop a stale past start so it + // can't leak into the request — the first phase falls back to "now". + useEffect(() => { + if (allowFirstPhaseBackdate || isExistingSchedule) return; + if (form.store.state.values.phases[0]?.startsAt != null) { + form.setFieldValue("phases[0].startsAt", null); + } + }, [allowFirstPhaseBackdate, isExistingSchedule, form]); + const phaseTimingError = useMemo( () => getCreateSchedulePhaseTimingError({ @@ -219,6 +291,7 @@ export function CreateScheduleFormProvider({ products, features, isExistingSchedule, + allowFirstPhaseBackdate, isPhaseLocked, handleAddPhase, handleInsertPhase, @@ -248,6 +321,7 @@ export function CreateScheduleFormProvider({ products, features, isExistingSchedule, + allowFirstPhaseBackdate, isPhaseLocked, handleAddPhase, handleInsertPhase, diff --git a/vite/src/components/forms/create-schedule/hooks/useCreateScheduleMutation.ts b/vite/src/components/forms/create-schedule/hooks/useCreateScheduleMutation.ts index 0f367a27a..fa5614ec9 100644 --- a/vite/src/components/forms/create-schedule/hooks/useCreateScheduleMutation.ts +++ b/vite/src/components/forms/create-schedule/hooks/useCreateScheduleMutation.ts @@ -18,6 +18,8 @@ export function useCreateScheduleMutation({ useInvoice?: boolean; enableProductImmediately?: boolean; finalizeInvoice?: boolean; + invoiceTemplateId?: string; + netTermsDays?: number; }) => CreateScheduleParamsV0 | null; onCheckoutRedirect?: (checkoutUrl: string) => void; onSuccess?: () => void; @@ -30,10 +32,14 @@ export function useCreateScheduleMutation({ useInvoice, enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, }: { useInvoice?: boolean; enableProductImmediately?: boolean; finalizeInvoice?: boolean; + invoiceTemplateId?: string; + netTermsDays?: number; }) => { if (!customerId) throw new Error("Customer ID is required"); @@ -41,6 +47,8 @@ export function useCreateScheduleMutation({ useInvoice, enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, }); if (!requestBody) throw new Error("Failed to build request body"); @@ -88,14 +96,20 @@ export function useCreateScheduleMutation({ const handleInvoiceSubmit = async ({ enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, }: { enableProductImmediately: boolean; finalizeInvoice: boolean; + invoiceTemplateId?: string; + netTermsDays?: number; }) => { const result = await mutation.mutateAsync({ useInvoice: true, enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, }); return { stripeId: result.data?.invoice?.stripe_id, diff --git a/vite/src/components/forms/create-schedule/hooks/useCreateScheduleRequestBody.ts b/vite/src/components/forms/create-schedule/hooks/useCreateScheduleRequestBody.ts index 9e6174594..9460b2406 100644 --- a/vite/src/components/forms/create-schedule/hooks/useCreateScheduleRequestBody.ts +++ b/vite/src/components/forms/create-schedule/hooks/useCreateScheduleRequestBody.ts @@ -116,6 +116,7 @@ export function buildCreateScheduleRequestBody({ nowMs, billingBehavior, resetBillingCycle, + allowFirstPhaseBackdate, }: { customerId: string | undefined; entityId: string | undefined; @@ -125,13 +126,22 @@ export function buildCreateScheduleRequestBody({ nowMs?: number; billingBehavior?: BillingBehavior | null; resetBillingCycle?: boolean; + allowFirstPhaseBackdate?: boolean; }): CreateScheduleParamsV0 | null { const now = nowMs ?? Date.now(); if (!customerId || phases.length === 0) return null; if (getCreateSchedulePhaseTimingError({ phases, nowMs: now })) return null; const apiPhases = phases.map((phase, index) => { - const startsAt = index === 0 ? now : phase.startsAt; + // The first phase starts immediately (now) unless backdating is allowed — + // only when a brand-new Stripe subscription will be created — in which case + // a past starts_at flows through to backdate that subscription. + const startsAt = + index === 0 + ? allowFirstPhaseBackdate + ? (phase.startsAt ?? now) + : now + : phase.startsAt; if (startsAt === null) return null; const plans = phase.plans @@ -193,6 +203,7 @@ export function useCreateScheduleRequestBody({ nowMs, billingBehavior, resetBillingCycle, + allowFirstPhaseBackdate, }: { customerId: string | undefined; entityId: string | undefined; @@ -202,6 +213,7 @@ export function useCreateScheduleRequestBody({ nowMs?: number; billingBehavior?: BillingBehavior | null; resetBillingCycle?: boolean; + allowFirstPhaseBackdate?: boolean; }) { return useMemo( () => @@ -214,6 +226,7 @@ export function useCreateScheduleRequestBody({ nowMs, billingBehavior, resetBillingCycle, + allowFirstPhaseBackdate, }), [ customerId, @@ -224,6 +237,7 @@ export function useCreateScheduleRequestBody({ nowMs, billingBehavior, resetBillingCycle, + allowFirstPhaseBackdate, ], ); } @@ -238,6 +252,7 @@ export function useBuildCreateScheduleRequestBody({ getBillingBehavior, getResetBillingCycle, getEnablePlanImmediately, + getAllowFirstPhaseBackdate, }: { customerId: string | undefined; entityId: string | undefined; @@ -248,6 +263,7 @@ export function useBuildCreateScheduleRequestBody({ getBillingBehavior?: () => BillingBehavior | null; getResetBillingCycle?: () => boolean; getEnablePlanImmediately?: () => boolean; + getAllowFirstPhaseBackdate?: () => boolean; }) { return useMemo( () => @@ -255,10 +271,14 @@ export function useBuildCreateScheduleRequestBody({ useInvoice, enableProductImmediately, finalizeInvoice, + invoiceTemplateId, + netTermsDays, }: { useInvoice?: boolean; enableProductImmediately?: boolean; finalizeInvoice?: boolean; + invoiceTemplateId?: string; + netTermsDays?: number; } = {}): CreateScheduleParamsV0 | null => { const requestBody = buildCreateScheduleRequestBody({ customerId, @@ -269,6 +289,7 @@ export function useBuildCreateScheduleRequestBody({ nowMs, billingBehavior: getBillingBehavior?.() ?? null, resetBillingCycle: getResetBillingCycle?.() ?? false, + allowFirstPhaseBackdate: getAllowFirstPhaseBackdate?.() ?? false, }); if (!requestBody) return null; @@ -280,6 +301,12 @@ export function useBuildCreateScheduleRequestBody({ enabled: true, enable_plan_immediately: enableProductImmediately ?? true, finalize: finalizeInvoice ?? true, + ...(invoiceTemplateId !== undefined + ? { invoice_template_id: invoiceTemplateId } + : {}), + ...(netTermsDays !== undefined + ? { net_terms_days: netTermsDays } + : {}), }, }; } @@ -307,6 +334,7 @@ export function useBuildCreateScheduleRequestBody({ getBillingBehavior, getResetBillingCycle, getEnablePlanImmediately, + getAllowFirstPhaseBackdate, ], ); } diff --git a/vite/src/components/forms/create-schedule/hooks/useHasSchedule.ts b/vite/src/components/forms/create-schedule/hooks/useHasSchedule.ts index c4c204d79..6fb79d6c9 100644 --- a/vite/src/components/forms/create-schedule/hooks/useHasSchedule.ts +++ b/vite/src/components/forms/create-schedule/hooks/useHasSchedule.ts @@ -1,6 +1,14 @@ import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; -export function useHasSchedule() { +export function useHasSchedule({ + entityId, +}: { entityId?: string | null } = {}) { const { schedule, customer } = useCusQuery({ schedule: true }); - return !!schedule || !!customer?.entities?.some((entity) => entity.schedule); + if (entityId) { + const entity = customer?.entities?.find( + (e) => e.id === entityId || e.internal_id === entityId, + ); + return !!entity?.schedule; + } + return !!schedule; } diff --git a/vite/src/components/forms/shared/InvoiceSettingsSection.tsx b/vite/src/components/forms/shared/InvoiceSettingsSection.tsx new file mode 100644 index 000000000..2562dbf3e --- /dev/null +++ b/vite/src/components/forms/shared/InvoiceSettingsSection.tsx @@ -0,0 +1,93 @@ +import type { InvoiceTemplate } from "@autumn/shared"; +import { InfoIcon } from "lucide-react"; +import { Input } from "@/components/v2/inputs/Input"; +import { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; +import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/v2/tooltips/Tooltip"; +import { useInvoiceTemplatesQuery } from "@/hooks/queries/useInvoiceTemplatesQuery"; +import { cn } from "@/lib/utils"; + +export const DEFAULT_NET_TERMS_DAYS = 30; +const NO_TEMPLATE_VALUE = "none"; + +export interface InvoiceSettings { + templateId: string | null; + netTermsDays: number; +} + +export function InvoiceSettingsSection({ + value, + onChange, + disabled, +}: { + value: InvoiceSettings; + onChange: (value: InvoiceSettings) => void; + disabled?: boolean; +}) { + const { templates } = useInvoiceTemplatesQuery(); + if (templates.length === 0) return null; + const options: Pick[] = [ + { id: NO_TEMPLATE_VALUE, name: "None" }, + ...templates, + ]; + return ( + +
+
+ Template + { + const templateId = next === NO_TEMPLATE_VALUE ? null : next; + const template = templates.find((t) => t.id === templateId); + onChange({ + templateId, + netTermsDays: template?.net_terms_days ?? value.netTermsDays, + }); + }} + options={options} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + placeholder="Select a template" + emptyText="No templates configured" + /> +
+
+
+ + Net payment terms (days) + + + + + + + How long the customer has to pay before the invoice is due. + + +
+ { + const parsed = Number.parseInt(e.target.value, 10); + onChange({ + ...value, + netTermsDays: Number.isNaN(parsed) ? 0 : parsed, + }); + }} + /> +
+
+
+ ); +} diff --git a/vite/src/components/forms/shared/PlanItemsSection.tsx b/vite/src/components/forms/shared/PlanItemsSection.tsx index 7271307ae..76f326053 100644 --- a/vite/src/components/forms/shared/PlanItemsSection.tsx +++ b/vite/src/components/forms/shared/PlanItemsSection.tsx @@ -9,13 +9,18 @@ import { PencilSimpleIcon } from "@phosphor-icons/react"; import { LayoutGroup, motion } from "motion/react"; import { useMemo } from "react"; import type { UseAttachForm } from "@/components/forms/attach-v2/hooks/useAttachForm"; +import type { AdminPlanIds } from "@/components/forms/shared/admin/AdminPlanIdsTooltip"; import type { UseUpdateSubscriptionForm } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm"; import { Button } from "@/components/v2/buttons/Button"; import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents"; import { CollapsedBooleanItems } from "./plan-items/CollapsedBooleanItems"; import { DeletedItemRow } from "./plan-items/DeletedItemRow"; import { PlanEditButton } from "./plan-items/PlanEditButton"; -import { PlanItemRow } from "./plan-items/PlanItemRow"; +import { + getItemMatchKey, + hasItemChanged, + PlanItemRow, +} from "./plan-items/PlanItemRow"; import { PlanPriceHeader } from "./plan-items/PlanPriceHeader"; import { PlanTrialEditor, @@ -45,7 +50,7 @@ export interface PlanItemsSectionProps { initialPrepaidOptions: Record; existingOptions?: FeatureOptions[]; - form: UseUpdateSubscriptionForm | UseAttachForm; + form?: UseUpdateSubscriptionForm | UseAttachForm; showDiff: boolean; currency: string; @@ -57,11 +62,79 @@ export interface PlanItemsSectionProps { trialConfig?: TrialConfig; gateDeletedItemsByDiff?: boolean; + changesOnly?: boolean; readOnly?: boolean; - adminIds?: import( - "@/components/forms/shared/admin/AdminPlanIdsTooltip" - ).AdminPlanIds; + adminIds?: AdminPlanIds; +} + +export function getPlanItemsDiff({ + product, + originalItems, + showDiff, + gateDeletedItemsByDiff = false, +}: { + product: FrontendProduct | undefined; + originalItems: ProductItem[] | undefined; + showDiff: boolean; + gateDeletedItemsByDiff?: boolean; +}) { + const originalItemsMap = new Map( + originalItems + ?.filter((i) => i.feature_id) + .map((i) => [getItemMatchKey(i), i]) ?? [], + ); + + const currentItemKeys = new Set( + product?.items + ?.filter((i) => i.feature_id) + .map((i) => getItemMatchKey(i)) ?? [], + ); + + const changedOriginals: ProductItem[] = []; + if (showDiff) { + for (const item of product?.items ?? []) { + if (!item.feature_id) continue; + const key = getItemMatchKey(item); + const originalItem = originalItemsMap.get(key); + if (originalItem && hasItemChanged({ originalItem, updatedItem: item })) { + changedOriginals.push(originalItem); + originalItemsMap.delete(key); + } + } + } + + const isItemDeleted = (i: ProductItem) => + !!i.feature_id && !currentItemKeys.has(getItemMatchKey(i)); + + const purelyDeletedItems = gateDeletedItemsByDiff + ? showDiff && originalItems + ? originalItems.filter(isItemDeleted) + : [] + : (originalItems?.filter(isItemDeleted) ?? []); + + const deletedItems = [...changedOriginals, ...purelyDeletedItems]; + const sortedItems = sortPlanItems({ items: product?.items ?? [] }); + const { visibleItems, collapsedBooleanItems } = splitBooleanItems({ + items: sortedItems, + }); + const isItemNew = (item: ProductItem) => + !originalItemsMap.has(getItemMatchKey(item)); + const diffVisibleItems = visibleItems.filter(isItemNew); + const diffCollapsedBooleanItems = collapsedBooleanItems.filter(isItemNew); + + return { + originalItemsMap, + deletedItems, + visibleItems, + collapsedBooleanItems, + diffVisibleItems, + diffCollapsedBooleanItems, + hasDiffItems: + diffVisibleItems.length > 0 || + diffCollapsedBooleanItems.length > 0 || + deletedItems.length > 0, + }; } export function PlanItemsSection({ @@ -79,37 +152,31 @@ export function PlanItemsSection({ versionChange, trialConfig, gateDeletedItemsByDiff = false, + changesOnly = false, readOnly = false, adminIds, }: PlanItemsSectionProps) { - const originalItemsMap = new Map( - originalItems - ?.filter((i) => i.feature_id) - .map((i) => [`${i.feature_id}:${i.usage_model ?? ""}`, i]) ?? [], - ); - - const currentFeatureIds = new Set( - product?.items?.map((i) => i.feature_id).filter(Boolean) ?? [], - ); - - const deletedItems = gateDeletedItemsByDiff - ? showDiff && originalItems - ? originalItems.filter( - (i) => i.feature_id && !currentFeatureIds.has(i.feature_id), - ) - : [] - : (originalItems?.filter( - (i) => i.feature_id && !currentFeatureIds.has(i.feature_id), - ) ?? []); - - const sortedItems = useMemo( - () => sortPlanItems({ items: product?.items ?? [] }), - [product?.items], - ); - const { visibleItems, collapsedBooleanItems } = useMemo( - () => splitBooleanItems({ items: sortedItems }), - [sortedItems], + const { + originalItemsMap, + deletedItems, + visibleItems: allVisibleItems, + collapsedBooleanItems: allCollapsedBooleanItems, + diffVisibleItems, + diffCollapsedBooleanItems, + } = useMemo( + () => + getPlanItemsDiff({ + product, + originalItems, + showDiff, + gateDeletedItemsByDiff, + }), + [product, originalItems, showDiff, gateDeletedItemsByDiff], ); + const visibleItems = changesOnly ? diffVisibleItems : allVisibleItems; + const collapsedBooleanItems = changesOnly + ? diffCollapsedBooleanItems + : allCollapsedBooleanItems; const hasItems = (product?.items?.length ?? 0) > 0 || deletedItems.length > 0; @@ -147,7 +214,7 @@ export function PlanItemsSection({ /> @@ -162,6 +229,7 @@ export function PlanItemsSection({ {collapsedBooleanItems.length > 0 && ( ( ({ + templateId: null, + netTermsDays: DEFAULT_NET_TERMS_DAYS, + }); const [completedInvoiceUrl, setCompletedInvoiceUrl] = useState( null, ); @@ -180,13 +186,22 @@ export function SendInvoiceStage({ } }, [axiosInstance, customerId, emailValue, refetch]); + const buildSubmitParams = ( + finalizeInvoice: boolean, + ): SendInvoiceSubmitParams => ({ + enableProductImmediately: enableImmediately, + finalizeInvoice, + invoiceTemplateId: invoiceSettings.templateId ?? undefined, + netTermsDays: + invoiceSettings.netTermsDays > 0 + ? invoiceSettings.netTermsDays + : undefined, + }); + const handleDraft = async () => { setActiveAction("draft"); try { - const { stripeId } = await onSubmit({ - enableProductImmediately: enableImmediately, - finalizeInvoice: false, - }); + const { stripeId } = await onSubmit(buildSubmitParams(false)); if (stripeId) { const invoiceUrl = getInvoiceUrl(stripeId); window.open(invoiceUrl, "_blank"); @@ -200,10 +215,9 @@ export function SendInvoiceStage({ const handleFinalize = async () => { setActiveAction("finalize"); try { - const { hostedInvoiceUrl, stripeId } = await onSubmit({ - enableProductImmediately: enableImmediately, - finalizeInvoice: true, - }); + const { hostedInvoiceUrl, stripeId } = await onSubmit( + buildSubmitParams(true), + ); if (hostedInvoiceUrl) { setCompletedInvoiceUrl(hostedInvoiceUrl); } else if (stripeId) { @@ -304,6 +318,12 @@ export function SendInvoiceStage({ scheduledStartDate={scheduledStartDate} /> + + diff --git a/vite/src/components/forms/shared/plan-items/CollapsedBooleanItems.tsx b/vite/src/components/forms/shared/plan-items/CollapsedBooleanItems.tsx index c3607b62d..d3dd92095 100644 --- a/vite/src/components/forms/shared/plan-items/CollapsedBooleanItems.tsx +++ b/vite/src/components/forms/shared/plan-items/CollapsedBooleanItems.tsx @@ -7,17 +7,20 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents"; +import { cn } from "@/lib/utils"; import { getItemId } from "@/utils/product/productItemUtils"; import { motion } from "motion/react"; interface CollapsedBooleanItemsProps { items: ProductItem[]; renderItem: (item: ProductItem, index: number) => ReactNode; + triggerClassName?: string; } export function CollapsedBooleanItems({ items, renderItem, + triggerClassName, }: CollapsedBooleanItemsProps) { const [value, setValue] = useState([]); @@ -35,7 +38,12 @@ export function CollapsedBooleanItems({ className="w-full" > - + {label} boolean flag{items.length === 1 ? "" : "s"} diff --git a/vite/src/components/forms/shared/plan-items/PlanEditButton.tsx b/vite/src/components/forms/shared/plan-items/PlanEditButton.tsx index c27b4eedb..5de6e00f0 100644 --- a/vite/src/components/forms/shared/plan-items/PlanEditButton.tsx +++ b/vite/src/components/forms/shared/plan-items/PlanEditButton.tsx @@ -5,7 +5,11 @@ import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents" export function PlanEditButton({ onEditPlan }: { onEditPlan: () => void }) { return ( - + @@ -183,9 +205,12 @@ export const DateInputUnix = ({ mode="single" selected={dateObj} onSelect={handleDaySelect} - disabled={disablePastDates && minDay ? { before: minDay } : undefined} + disabled={[ + ...(disablePastDates && minDay ? [{ before: minDay }] : []), + ...(disableFutureDates && maxDay ? [{ after: maxDay }] : []), + ]} captionLayout="dropdown-buttons" - fromYear={new Date().getFullYear()} + fromYear={fromYear ?? new Date().getFullYear()} toYear={new Date().getFullYear() + 10} /> {withTime && ( diff --git a/vite/src/components/general/PageHeader.tsx b/vite/src/components/general/PageHeader.tsx new file mode 100644 index 000000000..49ef29945 --- /dev/null +++ b/vite/src/components/general/PageHeader.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +/** + * Shared page header: leading icon + title on the left, optional actions on + * the right. Matches Table.Toolbar + Table.Heading styles. + */ +export function PageHeader({ + icon, + title, + children, +}: { + icon: ReactNode; + title: string; + children?: ReactNode; +}) { + return ( +
+
+
+ {icon} + {title} +
+
{children}
+
+
+ ); +} diff --git a/vite/src/components/general/table/table-body-virtualized.tsx b/vite/src/components/general/table/table-body-virtualized.tsx index 3b0457c89..525c9b55c 100644 --- a/vite/src/components/general/table/table-body-virtualized.tsx +++ b/vite/src/components/general/table/table-body-virtualized.tsx @@ -50,7 +50,7 @@ const VirtualRowInner = ({ data-state={row.getIsSelected() && "selected"} data-index={virtualRow.index} className={cn( - "text-tertiary-foreground transition-none h-10 relative border-b", + "text-tertiary-foreground transition-none h-10 relative border-b last:border-b-0", rowClassName, isSelected ? "z-100" : "hover:bg-interactive-secondary-hover", (onRowClick || rowHref) && "cursor-pointer", diff --git a/vite/src/components/general/table/table-content.tsx b/vite/src/components/general/table/table-content.tsx index 86b442d48..5848ca73f 100644 --- a/vite/src/components/general/table/table-content.tsx +++ b/vite/src/components/general/table/table-content.tsx @@ -17,8 +17,8 @@ export function TableContent({ return (
diff --git a/vite/src/components/general/table/table-header.tsx b/vite/src/components/general/table/table-header.tsx index b7111cc41..8273676b0 100644 --- a/vite/src/components/general/table/table-header.tsx +++ b/vite/src/components/general/table/table-header.tsx @@ -2,9 +2,9 @@ import { flexRender, type HeaderGroup } from "@tanstack/react-table"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { Checkbox } from "@/components/v2/checkboxes/Checkbox"; import { - TableHeader as ShadcnTableHeader, - TableHead, - TableRow, + TableHeader as ShadcnTableHeader, + TableHead, + TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import { useTableContext } from "./table-context"; diff --git a/vite/src/components/v2/badges/BetaBadge.tsx b/vite/src/components/v2/badges/BetaBadge.tsx new file mode 100644 index 000000000..0de91327c --- /dev/null +++ b/vite/src/components/v2/badges/BetaBadge.tsx @@ -0,0 +1,15 @@ +import { Badge } from "./Badge"; +import { cn } from "@/lib/utils"; + +export function BetaBadge({ className }: { className?: string }) { + return ( + + BETA + + ); +} diff --git a/vite/src/components/v2/buttons/CopyButton.tsx b/vite/src/components/v2/buttons/CopyButton.tsx index 90c02725d..5cc89f6c6 100644 --- a/vite/src/components/v2/buttons/CopyButton.tsx +++ b/vite/src/components/v2/buttons/CopyButton.tsx @@ -74,6 +74,8 @@ interface CopyButtonProps extends IconButtonProps { text: string; iconOrientation?: "left" | "right"; innerClassName?: string; + /** Classes applied to the copy icon button (MiniCopyButton only). Overrides the default hover-reveal. */ + iconClassName?: string; } export const CopyButton = ({ @@ -123,6 +125,7 @@ export const MiniCopyButton = ({ side = "right", innerClassName = "", iconOrientation = "right", + iconClassName, children, ...props }: CopyButtonProps) => { @@ -141,6 +144,7 @@ export const MiniCopyButton = ({ className={cn( "opacity-0 group-hover:opacity-100 cursor-pointer px-0!", copied && "opacity-100", + iconClassName, )} /> diff --git a/vite/src/components/v2/dialogs/Dialog.tsx b/vite/src/components/v2/dialogs/Dialog.tsx index 3b0d7c9b1..5ebd9bc31 100644 --- a/vite/src/components/v2/dialogs/Dialog.tsx +++ b/vite/src/components/v2/dialogs/Dialog.tsx @@ -88,7 +88,7 @@ const DialogContent = React.forwardRef< ref={ref} data-slot="dialog-content" className={cn( - "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[40%] left-[50%] z-[180] grid translate-x-[-50%] translate-y-[-50%] rounded-lg shadow-lg ring-1 ring-foreground/10 duration-200", + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[50%] left-[50%] z-[180] grid translate-x-[-50%] translate-y-[-50%] rounded-lg shadow-lg ring-1 ring-foreground/10 duration-200", "w-full max-w-md gap-3 bg-background", "p-4", className, diff --git a/vite/src/components/v2/icons/AutumnIcons.tsx b/vite/src/components/v2/icons/AutumnIcons.tsx index 41484efb1..0a1418531 100644 --- a/vite/src/components/v2/icons/AutumnIcons.tsx +++ b/vite/src/components/v2/icons/AutumnIcons.tsx @@ -365,6 +365,28 @@ const FreeTrialIcon = ({ ); }; +export const StripeIcon = ({ + size = 16, + className, +}: { + size?: number; + className?: string; +}) => { + return ( + + + + ); +}; + export const RevenueCatIcon = ({ size = 32, color = "currentColor", diff --git a/vite/src/components/v2/inline-custom-plan-editor/InlineEditorContext.tsx b/vite/src/components/v2/inline-custom-plan-editor/InlineEditorContext.tsx index 3aa455f6e..4900b45c0 100644 --- a/vite/src/components/v2/inline-custom-plan-editor/InlineEditorContext.tsx +++ b/vite/src/components/v2/inline-custom-plan-editor/InlineEditorContext.tsx @@ -101,6 +101,7 @@ export function InlineEditorProvider({ initialItem={initialItem} setSheet={setSheet} setInitialItem={setInitialItem} + updateItemId={setItemId} closeSheet={closeSheet} itemDraft={itemDraft} > diff --git a/vite/src/components/v2/inline-custom-plan-editor/InlinePlanEditor.tsx b/vite/src/components/v2/inline-custom-plan-editor/InlinePlanEditor.tsx index cea22b142..58c599a7c 100644 --- a/vite/src/components/v2/inline-custom-plan-editor/InlinePlanEditor.tsx +++ b/vite/src/components/v2/inline-custom-plan-editor/InlinePlanEditor.tsx @@ -111,9 +111,10 @@ function InlinePlanEditorContent({ )}
-
+ +
diff --git a/vite/src/components/v2/inline-custom-plan-editor/PlanEditorContext.tsx b/vite/src/components/v2/inline-custom-plan-editor/PlanEditorContext.tsx index 9e0a9a0aa..054644c37 100644 --- a/vite/src/components/v2/inline-custom-plan-editor/PlanEditorContext.tsx +++ b/vite/src/components/v2/inline-custom-plan-editor/PlanEditorContext.tsx @@ -33,6 +33,7 @@ interface ProductContextValue { initialItem: ProductItem | null; setSheet: (params: { type: string | null; itemId?: string | null }) => void; setInitialItem: (item: ProductItem | null) => void; + updateItemId: (itemId: string) => void; closeSheet: () => void; itemDraft: ItemDraftController; } @@ -54,6 +55,7 @@ export function ProductProvider({ initialItem, setSheet, setInitialItem, + updateItemId, closeSheet, itemDraft, }: { @@ -68,6 +70,7 @@ export function ProductProvider({ initialItem: ProductItem | null; setSheet: (params: { type: string | null; itemId?: string | null }) => void; setInitialItem: (item: ProductItem | null) => void; + updateItemId: (itemId: string) => void; closeSheet: () => void; itemDraft: ItemDraftController; }) { @@ -82,6 +85,7 @@ export function ProductProvider({ initialItem, setSheet, setInitialItem, + updateItemId, closeSheet, itemDraft, }} @@ -122,6 +126,8 @@ export function useSheet() { const storeSetInitialItem = useSheetStore((s) => s.setInitialItem); const storeCloseSheet = useSheetStore((s) => s.closeSheet); + const storeUpdateItemId = useSheetStore((s) => s.updateItemId); + if (context) { return { sheetType: context.sheetType, @@ -129,6 +135,7 @@ export function useSheet() { initialItem: context.initialItem, setSheet: context.setSheet, setInitialItem: context.setInitialItem, + updateItemId: context.updateItemId, closeSheet: context.closeSheet, itemDraft: context.itemDraft, }; @@ -140,6 +147,7 @@ export function useSheet() { initialItem: storeInitialItem, setSheet: storeSetSheet, setInitialItem: storeSetInitialItem, + updateItemId: storeUpdateItemId, closeSheet: storeCloseSheet, itemDraft: disabledItemDraftController, }; @@ -180,9 +188,9 @@ export function useCurrentItem() { } /** Hook to set the current item being edited. Uses context if available, otherwise Zustand. */ -function useSetCurrentItem() { +export function useSetCurrentItem() { const { product, setProduct } = useProduct(); - const { itemId, itemDraft } = useSheet(); + const { itemId, itemDraft, updateItemId } = useSheet(); return useCallback( (updatedItem: ProductItem) => { @@ -207,11 +215,19 @@ function useSetCurrentItem() { if (originalIndex === -1) return; + const newItemId = getItemId({ + item: updatedItem, + itemIndex: originalIndex, + }); + if (newItemId !== itemId) { + updateItemId(newItemId); + } + const updatedItems = [...product.items]; updatedItems[originalIndex] = updatedItem; setProduct({ ...product, items: updatedItems }); }, - [itemDraft, itemId, product, setProduct], + [itemDraft, itemId, product, setProduct, updateItemId], ); } diff --git a/vite/src/components/v2/selects/RoleSelect.tsx b/vite/src/components/v2/selects/RoleSelect.tsx index d8503614a..e21aa5fac 100644 --- a/vite/src/components/v2/selects/RoleSelect.tsx +++ b/vite/src/components/v2/selects/RoleSelect.tsx @@ -23,17 +23,17 @@ const ROLE_META: Record = { owner: { label: "Owner", description: - "Write on everything (organisation, customers, features, plans, rewards, balances, billing, API keys, platform) + read analytics. Can delete the org and manage ownership.", + "Write on everything (organisation, customers, features, plans, rewards, balances, billing, migrations, API keys, platform) + read analytics. Can delete the org and manage ownership.", }, admin: { label: "Admin", description: - "Write on everything (organisation, customers, features, plans, rewards, balances, billing, API keys, platform) + read analytics. Cannot delete the org or transfer ownership.", + "Write on everything (organisation, customers, features, plans, rewards, balances, billing, migrations, API keys, platform) + read analytics. Cannot delete the org or transfer ownership.", }, developer: { label: "Developer", description: - "Write on customers, features, plans, rewards, balances, billing, API keys, and platform. Read organisation and analytics.", + "Write on customers, features, plans, rewards, balances, billing, migrations, API keys, and platform. Read organisation and analytics.", }, sales: { label: "Sales", @@ -43,7 +43,7 @@ const ROLE_META: Record = { member: { label: "Member", description: - "Read-only on everything: organisation, customers, features, plans, rewards, balances, billing, analytics, API keys, platform. No write access.", + "Read-only on organisation, customers, features, plans, rewards, balances, billing, analytics, API keys, and platform. No migrations access.", }, }; diff --git a/vite/src/components/v2/sheets/InlineSheetPanel.tsx b/vite/src/components/v2/sheets/InlineSheetPanel.tsx new file mode 100644 index 000000000..22eb52ed9 --- /dev/null +++ b/vite/src/components/v2/sheets/InlineSheetPanel.tsx @@ -0,0 +1,73 @@ +import { AnimatePresence, motion } from "motion/react"; +import type { ReactNode } from "react"; +import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; +import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { cn } from "@/lib/utils"; + +const SHEET_PANEL_WIDTH = "28rem"; +const SHEET_PANEL_Z_INDEX = 100; +const SHEET_PANEL_TRANSITION = { + duration: 0.3, + ease: [0.32, 0.72, 0, 1] as const, +} as const; + +interface InlineSheetPanelProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + className?: string; + width?: string; + zIndex?: number; + transition?: { + duration: number; + ease: readonly [number, number, number, number]; + }; +} + +/** + * Shared right-hand sheet panel used across the app's inline sheet orchestrators. + * Renders a slide-in, rounded, inset panel that floats over the (separately + * rendered) backdrop so the surrounding area reads as dimmed on every side. + */ +export function InlineSheetPanel({ + isOpen, + onClose, + children, + className, + width = SHEET_PANEL_WIDTH, + zIndex = SHEET_PANEL_Z_INDEX, + transition = SHEET_PANEL_TRANSITION, +}: InlineSheetPanelProps) { + const isMobile = useIsMobile(); + return ( + + {isOpen && ( + + + + {children} + + + )} + + ); +} diff --git a/vite/src/components/v2/sheets/Sheet.tsx b/vite/src/components/v2/sheets/Sheet.tsx index 6784a688e..473d4c129 100644 --- a/vite/src/components/v2/sheets/Sheet.tsx +++ b/vite/src/components/v2/sheets/Sheet.tsx @@ -69,10 +69,7 @@ function SheetPortal({ ); } -function SheetOverlay({ - className, - ...props -}: SheetPrimitive.Backdrop.Props) { +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { return ( ) { ); } -function SheetTitle({ - className, - ...props -}: SheetPrimitive.Title.Props) { +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { return ( void; + zIndex?: number; +} + +/** + * Full-viewport dimming backdrop for inline sheets, portaled to the document body + * so it covers everything behind the floating sheet panel uniformly. + */ +export function SheetBackdrop({ + isOpen, + onClose, + zIndex = SHEET_BACKDROP_Z_INDEX, +}: SheetBackdropProps) { + return createPortal( + + {isOpen && ( + + )} + , + document.body, + ); +} diff --git a/vite/src/hooks/common/useAppQueryStates.tsx b/vite/src/hooks/common/useAppQueryStates.tsx index 4a17fb18a..f49bb4045 100644 --- a/vite/src/hooks/common/useAppQueryStates.tsx +++ b/vite/src/hooks/common/useAppQueryStates.tsx @@ -8,7 +8,6 @@ type SecondaryTabType = | "stripe" | "vercel" | "revenuecat" - | "redis" | "products" | "rewards" | "features" diff --git a/vite/src/hooks/queries/revcat/useRCPreflight.tsx b/vite/src/hooks/queries/revcat/useRCPreflight.tsx new file mode 100644 index 000000000..c7c60b393 --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCPreflight.tsx @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export interface RCPreflightPrice { + amount_micros: number; + currency: string; +} + +export interface RCPreflightItem { + plan_id: string; + autumn_name: string; + autumn_price: RCPreflightPrice | null; + rc_exists: boolean; + rc_name: string | null; + rc_price: RCPreflightPrice | null; +} + +export const useRCPreflight = ({ enabled = true }: { enabled?: boolean } = {}) => { + const axiosInstance = useAxiosInstance(); + const buildKey = useQueryKeyFactory(); + + const fetcher = async () => { + try { + const { data }: { data: { items: RCPreflightItem[] } } = + await axiosInstance.post("/v1/organization/revenuecat/preflight"); + return data.items || []; + } catch (_error) { + return []; + } + }; + + const { + data: items = [], + isLoading, + error, + refetch, + } = useQuery({ + queryKey: buildKey(["revenuecat-preflight"]), + queryFn: fetcher, + enabled, + }); + + return { items, isLoading, error, refetch }; +}; diff --git a/vite/src/hooks/queries/revcat/useRCProjects.tsx b/vite/src/hooks/queries/revcat/useRCProjects.tsx new file mode 100644 index 000000000..9accebb3a --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCProjects.tsx @@ -0,0 +1,63 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +interface RevenueCatProject { + id: string; + name: string; +} + +interface RevenueCatProjectsResponse { + projects: RevenueCatProject[]; +} + +export const useRCProjects = ({ enabled = true }: { enabled?: boolean } = {}) => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const buildKey = useQueryKeyFactory(); + + const queryKey = buildKey(["revenuecat-projects"]); + + const fetcher = async () => { + try { + const { data }: { data: RevenueCatProjectsResponse } = + await axiosInstance.get("/v1/organization/revenuecat/projects"); + return data.projects || []; + } catch (_error) { + return []; + } + }; + + const { + data: projects = [], + isLoading, + error, + refetch, + } = useQuery({ + queryKey, + queryFn: fetcher, + enabled, + }); + + const createMutation = useMutation({ + mutationFn: async (name: string) => { + const { data } = await axiosInstance.post( + "/v1/organization/revenuecat/projects", + { name }, + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + return { + projects, + isLoading, + error, + refetch, + createProject: createMutation.mutateAsync, + isCreating: createMutation.isPending, + }; +}; diff --git a/vite/src/hooks/queries/revcat/useRCSync.tsx b/vite/src/hooks/queries/revcat/useRCSync.tsx new file mode 100644 index 000000000..167f227d5 --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCSync.tsx @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export interface RCSyncAppResult { + app_id: string; + app_type: string; + product: "created" | "updated" | "exists"; + store_push?: "pushed" | "failed" | "skipped"; + message?: string; +} + +export interface RCSyncResult { + plan_id: string; + status: "synced" | "skipped" | "error"; + store_identifier?: string; + apps?: RCSyncAppResult[]; + message?: string; +} + +export const useRCSync = () => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const buildKey = useQueryKeyFactory(); + + const mutation = useMutation({ + mutationFn: async (productIds: string[]) => { + const { data } = await axiosInstance.post<{ results: RCSyncResult[] }>( + "/v1/organization/revenuecat/sync", + { product_ids: productIds }, + ); + return data.results; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: buildKey(["revenuecat-mappings"]), + }); + }, + }); + + return { sync: mutation.mutateAsync, isSyncing: mutation.isPending }; +}; diff --git a/vite/src/hooks/queries/revcat/useRCWebhook.tsx b/vite/src/hooks/queries/revcat/useRCWebhook.tsx new file mode 100644 index 000000000..720e84a64 --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCWebhook.tsx @@ -0,0 +1,61 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +export type RCWebhookStatus = "registered" | "not_registered" | "unknown"; + +interface RCWebhookResponse { + status: RCWebhookStatus; + url: string | null; + secret: string | null; +} + +export const useRCWebhook = () => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const buildKey = useQueryKeyFactory(); + const queryKey = buildKey(["revenuecat-webhook"]); + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + const { data } = await axiosInstance.get( + "/v1/organization/revenuecat/webhook", + ); + return data; + }, + }); + + const registerMutation = useMutation({ + mutationFn: async () => { + const { data } = await axiosInstance.post( + "/v1/organization/revenuecat/webhook", + ); + return data; + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey }); + if (result.status === "registered") { + toast.success("Webhook registered with RevenueCat"); + } else { + toast.warning("Couldn't register automatically — set it up manually below"); + } + }, + onError: (error) => { + toast.error( + getBackendErr(error, "Couldn't register automatically — set it up manually below"), + ); + }, + }); + + return { + status: data?.status ?? "unknown", + url: data?.url ?? null, + secret: data?.secret ?? null, + isLoading, + register: registerMutation.mutateAsync, + isRegistering: registerMutation.isPending, + }; +}; diff --git a/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx b/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx index 56eee55eb..3abbff731 100644 --- a/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx +++ b/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx @@ -4,6 +4,8 @@ import { useAxiosInstance } from "@/services/useAxiosInstance"; interface RevenueCatConfig { connected: boolean; + connection?: "oauth" | "api_key" | "none"; + oauth_connected?: boolean; api_key?: string; sandbox_api_key?: string; project_id?: string; diff --git a/vite/src/hooks/queries/useAgentRulesQuery.tsx b/vite/src/hooks/queries/useAgentRulesQuery.tsx new file mode 100644 index 000000000..a9bf43c6c --- /dev/null +++ b/vite/src/hooks/queries/useAgentRulesQuery.tsx @@ -0,0 +1,64 @@ +import type { AgentRules } from "@autumn/shared"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export interface AgentRulesResponse extends AgentRules { + metadata: Record; + org_id: string; + org_slug: string | null; + updated_at: number | null; +} + +export const useAgentRulesQuery = () => { + const axiosInstance = useAxiosInstance(); + const buildKey = useQueryKeyFactory(); + const queryClient = useQueryClient(); + const queryKey = buildKey(["agent-rules"]); + + const { data, isLoading, error } = useQuery({ + queryKey, + queryFn: async () => { + const { data } = await axiosInstance.post( + "/v1/agent.get_rules", + ); + return data; + }, + }); + + const generate = useMutation({ + mutationFn: async () => { + const { data } = await axiosInstance.post( + "/v1/agent.generate_rules", + {}, + ); + return data; + }, + onSuccess: (rules) => { + queryClient.setQueryData(queryKey, rules); + }, + }); + + const update = useMutation({ + mutationFn: async (updates: AgentRules) => { + const { data } = await axiosInstance.post( + "/v1/agent.update_rules", + updates, + ); + return data; + }, + onSuccess: (rules) => { + queryClient.setQueryData(queryKey, rules); + }, + }); + + return { + rules: data, + isLoading, + error, + generate: generate.mutateAsync, + isGenerating: generate.isPending, + update: update.mutateAsync, + isUpdating: update.isPending, + }; +}; diff --git a/vite/src/hooks/queries/useInvoiceTemplatesQuery.tsx b/vite/src/hooks/queries/useInvoiceTemplatesQuery.tsx new file mode 100644 index 000000000..31536356d --- /dev/null +++ b/vite/src/hooks/queries/useInvoiceTemplatesQuery.tsx @@ -0,0 +1,24 @@ +import type { InvoiceTemplate } from "@autumn/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useOrg } from "@/hooks/common/useOrg"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export const useInvoiceTemplatesQuery = () => { + const axiosInstance = useAxiosInstance(); + const { org } = useOrg(); + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["invoice-templates", org?.id], + queryFn: async () => { + const { data } = await axiosInstance.get<{ + templates: InvoiceTemplate[]; + }>("/invoice_templates"); + return data; + }, + }); + return { + templates: data?.templates ?? [], + isLoading, + error, + refetch, + }; +}; diff --git a/vite/src/hooks/queries/useMigrationFilterPreview.ts b/vite/src/hooks/queries/useMigrationFilterPreview.ts index 6c0a85224..48c88d050 100644 --- a/vite/src/hooks/queries/useMigrationFilterPreview.ts +++ b/vite/src/hooks/queries/useMigrationFilterPreview.ts @@ -1,50 +1,146 @@ -import type { CustomerFilter, CustomerWithProducts } from "@autumn/shared"; +import type { + CustomerFilter, + CustomerWithProducts, + MigrationItemRun, +} from "@autumn/shared"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { ACTIVE_POLL_MS } from "@/hooks/queries/useMigrationRunsQuery"; import { useAxiosInstance } from "@/services/useAxiosInstance"; - -const DEFAULT_PAGE_SIZE = 10; +import { DEFAULT_CUSTOMER_LIST_PAGE_SIZE } from "@/utils/constants/customerListPagination"; +import type { ExecutionStatus } from "@/views/migrations/migration/live/ExecutionStatusSubMenu"; interface FilterPreviewResponse { - count: number; - customers: CustomerWithProducts[]; - page: number; - pageSize: number; + count: number | null; + customers: MigrationPreviewCustomer[]; + next_cursor: string | null; } +type FilterPreviewRows = FilterPreviewResponse & { + cursor: string; +}; + +export type MigrationPreviewCustomer = CustomerWithProducts & { + migration_item_run?: MigrationItemRun | null; +}; + +type CustomerListFilters = { + status?: string[]; + version?: string[]; + none?: boolean; + processor?: string[]; +}; + export const useMigrationFilterPreview = ({ filter, search = "", - page = 0, - pageSize = DEFAULT_PAGE_SIZE, + customerFilters, + cursor = "", + pageSize = DEFAULT_CUSTOMER_LIST_PAGE_SIZE, + migrationId, + executionStatuses = [], + migrationRunId, + migrationRunDryRun, + isActive = false, + includeRows = true, }: { filter: CustomerFilter; search?: string; - page?: number; + customerFilters?: CustomerListFilters; + cursor?: string; pageSize?: number; + migrationId?: string; + executionStatuses?: ExecutionStatus[]; + migrationRunId?: string; + migrationRunDryRun?: boolean; + isActive?: boolean; + includeRows?: boolean; }) => { const axiosInstance = useAxiosInstance(); const buildKey = useQueryKeyFactory(); const filterKey = useMemo(() => JSON.stringify(filter), [filter]); - const queryKey = buildKey(["migration-filter-preview", filterKey, search, page, pageSize]); + const customerFiltersKey = useMemo( + () => JSON.stringify(customerFilters ?? {}), + [customerFilters], + ); + const executionKey = useMemo( + () => executionStatuses.slice().sort().join(","), + [executionStatuses], + ); + const baseKey = [ + "migration-filter-preview", + filterKey, + search, + customerFiltersKey, + migrationId, + executionKey, + migrationRunId, + migrationRunDryRun, + ] as const; + const queryKey = buildKey([...baseKey, cursor, pageSize]); - const query = useQuery({ + const query = useQuery({ queryKey, - queryFn: async () => { + queryFn: async ({ signal }) => { const { data } = await axiosInstance.post( "/migrations.filter.preview", - { filter, search, page, pageSize }, + { + filter, + search, + customerFilters, + cursor, + pageSize, + migrationId, + executionStatuses, + migrationRunId, + migrationRunDryRun, + includeCount: false, + }, + { signal }, ); - return data; + return { ...data, cursor }; }, staleTime: 500, placeholderData: keepPreviousData, + enabled: includeRows, + refetchInterval: isActive ? ACTIVE_POLL_MS : false, }); + const countQuery = useQuery({ + queryKey: buildKey(["migration-filter-preview-count", ...baseKey]), + queryFn: async ({ signal }) => { + const { data } = await axiosInstance.post( + "/migrations.filter.preview", + { + filter, + search, + customerFilters, + pageSize: 1, + migrationId, + executionStatuses, + migrationRunId, + migrationRunDryRun, + countOnly: true, + }, + { signal }, + ); + return data.count; + }, + staleTime: 500, + placeholderData: keepPreviousData, + refetchInterval: isActive ? ACTIVE_POLL_MS : false, + }); + + const hasRowsForCursor = !includeRows || query.data?.cursor === cursor; + return { - count: query.data?.count ?? null, - customers: query.data?.customers ?? [], - isLoading: query.isLoading, + count: countQuery.data ?? null, + customers: hasRowsForCursor ? (query.data?.customers ?? []) : [], + nextCursor: hasRowsForCursor ? (query.data?.next_cursor ?? null) : null, + isLoading: includeRows + ? !hasRowsForCursor || query.isLoading + : countQuery.isLoading || countQuery.isPlaceholderData, + isCountLoading: countQuery.isLoading || countQuery.isPlaceholderData, }; }; diff --git a/vite/src/hooks/queries/useMigrationRunsQuery.ts b/vite/src/hooks/queries/useMigrationRunsQuery.ts index 201b10fa0..525a5cd18 100644 --- a/vite/src/hooks/queries/useMigrationRunsQuery.ts +++ b/vite/src/hooks/queries/useMigrationRunsQuery.ts @@ -4,12 +4,14 @@ import type { MigrationRunStatus, } from "@autumn/shared"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; import { useAxiosInstance } from "@/services/useAxiosInstance"; const ACTIVE_STATUSES: MigrationRunStatus[] = ["queued", "running"]; -const POLL_MS = 30000; +const IDLE_POLL_MS = 30000; +export const ACTIVE_POLL_MS = 2000; +const EVENT_SETTLE_DELAYS_MS = [0, 1500, 4000]; export type MigrationItemEventStatus = "succeeded" | "skipped" | "failed"; @@ -33,42 +35,66 @@ export interface MigrationItemEvent { response: Record | null; } -function findActiveRun(runs: MigrationRun[]): MigrationRun | undefined { +export type MigrationRunItemCounts = { + total: number; + running: number; + succeeded: number; + skipped: number; + failed: number; + completed: number; +}; + +export type MigrationRunWithItemCounts = MigrationRun & { + item_run_counts?: MigrationRunItemCounts; +}; + +function findActiveRun( + runs: MigrationRunWithItemCounts[], +): MigrationRunWithItemCounts | undefined { return runs.find((r) => ACTIVE_STATUSES.includes(r.status)); } export const useMigrationRunsQuery = ({ migrationId, migrationRunId, + itemIds, enabled = true, }: { migrationId: string; migrationRunId?: string; + itemIds?: string[]; enabled?: boolean; }) => { const axiosInstance = useAxiosInstance(); const queryClient = useQueryClient(); const buildKey = useQueryKeyFactory(); const runsQueryKey = buildKey(["migration-runs", migrationId]); + const eventsKeyPrefix = useMemo( + () => ["migration-item-events", migrationId], + [migrationId], + ); + const stableItemIds = itemIds ? [...itemIds].sort().join(",") : "all"; const eventsQueryKey = buildKey([ - "migration-item-events", - migrationId, + ...eventsKeyPrefix, migrationRunId ?? "all", + stableItemIds, ]); - const runsQuery = useQuery<{ list: MigrationRun[] }>({ + const runsQuery = useQuery<{ list: MigrationRunWithItemCounts[] }>({ queryKey: runsQueryKey, queryFn: async () => { - const { data } = await axiosInstance.post<{ list: MigrationRun[] }>( - "/migrations.runs.list", - { migrationId }, - ); + const { data } = await axiosInstance.post<{ + list: MigrationRunWithItemCounts[]; + }>("/migrations.runs.list", { migrationId }); return data; }, enabled, refetchOnWindowFocus: true, staleTime: 0, - refetchInterval: POLL_MS, + refetchInterval: (query) => + findActiveRun(query.state.data?.list ?? []) + ? ACTIVE_POLL_MS + : IDLE_POLL_MS, }); const activeRun = findActiveRun(runsQuery.data?.list ?? []); @@ -79,22 +105,30 @@ export const useMigrationRunsQuery = ({ queryFn: async () => { const { data } = await axiosInstance.post<{ list: MigrationItemEvent[]; - }>("/migrations.item_events.list", { migrationId, migrationRunId }); + }>("/migrations.item_events.list", { + migrationId, + migrationRunId, + itemIds, + }); return data; }, enabled, refetchOnWindowFocus: true, staleTime: 0, - refetchInterval: POLL_MS, + refetchInterval: isActive ? ACTIVE_POLL_MS : IDLE_POLL_MS, }); const invalidate = useCallback(() => { queryClient.invalidateQueries({ queryKey: runsQueryKey }); - queryClient.invalidateQueries({ queryKey: eventsQueryKey }); - }, [queryClient, runsQueryKey, eventsQueryKey]); + for (const delay of EVENT_SETTLE_DELAYS_MS) + window.setTimeout( + () => queryClient.invalidateQueries({ queryKey: eventsKeyPrefix }), + delay, + ); + }, [queryClient, runsQueryKey, eventsKeyPrefix]); return { - runs: (runsQuery.data?.list ?? []) as MigrationRun[], + runs: (runsQuery.data?.list ?? []) as MigrationRunWithItemCounts[], isLoadingRuns: runsQuery.isLoading, isActive, activeRunDryRun: activeRun?.dry_run ?? null, diff --git a/vite/src/hooks/queries/useMigrationsQuery.tsx b/vite/src/hooks/queries/useMigrationsQuery.tsx index e26122959..06cb6ddd3 100644 --- a/vite/src/hooks/queries/useMigrationsQuery.tsx +++ b/vite/src/hooks/queries/useMigrationsQuery.tsx @@ -5,6 +5,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; import { useAxiosInstance } from "@/services/useAxiosInstance"; +export type MigrationWithRunInfo = Migration & { has_live_runs: boolean }; +export type RetryableMigrationItemRunStatus = "failed" | "skipped"; + interface PrepareModuleResult { key: string; kind: string; @@ -24,12 +27,14 @@ export const useMigrationsQuery = () => { const queryClient = useQueryClient(); const queryKey = buildKey(["migrations"]); - const { data, isLoading, error, refetch } = useQuery<{ list: Migration[] }>({ + const { data, isLoading, error, refetch } = useQuery<{ + list: MigrationWithRunInfo[]; + }>({ queryKey, queryFn: async () => { - const { data } = await axiosInstance.post<{ list: Migration[] }>( - "/migrations.list", - ); + const { data } = await axiosInstance.post<{ + list: MigrationWithRunInfo[]; + }>("/migrations.list"); return data; }, }); @@ -37,7 +42,12 @@ export const useMigrationsQuery = () => { const invalidate = () => queryClient.invalidateQueries({ queryKey }); const createMutation = useMutation({ - mutationFn: async (body: { id: string }) => { + mutationFn: async (body: { + id: string; + filter?: MigrationFilter | null; + operations?: Operations | null; + no_billing_changes?: boolean; + }) => { const { data } = await axiosInstance.post( "/migrations.create", body, @@ -54,8 +64,8 @@ export const useMigrationsQuery = () => { id?: string; filter?: MigrationFilter | null; operations?: Operations | null; - retry_failed?: boolean; no_billing_changes?: boolean; + archived?: boolean; }; }) => { const { data } = await axiosInstance.post( @@ -97,10 +107,13 @@ export const useMigrationsQuery = () => { only?: string[]; concurrency?: number; lazy_run?: boolean; + retry_item_statuses?: RetryableMigrationItemRunStatus[]; }) => { const { data } = await axiosInstance.post<{ migration_id: string; dry_run: boolean; + lazy_run: boolean; + concurrency?: number; run_id: string; trigger_run_id?: string; public_access_token?: string; @@ -123,10 +136,11 @@ export const useMigrationsQuery = () => { }); return { - migrations: (data?.list ?? []) as Migration[], + migrations: (data?.list ?? []) as MigrationWithRunInfo[], isLoading, error, refetch, + invalidate, createMigration: createMutation.mutateAsync, isCreating: createMutation.isPending, updateMigration: updateMutation.mutateAsync, diff --git a/vite/src/hooks/stores/useSheetStore.ts b/vite/src/hooks/stores/useSheetStore.ts index ca3f1ffbf..7fec5e65c 100644 --- a/vite/src/hooks/stores/useSheetStore.ts +++ b/vite/src/hooks/stores/useSheetStore.ts @@ -60,6 +60,7 @@ interface SheetState { data?: Record | null; }) => void; setInitialItem: (item: ProductItem | null) => void; + updateItemId: (itemId: string) => void; closeSheet: () => void; reset: () => void; } @@ -90,6 +91,9 @@ export const useSheetStore = create((set) => ({ // Set the initial item state for change detection setInitialItem: (item) => set({ initialItem: item }), + // Update just the itemId without clearing other state + updateItemId: (itemId) => set({ itemId }), + // Close the sheet closeSheet: () => set((state) => ({ diff --git a/vite/src/index.css b/vite/src/index.css index 28f3b2d86..ed2c243b3 100644 --- a/vite/src/index.css +++ b/vite/src/index.css @@ -153,7 +153,7 @@ html { --sandbox: #0f9bff; --card: #121212; - --border: #2c2c2c; + --border: #222222; --chart-grid-stroke: #262626; @@ -197,7 +197,7 @@ html { --input-background: #111111; --muted: #111111; --card: #050505; - --border: #1e1e1e; + --border: #181818; --chart-grid-stroke: #1a1a1a; } diff --git a/vite/src/services/OrgService.tsx b/vite/src/services/OrgService.tsx index d9287833e..6ab873f1b 100644 --- a/vite/src/services/OrgService.tsx +++ b/vite/src/services/OrgService.tsx @@ -24,4 +24,19 @@ export class OrgService { ) { return await axiosInstance.patch(`/v1/organization/vercel`, data); } + + static async getChat(axiosInstance: AxiosInstance) { + return await axiosInstance.get(`/organization/chat`); + } + + static async createChatInstall( + axiosInstance: AxiosInstance, + data: { provider: "slack"; env: string }, + ) { + return await axiosInstance.post(`/organization/chat/install`, data); + } + + static async disconnectChat(axiosInstance: AxiosInstance, provider: "slack") { + return await axiosInstance.delete(`/organization/chat/${provider}`); + } } diff --git a/vite/src/services/customers/CusService.tsx b/vite/src/services/customers/CusService.tsx index 075c459a8..2f13b709f 100644 --- a/vite/src/services/customers/CusService.tsx +++ b/vite/src/services/customers/CusService.tsx @@ -120,7 +120,7 @@ export class CusService { axios: AxiosInstance; customer_id: string; }): Promise<{ success: boolean }> { - const { data } = await axios.post(`/customers/clear_cache`, { + const { data } = await axios.post(`/v1/customers/clear_cache`, { customer_id, }); return data; diff --git a/vite/src/services/products/ProductService.tsx b/vite/src/services/products/ProductService.tsx index 54ea8835b..c770ea279 100644 --- a/vite/src/services/products/ProductService.tsx +++ b/vite/src/services/products/ProductService.tsx @@ -1,3 +1,4 @@ +import type { UpdatePlanParamsV2Input } from "@autumn/shared"; import type { AxiosInstance } from "axios"; import { notNullish } from "@/utils/genUtils"; @@ -11,15 +12,27 @@ export class ProductService { axiosInstance: AxiosInstance, productId: string, data: any, - version?: number, + options?: { version?: number }, ) { - const url = notNullish(version) - ? `/v1/products/${productId}?version=${version}` + const params = new URLSearchParams(); + if (notNullish(options?.version)) + params.set("version", String(options.version)); + const qs = params.toString(); + const url = qs + ? `/v1/products/${productId}?${qs}` : `/v1/products/${productId}`; const response = await axiosInstance.post(url, data); return response.data; } + static async updatePlan( + axiosInstance: AxiosInstance, + data: UpdatePlanParamsV2Input, + ) { + const response = await axiosInstance.post("/v1/plans.update", data); + return response.data; + } + static async deleteProduct( axiosInstance: AxiosInstance, productId: string, diff --git a/vite/src/utils/constants/customerListPagination.ts b/vite/src/utils/constants/customerListPagination.ts new file mode 100644 index 000000000..148668919 --- /dev/null +++ b/vite/src/utils/constants/customerListPagination.ts @@ -0,0 +1,2 @@ +export const CUSTOMER_LIST_PAGE_SIZE_OPTIONS = [50, 100, 250, 500]; +export const DEFAULT_CUSTOMER_LIST_PAGE_SIZE = 50; diff --git a/vite/src/views/admin/AdminView.tsx b/vite/src/views/admin/AdminView.tsx index 988cb1b6e..c836c782c 100644 --- a/vite/src/views/admin/AdminView.tsx +++ b/vite/src/views/admin/AdminView.tsx @@ -3,8 +3,8 @@ import { Globe, Sliders } from "@phosphor-icons/react"; import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; -import { Button } from "@/components/v2/buttons/Button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/v2/buttons/Button"; import { authClient } from "@/lib/auth-client"; import { useEnv } from "@/utils/envUtils"; import { AdminOrgTable } from "@/views/admin/AdminOrgTable"; @@ -13,6 +13,7 @@ import { DefaultView } from "../DefaultView"; import LoadingScreen from "../general/LoadingScreen"; import { CreateUser } from "./components/CreateUser"; import { EdgeConfigTab } from "./components/EdgeConfigTab"; +import { SlackAdminBotTab } from "./components/SlackAdminBotTab"; import { useAdmin } from "./hooks/useAdmin"; export const AdminView = () => { @@ -81,6 +82,7 @@ export const AdminView = () => { Organizations Users + Slack Bot Edge Config @@ -92,6 +94,10 @@ export const AdminView = () => { + + + + diff --git a/vite/src/views/admin/components/SlackAdminBotTab.tsx b/vite/src/views/admin/components/SlackAdminBotTab.tsx new file mode 100644 index 000000000..51e967c94 --- /dev/null +++ b/vite/src/views/admin/components/SlackAdminBotTab.tsx @@ -0,0 +1,363 @@ +import { AppEnv } from "@autumn/shared"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ExternalLink, RefreshCw, Save, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/v2/badges/Badge"; +import { Button } from "@/components/v2/buttons/Button"; +import { Input } from "@/components/v2/inputs/Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/v2/selects/Select"; +import { useDebounce } from "@/hooks/useDebounce"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +type SlackAdminInstallation = { + id: string; + workspace_id: string; + workspace_name?: string | null; + bot_user_id?: string | null; + target_org_id: string; + target_org_name?: string | null; + target_org_slug?: string | null; + target_env: AppEnv; + updated_at?: number | null; + installed_by_user_id?: string | null; + oauth_credentials?: SlackAdminOAuthCredential[]; +}; + +type SlackAdminOAuthCredential = { + id: string; + env: AppEnv; + oauth_client_id: string; + oauth_consent_id?: string | null; + access_token_expires_at: number; + updated_at?: number | null; +}; + +type OrgSearchResult = { + id: string; + name?: string | null; + slug?: string | null; + createdAt: string; +}; + +type OrgSearchResponse = { + rows: OrgSearchResult[]; + hasNextPage: boolean; +}; + +const queryKey = ["admin-slack-admin-bot"]; + +export const SlackAdminBotTab = () => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const [targetOrgIdOrSlug, setTargetOrgIdOrSlug] = useState(""); + const [orgSearch, setOrgSearch] = useState(""); + const [targetEnv, setTargetEnv] = useState(AppEnv.Live); + const debouncedOrgSearch = useDebounce({ + value: orgSearch.trim(), + delayMs: 250, + }); + + const { data, isLoading, refetch } = useQuery({ + queryKey, + queryFn: async () => { + const { data } = await axiosInstance.get<{ + installation: SlackAdminInstallation | null; + }>("/admin/chat/slack-admin"); + return data; + }, + }); + + const installation = data?.installation ?? null; + const credentials = installation?.oauth_credentials ?? []; + const targetOrgName = + installation?.target_org_name || + installation?.target_org_slug || + installation?.target_org_id; + + const { data: orgSearchData, isLoading: isSearchingOrgs } = + useQuery({ + queryKey: ["admin-slack-bot-org-search", debouncedOrgSearch], + queryFn: async () => { + const params = new URLSearchParams({ search: debouncedOrgSearch }); + const { data } = await axiosInstance.get( + `/admin/orgs?${params.toString()}`, + ); + return data; + }, + enabled: Boolean(installation) && debouncedOrgSearch.length > 0, + }); + + const orgRows = useMemo( + () => orgSearchData?.rows ?? [], + [orgSearchData?.rows], + ); + + useEffect(() => { + if (!installation) return; + setTargetOrgIdOrSlug(installation.target_org_id); + setTargetEnv(installation.target_env); + }, [installation]); + + const installMutation = useMutation({ + mutationFn: async () => { + const { data } = await axiosInstance.post<{ url: string }>( + "/admin/chat/slack-admin/install", + ); + return data; + }, + onSuccess: ({ url }) => { + window.location.assign(url); + }, + onError: (error) => { + toast.error(getBackendErr(error, "Failed to create Slack install URL")); + }, + }); + + const updateTargetMutation = useMutation({ + mutationFn: async () => { + const { data } = await axiosInstance.patch<{ + installation: SlackAdminInstallation; + }>("/admin/chat/slack-admin/target", { + org_id: targetOrgIdOrSlug.trim(), + env: targetEnv, + }); + return data; + }, + onSuccess: async () => { + toast.success("Slack admin bot target updated"); + await queryClient.invalidateQueries({ queryKey }); + }, + onError: (error) => { + toast.error(getBackendErr(error, "Failed to update Slack admin bot")); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: async () => { + await axiosInstance.delete("/admin/chat/slack-admin"); + }, + onSuccess: async () => { + toast.success("Slack admin bot revoked"); + setTargetOrgIdOrSlug(""); + setOrgSearch(""); + setTargetEnv(AppEnv.Live); + await queryClient.invalidateQueries({ queryKey }); + }, + onError: (error) => { + toast.error(getBackendErr(error, "Failed to revoke Slack admin bot")); + }, + }); + + const handleRevoke = () => { + if (!confirm("Revoke the Slack admin bot installation?")) return; + revokeMutation.mutate(); + }; + + const handleSelectOrg = ({ org }: { org: OrgSearchResult }) => { + setTargetOrgIdOrSlug(org.id); + setOrgSearch(org.name || org.slug || org.id); + }; + + return ( +
+
+
+

Slack Bot

+

+ Install one admin Slack workspace and point it at a target org. +

+
+ +
+ +
+
+
+
+

+ {installation?.workspace_name ?? "No workspace installed"} +

+ + {installation ? "Installed" : "Not installed"} + +
+ {installation ? ( +

+ {installation.workspace_id} + {installation.bot_user_id + ? ` - Bot ${installation.bot_user_id}` + : ""} +

+ ) : null} +
+ + +
+ +
+
+ Target org + {installation ? ( +
+

+ {targetOrgName} +

+

+ {installation.target_org_id} + {installation.target_org_slug + ? ` - ${installation.target_org_slug}` + : ""} +

+
+ ) : null} + setTargetOrgIdOrSlug(event.target.value)} + placeholder="Org ID or slug" + disabled={!installation} + /> + setOrgSearch(event.target.value)} + placeholder="Search orgs by name, slug, or ID" + disabled={!installation} + /> + {installation && debouncedOrgSearch.length > 0 ? ( +
+ {isSearchingOrgs ? ( +
+ Searching organizations... +
+ ) : orgRows.length === 0 ? ( +
+ No organizations found. +
+ ) : ( + orgRows.map((org) => { + const isSelected = targetOrgIdOrSlug === org.id; + + return ( + + ); + }) + )} +
+ ) : null} +
+ +
+ Environment + +
+ + +
+ + {credentials.length > 0 ? ( +
+

+ Internal OAuth credentials +

+
+ {credentials.map((credential) => ( +
+ + {credential.env} + + {credential.oauth_client_id} + {credential.oauth_consent_id ? ( + + {credential.oauth_consent_id} + + ) : null} +
+ ))} +
+
+ ) : null} + +
+ +
+
+
+ ); +}; diff --git a/vite/src/views/admin/oauth/OAuthClientsView.tsx b/vite/src/views/admin/oauth/OAuthClientsView.tsx index ef344b182..6b23f234d 100644 --- a/vite/src/views/admin/oauth/OAuthClientsView.tsx +++ b/vite/src/views/admin/oauth/OAuthClientsView.tsx @@ -1,9 +1,10 @@ import { AppEnv } from "@autumn/shared"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowLeft, Globe, Key, + MessageSquare, Pencil, Plus, RefreshCw, @@ -62,6 +63,25 @@ export const OAuthClientsView = () => { }); const clients: OAuthClient[] = data?.clients || []; + const upsertSlackMcpMutation = useMutation({ + mutationFn: async () => { + const { data } = await axiosInstance.post( + "/admin/oauth-clients/slack-mcp", + ); + return data; + }, + onSuccess: (client) => { + toast.success( + `Slack MCP OAuth client ready: ${client.client_id ?? "autumn_mcp_slack"}`, + ); + refetch(); + }, + onError: (error) => { + toast.error( + getBackendErr(error, "Failed to create Slack MCP OAuth client"), + ); + }, + }); const handleDeleteClient = async (client_id: string) => { if (!confirm("Are you sure you want to delete this OAuth client?")) { @@ -157,6 +177,15 @@ export const OAuthClientsView = () => { > Refresh + } + onClick={() => upsertSlackMcpMutation.mutate()} + disabled={upsertSlackMcpMutation.isPending} + > + Add Slack MCP + { const firstLetter = org?.name?.charAt(0).toUpperCase() || "A"; return ( -
+
{org.logo ? ( {org.name} ) : ( - + {firstLetter} )} @@ -102,11 +113,60 @@ const OrgLogo = ({ org }: { org: { name: string; logo?: string | null } }) => { ); }; +const getConsentRedirectUrl = (data: unknown) => { + if (!data || typeof data !== "object") return null; + const response = data as Record; + + return [response.url, response.uri, response.redirectTo].find( + (value): value is string => typeof value === "string" && value.length > 0, + ); +}; + +const isExternalAppRedirect = (redirectUrl: string) => { + if (!URL.canParse(redirectUrl)) return false; + const protocol = new URL(redirectUrl).protocol; + return protocol !== "http:" && protocol !== "https:"; +}; + +const openConsentRedirect = ({ + onExternalRedirectFallback, + redirectUrl, +}: { + onExternalRedirectFallback: () => void; + redirectUrl: string; +}) => { + const shouldShowFallback = isExternalAppRedirect(redirectUrl); + window.location.href = redirectUrl; + + if (shouldShowFallback) { + window.setTimeout(onExternalRedirectFallback, 1200); + } +}; + +const leafScopeSet = new Set(LEAF_OAUTH_SCOPES); + +const getGrantableMcpScopes = ({ + requestedScopes, + sessionScopes, +}: { + requestedScopes: string[]; + sessionScopes: string[]; +}) => { + const requested = + requestedScopes.length > 0 ? requestedScopes : [...LEAF_OAUTH_SCOPES]; + + return [...new Set(requested)] + .filter((scope) => leafScopeSet.has(scope)) + .filter((scope) => isScopeSubset([scope], sessionScopes)); +}; + export const Consent = () => { const [searchParams] = useSearchParams(); const { data: session } = useSession(); const { data: orgs } = useListOrganizations(); const { data: activeOrganization } = authClient.useActiveOrganization(); + const errorIconMaskId = useId(); + const consentIconMaskId = useId(); const [clientInfo, setClientInfo] = useState(null); const [groupedPermissions, setGroupedPermissions] = useState< @@ -115,31 +175,22 @@ export const Consent = () => { const [jokeScope] = useState(() => getRandomJokeScope()); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); - const [orgDropdownOpen, setOrgDropdownOpen] = useState(false); + const [pendingRedirectUrl, setPendingRedirectUrl] = useState( + null, + ); + const [selectedEnv, setSelectedEnv] = useState(AppEnv.Live); const [switchingOrg, setSwitchingOrg] = useState(false); - const orgDropdownRef = useRef(null); const clientId = searchParams.get("client_id"); - const requestedScopes = searchParams.get("scope")?.split(" ") || []; + const redirectUri = searchParams.get("redirect_uri"); + const requestedScopes = + searchParams.get("scope")?.split(/\s+/).filter(Boolean) || []; + const sessionScopes = + (session as SessionWithScopes | null | undefined)?.scopes ?? []; // Get the current org (active or first available) const currentOrg = activeOrganization || orgs?.[0]; - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - orgDropdownRef.current && - !orgDropdownRef.current.contains(event.target as Node) - ) { - setOrgDropdownOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - const handleSwitchOrg = async (orgId: string) => { setSwitchingOrg(true); try { @@ -161,17 +212,26 @@ export const Consent = () => { return; } + let isInternalMcp = false; try { // Fetch client name from our own endpoint - const response = await fetch( + const clientInfoUrl = new URL( `${import.meta.env.VITE_BACKEND_URL}/oauth/client/${encodeURIComponent(clientId)}`, ); + if (redirectUri) { + clientInfoUrl.searchParams.set("redirect_uri", redirectUri); + } + + const response = await fetch(clientInfoUrl.toString()); if (response.ok) { const data = await response.json(); + isInternalMcp = data.is_internal_mcp === true; setClientInfo({ client_id: clientId, client_name: data.name || "Unknown Application", + is_atmn: data.is_atmn === true, + is_internal_mcp: isInternalMcp, }); } else { console.error("Error fetching client info:", response.status); @@ -179,6 +239,8 @@ export const Consent = () => { setClientInfo({ client_id: clientId, client_name: "External Application", + is_atmn: false, + is_internal_mcp: false, }); } } catch (error) { @@ -187,27 +249,53 @@ export const Consent = () => { setClientInfo({ client_id: clientId, client_name: "External Application", + is_atmn: false, + is_internal_mcp: false, }); } // Parse and group scopes by resource - const grouped = groupAndFormatScopes(requestedScopes); + const displayScopes = + isInternalMcp === true + ? getGrantableMcpScopes({ requestedScopes, sessionScopes }) + : requestedScopes; + const grouped = groupAndFormatScopes(displayScopes); setGroupedPermissions(grouped); setIsLoading(false); } fetchClientInfo(); - }, [clientId, requestedScopes.join(",")]); + }, [ + clientId, + redirectUri, + requestedScopes.join(","), + sessionScopes.join(","), + ]); const handleAuthorize = async () => { + if (!clientInfo) { + toast.error("Authorization failed"); + return; + } + setIsSubmitting(true); + setPendingRedirectUrl(null); try { - // Use the original requested scopes - const grantedScopes = requestedScopes.join(" "); + const grantedScopes = + clientInfo.is_internal_mcp === true + ? getGrantableMcpScopes({ requestedScopes, sessionScopes }).join(" ") + : requestedScopes.join(" "); const { data, error } = await authClient.oauth2.consent({ accept: true, scope: grantedScopes, + client_id: clientId, + redirect_uri: redirectUri, + env: clientInfo.is_atmn ? undefined : selectedEnv, + } as Parameters[0] & { + client_id: string | null; + redirect_uri: string | null; + env?: AppEnv; }); if (error) { @@ -216,12 +304,20 @@ export const Consent = () => { return; } - // Handle redirect - server returns { redirect: true, uri: "..." } - if (data?.uri) { - window.location.href = data.uri; - } else if (data?.redirectTo) { - window.location.href = data.redirectTo; + const redirectUrl = getConsentRedirectUrl(data); + if (redirectUrl) { + if (isExternalAppRedirect(redirectUrl)) { + setPendingRedirectUrl(redirectUrl); + } + openConsentRedirect({ + redirectUrl, + onExternalRedirectFallback: () => setIsSubmitting(false), + }); + return; } + + toast.error("Authorization failed"); + setIsSubmitting(false); } catch (error) { console.error("Authorization error:", error); toast.error("Authorization failed. Please try again."); @@ -231,6 +327,7 @@ export const Consent = () => { const handleCancel = async () => { setIsSubmitting(true); + setPendingRedirectUrl(null); try { const { data, error } = await authClient.oauth2.consent({ accept: false, @@ -242,11 +339,20 @@ export const Consent = () => { return; } - if (data?.uri) { - window.location.href = data.uri; - } else if (data?.redirectTo) { - window.location.href = data.redirectTo; + const redirectUrl = getConsentRedirectUrl(data); + if (redirectUrl) { + if (isExternalAppRedirect(redirectUrl)) { + setPendingRedirectUrl(redirectUrl); + } + openConsentRedirect({ + redirectUrl, + onExternalRedirectFallback: () => setIsSubmitting(false), + }); + return; } + + toast.error("Failed to cancel. Please close this window."); + setIsSubmitting(false); } catch (error) { console.error("Cancel error:", error); toast.error("Failed to cancel. Please close this window."); @@ -254,6 +360,11 @@ export const Consent = () => { } }; + const handleOpenPendingRedirect = () => { + if (!pendingRedirectUrl) return; + window.location.href = pendingRedirectUrl; + }; + if (isLoading) { return (
@@ -271,12 +382,27 @@ export const Consent = () => {
- - - - +

@@ -296,12 +422,27 @@ export const Consent = () => {
{/* Logo */}
- - - - +
@@ -323,70 +464,62 @@ export const Consent = () => { )}
- {/* Organization Selector */} - {currentOrg && ( -
-
-

- Organization -

-
-
- - - {/* Dropdown */} - {orgDropdownOpen && orgs && orgs.length >= 2 && ( -
- {orgs - .filter((org) => org.id !== currentOrg.id) - .map((org) => ( -
{/* Action Buttons */} + {pendingRedirectUrl && !isSubmitting && ( +

+ If {clientInfo.client_name} did not open, use the button below. +

+ )}
diff --git a/vite/src/views/auth/components/AuthBackground.tsx b/vite/src/views/auth/components/AuthBackground.tsx index ff8432cb2..c481a6e24 100644 --- a/vite/src/views/auth/components/AuthBackground.tsx +++ b/vite/src/views/auth/components/AuthBackground.tsx @@ -20,7 +20,7 @@ export function AuthBackground({ children }: AuthBackgroundProps) { aria-hidden="true" className="absolute inset-0 w-full h-full object-cover" /> -
diff --git a/vite/src/views/main-sidebar/SidebarContact.tsx b/vite/src/views/main-sidebar/SidebarContact.tsx index 7cf9ea818..70f9fd2ce 100644 --- a/vite/src/views/main-sidebar/SidebarContact.tsx +++ b/vite/src/views/main-sidebar/SidebarContact.tsx @@ -1,9 +1,20 @@ "use client"; -import { QuestionIcon } from "@phosphor-icons/react"; +import { ChatCircleTextIcon, QuestionIcon } from "@phosphor-icons/react"; import { GraduationCap } from "lucide-react"; +import { useState } from "react"; import { Link } from "react-router"; +import { toast } from "sonner"; import CopyButton from "@/components/general/CopyButton"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; import { DropdownMenu, DropdownMenuContent, @@ -11,6 +22,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; +import { LongInput } from "@/components/v2/inputs/LongInput"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useEnv } from "@/utils/envUtils"; import { useOnboardingVisibility } from "@/views/onboarding4/hooks/useOnboardingProgress"; import { NavButton } from "./NavButton"; @@ -19,65 +32,124 @@ export function SidebarContact() { const email = "hey@useautumn.com"; const env = useEnv(); const { show: showOnboardingGuide } = useOnboardingVisibility(); + const axiosInstance = useAxiosInstance({ env }); + const [feedbackOpen, setFeedbackOpen] = useState(false); + const [feedback, setFeedback] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmitFeedback = async () => { + if (!feedback.trim()) return; + + setLoading(true); + try { + await axiosInstance.post("/feedback", { feedback }); + toast.success("Thanks for your feedback!"); + setFeedback(""); + setFeedbackOpen(false); + } catch (error) { + console.error("Failed to send feedback:", error); + toast.error("Failed to send feedback"); + } finally { + setLoading(false); + } + }; return ( - - } nativeButton={false}> - } - title="Need help?" - onClick={() => {}} - /> - - - - 👋 We respond within 30 minutes - - - { - window.location.href = `mailto:${email}`; - }} - className="cursor-pointer" - > -
- {/* {email} */} - hey@useautumn.com - -
-
- window.open("https://cal.com/ayrod", "_blank")} - className="cursor-pointer" - > - Book a call - - - - We're online on Discord - - - - - - - - - - Show onboarding guide - -
-
+ <> + + } nativeButton={false}> + } + title="Contact us" + onClick={() => {}} + isGroup + /> + + + + 👋 We respond within 30 minutes + + + { + window.location.href = `mailto:${email}`; + }} + className="cursor-pointer" + > +
+ hey@useautumn.com + +
+
+ window.open("https://cal.com/ayrod", "_blank")} + className="cursor-pointer" + > + Book a call + + + + We're online on Discord + + + + + + + + setFeedbackOpen(true)} + className="cursor-pointer" + > + + Feedback + + + + Show onboarding guide + +
+
+ + + + Help us improve + + We read every comment, and often turn around features within a + couple days. Be as brutal as you can - thank you so much! + + + setFeedback(e.target.value)} + placeholder={`The worst part about Autumn is...\n\nI really wish Autumn had....\n\nThe part I found most confusing was...`} + className="min-h-[120px]" + /> + + + + + + + ); } diff --git a/vite/src/views/migrations/components/CreateMigrationDialog.tsx b/vite/src/views/migrations/components/CreateMigrationDialog.tsx index a569755d1..164089c92 100644 --- a/vite/src/views/migrations/components/CreateMigrationDialog.tsx +++ b/vite/src/views/migrations/components/CreateMigrationDialog.tsx @@ -1,7 +1,8 @@ import type { AxiosError } from "axios"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; +import { migrationUid } from "@/views/migrations/migration/shared/operationUtils"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; import { Dialog, @@ -27,8 +28,9 @@ export function CreateMigrationDialog({ const navigate = useNavigate(); const open = controlledOpen !== undefined ? controlledOpen : internalOpen; + const generateId = useCallback(() => `migration-${migrationUid()}`, []); const handleOpenChange = (nextOpen: boolean) => { - if (nextOpen) setId(""); + if (nextOpen) setId(generateId()); (controlledOnOpenChange || setInternalOpen)(nextOpen); }; @@ -66,12 +68,12 @@ export function CreateMigrationDialog({ - setId(e.target.value)} - /> + setId(e.target.value)} + />

diff --git a/vite/src/views/migrations/hooks/useMigrationsQueryState.ts b/vite/src/views/migrations/hooks/useMigrationsQueryState.ts new file mode 100644 index 000000000..e38aef991 --- /dev/null +++ b/vite/src/views/migrations/hooks/useMigrationsQueryState.ts @@ -0,0 +1,14 @@ +import { parseAsBoolean, useQueryStates } from "nuqs"; + +export const useMigrationsQueryState = () => { + const [queryStates, setQueryStates] = useQueryStates( + { + showArchived: parseAsBoolean.withDefault(false), + }, + { + history: "push", + }, + ); + + return { queryStates, setQueryStates }; +}; diff --git a/vite/src/views/migrations/migration-list/DeleteMigrationDialog.tsx b/vite/src/views/migrations/migration-list/DeleteMigrationDialog.tsx new file mode 100644 index 000000000..e7f8acefd --- /dev/null +++ b/vite/src/views/migrations/migration-list/DeleteMigrationDialog.tsx @@ -0,0 +1,71 @@ +import { toast } from "sonner"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { + useMigrationsQuery, + type MigrationWithRunInfo, +} from "@/hooks/queries/useMigrationsQuery"; + +export function DeleteMigrationDialog({ + migration, + open, + onOpenChange, +}: { + migration: MigrationWithRunInfo; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { deleteMigration, isDeleting } = useMigrationsQuery(); + + const handleDelete = async () => { + try { + await deleteMigration({ id: migration.id }); + toast.success(`Migration ${migration.id} deleted`); + onOpenChange(false); + } catch { + toast.error("Failed to delete migration"); + } + }; + + return ( + !isDeleting && onOpenChange(nextOpen)} + > + + + + Delete {migration.id} + + + This migration will be permanently deleted. This action cannot be + undone. + + + + + + + + + ); +} diff --git a/vite/src/views/migrations/migration-list/MigrationListColumns.tsx b/vite/src/views/migrations/migration-list/MigrationListColumns.tsx index c82dad589..3ce4b1891 100644 --- a/vite/src/views/migrations/migration-list/MigrationListColumns.tsx +++ b/vite/src/views/migrations/migration-list/MigrationListColumns.tsx @@ -1,26 +1,44 @@ -import type { Migration } from "@autumn/shared"; import type { ColumnDef, Row } from "@tanstack/react-table"; import { format } from "date-fns"; import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; +import { Badge } from "@/components/v2/badges/Badge"; +import type { MigrationWithRunInfo } from "@/hooks/queries/useMigrationsQuery"; +import { MigrationListRowToolbar } from "./MigrationListRowToolbar"; export const createMigrationListColumns = (): ColumnDef< - Migration, + MigrationWithRunInfo, unknown >[] => [ { header: "ID", size: 240, accessorKey: "id", - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => (
), }, + { + header: "Status", + size: 100, + cell: ({ row }: { row: Row }) => ( + + {row.original.has_live_runs ? "Ran" : "Draft"} + + ), + }, { header: "Filter", size: 120, - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => ( {row.original.filter ? "Configured" : "—"} @@ -29,7 +47,7 @@ export const createMigrationListColumns = (): ColumnDef< { header: "Operations", size: 120, - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => ( {row.original.operations ? "Configured" : "—"} @@ -39,10 +57,23 @@ export const createMigrationListColumns = (): ColumnDef< header: "Created", size: 160, accessorKey: "created_at", - cell: ({ row }: { row: Row }) => ( + cell: ({ row }: { row: Row }) => ( {format(new Date(row.original.created_at), "PP")} ), }, + { + header: "", + accessorKey: "actions", + size: 40, + cell: ({ row }: { row: Row }) => ( +
e.stopPropagation()} + > + +
+ ), + }, ]; diff --git a/vite/src/views/migrations/migration-list/MigrationListMenuButton.tsx b/vite/src/views/migrations/migration-list/MigrationListMenuButton.tsx new file mode 100644 index 000000000..618dfde83 --- /dev/null +++ b/vite/src/views/migrations/migration-list/MigrationListMenuButton.tsx @@ -0,0 +1,47 @@ +import { EllipsisVertical } from "lucide-react"; +import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/v2/dropdowns/DropdownMenu"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { useMigrationsQueryState } from "@/views/migrations/hooks/useMigrationsQueryState"; + +export function MigrationListMenuButton() { + const [dropdownOpen, setDropdownOpen] = useState(false); + const { queryStates, setQueryStates } = useMigrationsQueryState(); + + return ( + + + } + variant="skeleton" + size="default" + iconOrientation="center" + className="!h-7" + /> + + + { + setQueryStates({ + ...queryStates, + showArchived: !queryStates.showArchived, + }); + setDropdownOpen(false); + }} + > +
+ {queryStates.showArchived + ? "Show active migrations" + : "Show archived migrations"} +
+
+
+
+ ); +} diff --git a/vite/src/views/migrations/migration-list/MigrationListRowToolbar.tsx b/vite/src/views/migrations/migration-list/MigrationListRowToolbar.tsx new file mode 100644 index 000000000..db359f2fd --- /dev/null +++ b/vite/src/views/migrations/migration-list/MigrationListRowToolbar.tsx @@ -0,0 +1,119 @@ +import { + ArrowCounterClockwiseIcon, + CheckCircleIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import type { MouseEvent } from "react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { ToolbarButton } from "@/components/general/table-components/ToolbarButton"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/v2/dropdowns/DropdownMenu"; +import { + useMigrationsQuery, + type MigrationWithRunInfo, +} from "@/hooks/queries/useMigrationsQuery"; +import { DeleteMigrationDialog } from "./DeleteMigrationDialog"; + +export function MigrationListRowToolbar({ + migration, +}: { + migration: MigrationWithRunInfo; +}) { + const [dropdownOpen, setDropdownOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const { updateMigration } = useMigrationsQuery(); + + const handleArchiveToggle = async () => { + setDropdownOpen(false); + const newArchived = !migration.archived; + try { + await updateMigration({ + id: migration.id, + updates: { archived: newArchived }, + }); + toast.success( + newArchived + ? `Migration ${migration.id} marked as complete` + : `Migration ${migration.id} unarchived`, + ); + } catch { + toast.error( + newArchived + ? "Failed to mark migration as complete" + : "Failed to unarchive migration", + ); + } + }; + + const openDeleteDialog = () => { + setDropdownOpen(false); + setDeleteOpen(true); + }; + + const menuAction = (() => { + if (migration.archived) { + return { + icon: , + label: "Unarchive", + onSelect: handleArchiveToggle, + }; + } + + if (migration.has_live_runs) { + return { + icon: , + label: "Mark as complete", + onSelect: handleArchiveToggle, + }; + } + + return { + icon: , + label: "Delete", + onSelect: openDeleteDialog, + }; + })(); + + const handleMenuSelect = ( + e: MouseEvent, + action: () => void, + ) => { + e.stopPropagation(); + e.preventDefault(); + action(); + }; + + return ( + <> + +
{ e.preventDefault(); e.stopPropagation(); }} + onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }} + > + + + +
+ + handleMenuSelect(e, menuAction.onSelect)} + > + {menuAction.icon} + {menuAction.label} + + +
+ + + ); +} diff --git a/vite/src/views/migrations/migration-list/MigrationListTable.tsx b/vite/src/views/migrations/migration-list/MigrationListTable.tsx index a687ef7a7..01dda09e6 100644 --- a/vite/src/views/migrations/migration-list/MigrationListTable.tsx +++ b/vite/src/views/migrations/migration-list/MigrationListTable.tsx @@ -1,21 +1,36 @@ -import type { Migration } from "@autumn/shared"; import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; import { useMemo } from "react"; import { Table } from "@/components/general/table"; +import { BetaBadge } from "@/components/v2/badges/BetaBadge"; import { EmptyState } from "@/components/v2/empty-states/EmptyState"; -import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; +import { + useMigrationsQuery, + type MigrationWithRunInfo, +} from "@/hooks/queries/useMigrationsQuery"; import { pushPage } from "@/utils/genUtils"; import { useProductTable } from "@/views/products/hooks/useProductTable"; +import { useMigrationsQueryState } from "@/views/migrations/hooks/useMigrationsQueryState"; +import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; import { createMigrationListColumns } from "./MigrationListColumns"; import { MigrationListCreateButton } from "./MigrationListCreateButton"; +import { MigrationListMenuButton } from "./MigrationListMenuButton"; export function MigrationListTable() { const { migrations, isLoading } = useMigrationsQuery(); + const { queryStates } = useMigrationsQueryState(); + + const filteredMigrations = useMemo( + () => + migrations.filter((m) => + queryStates.showArchived ? m.archived : !m.archived, + ), + [migrations, queryStates.showArchived], + ); const columns = useMemo(() => createMigrationListColumns(), []); const table = useProductTable({ - data: migrations, + data: filteredMigrations, columns, options: { globalFilterFn: "includesString", @@ -23,7 +38,7 @@ export function MigrationListTable() { }, }); - const getRowHref = (row: Migration) => + const getRowHref = (row: MigrationWithRunInfo) => pushPage({ path: `/migrations/${row.id}` }); if (!isLoading && migrations.length === 0) { @@ -44,6 +59,9 @@ export function MigrationListTable() { isLoading, rowClassName: "h-10", getRowHref, + emptyStateText: queryStates.showArchived + ? "You haven't archived any migrations yet" + : undefined, }} > @@ -55,22 +73,26 @@ export function MigrationListTable() { className="text-subtle" /> Migrations +
+
-
- - - - - - -
+ + Migrations are in beta. For complex operations, please reach out to us + at support@useautumn.com + + + + + + + ); } diff --git a/vite/src/views/migrations/migration/FilterStep.tsx b/vite/src/views/migrations/migration/FilterStep.tsx index fdd8ac6ee..6fdc1a66f 100644 --- a/vite/src/views/migrations/migration/FilterStep.tsx +++ b/vite/src/views/migrations/migration/FilterStep.tsx @@ -1,4 +1,4 @@ -import type { MigrationFilter } from "@autumn/shared"; +import type { CustomerFilter, MigrationFilter } from "@autumn/shared"; import { ArrowRightIcon } from "@phosphor-icons/react"; import { Button } from "@/components/v2/buttons/Button"; import { CustomerPreview, useCustomerCount } from "./filters/CustomerPreview"; @@ -8,6 +8,14 @@ import type { useMigrationEditorForm } from "./useMigrationEditorForm"; type FormInstance = ReturnType["form"]; +function hasActiveFilter(filter: CustomerFilter): boolean { + if (filter.customer_id) return true; + if (!filter.plan) return false; + const plan = filter.plan; + if (typeof plan !== "object") return false; + return Object.values(plan).some((v) => v !== undefined && v !== ""); +} + export function FilterStep({ form, filter, @@ -21,8 +29,10 @@ export function FilterStep({ onStepChange: (step: StepId) => void; onNext: () => void; }) { - const customerCount = useCustomerCount(filter.customer ?? {}); + const customerFilter = filter.customer ?? {}; + const customerCount = useCustomerCount(customerFilter); const hasCustomers = customerCount !== null && customerCount > 0; + const showPreview = hasActiveFilter(customerFilter); return (
@@ -33,7 +43,7 @@ export function FilterStep({ onClick={onNext} disabled={!hasCustomers} > - {hasCustomers ? `Next (${customerCount})` : "Next"} + {hasCustomers ? `Next (${customerCount.toLocaleString()})` : "Next"} @@ -41,7 +51,7 @@ export function FilterStep({ value={filter} onChange={(v) => form.setFieldValue("filter", v)} /> - + {showPreview && }
); } diff --git a/vite/src/views/migrations/migration/MigrationEditor.tsx b/vite/src/views/migrations/migration/MigrationEditor.tsx index 97e2b12c9..9fe3591d0 100644 --- a/vite/src/views/migrations/migration/MigrationEditor.tsx +++ b/vite/src/views/migrations/migration/MigrationEditor.tsx @@ -10,6 +10,7 @@ import { useMigrationSheetStore } from "./live/useMigrationSheetStore"; import { OperationsStep } from "./OperationsStep"; import { STEPS, type StepId } from "./StepIndicator"; import { useMigrationEditorForm } from "./useMigrationEditorForm"; +import { useMigrationRunsQuery } from "@/hooks/queries/useMigrationRunsQuery"; const STEP_IDS = STEPS.map((s) => s.id); @@ -29,6 +30,8 @@ export function MigrationEditor({ migration }: { migration: Migration }) { ); const customerCount = useCustomerCount(filter.customer ?? {}); const hasCustomers = customerCount !== null && customerCount > 0; + const { runs } = useMigrationRunsQuery({ migrationId: migration.id }); + const hasRuns = runs.length > 0; const setLiveFormState = useMigrationSheetStore((s) => s.setLiveFormState); useEffect(() => { @@ -38,6 +41,7 @@ export function MigrationEditor({ migration }: { migration: Migration }) { const guardedSetStep = useGuardedStepNavigation({ step, hasCustomers, + hasRuns, operations, saveError, enableErrorDisplay, @@ -74,8 +78,7 @@ export function MigrationEditor({ migration }: { migration: Migration }) { operations={operations} noBillingChanges={noBillingChanges} step={step} - onStepChange={guardedSetStep} - onPrevious={() => setStep("operations")} + onStepChange={guardedSetStep} /> )}
diff --git a/vite/src/views/migrations/migration/MigrationView.tsx b/vite/src/views/migrations/migration/MigrationView.tsx index a14d001fe..24533b873 100644 --- a/vite/src/views/migrations/migration/MigrationView.tsx +++ b/vite/src/views/migrations/migration/MigrationView.tsx @@ -1,16 +1,16 @@ -import { AnimatePresence, motion } from "motion/react"; +import { motion } from "motion/react"; import { useCallback, useEffect } from "react"; -import { createPortal } from "react-dom"; import { useHotkeys } from "react-hotkeys-hook"; import { useNavigate, useParams } from "react-router"; +import { AdminHover } from "@/components/general/AdminHover"; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; -import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; +import { InlineSheetPanel } from "@/components/v2/sheets/InlineSheetPanel"; +import { SheetBackdrop } from "@/components/v2/sheets/SheetBackdrop"; import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; import { navigateTo } from "@/utils/genUtils"; import { SHEET_ANIMATION } from "@/views/customers2/customer/customerAnimations"; @@ -74,7 +74,16 @@ export function MigrationView() { - {migration.id} + + {migration.id} + @@ -84,44 +93,22 @@ export function MigrationView() {
- {createPortal( - - {selectedCustomer && ( - - )} - , - document.body, - )} + - + {selectedCustomer && ( - - - - - - + )} - +
); } diff --git a/vite/src/views/migrations/migration/StepIndicator.tsx b/vite/src/views/migrations/migration/StepIndicator.tsx index 5375f8480..b1b6d6ebb 100644 --- a/vite/src/views/migrations/migration/StepIndicator.tsx +++ b/vite/src/views/migrations/migration/StepIndicator.tsx @@ -19,10 +19,12 @@ export const STEPS: { id: StepId; label: string; icon: Icon }[] = [ export function StepIndicator({ step, onStepChange, + stepMeta, children, }: { step: StepId; onStepChange: (step: StepId) => void; + stepMeta?: Partial>; children?: ReactNode; }) { return ( @@ -39,7 +41,9 @@ export function StepIndicator({ onClick={() => onStepChange(s.id)} className={cn( "flex items-center gap-2 text-md cursor-pointer transition-colors", - isActive ? "text-foreground font-medium" : "text-tertiary-foreground hover:text-muted-foreground", + isActive + ? "text-foreground font-medium" + : "text-tertiary-foreground hover:text-muted-foreground", )} > {s.label} + {stepMeta?.[s.id]}
); diff --git a/vite/src/views/migrations/migration/filters/CustomerPreview.tsx b/vite/src/views/migrations/migration/filters/CustomerPreview.tsx index ca83d5344..d601244d0 100644 --- a/vite/src/views/migrations/migration/filters/CustomerPreview.tsx +++ b/vite/src/views/migrations/migration/filters/CustomerPreview.tsx @@ -1,13 +1,13 @@ import type { CustomerFilter, CustomerWithProducts } from "@autumn/shared"; import { + ArrowSquareOutIcon, CaretLeftIcon, CaretRightIcon, ListMagnifyingGlassIcon, - UsersIcon, } from "@phosphor-icons/react"; -import type { PaginationState } from "@tanstack/react-table"; -import { debounce } from "lodash"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { useDeferredValue, useState } from "react"; +import { Link } from "react-router"; import { Table } from "@/components/general/table"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { Input } from "@/components/v2/inputs/Input"; @@ -21,85 +21,88 @@ import { import { Separator } from "@/components/v2/separator"; import { useMigrationFilterPreview } from "@/hooks/queries/useMigrationFilterPreview"; import { cn } from "@/lib/utils"; +import { + CUSTOMER_LIST_PAGE_SIZE_OPTIONS, + DEFAULT_CUSTOMER_LIST_PAGE_SIZE, +} from "@/utils/constants/customerListPagination"; +import { pushPage } from "@/utils/genUtils"; import { createCustomerListColumns } from "@/views/customers2/components/table/customer-list/CustomerListColumns"; import { useProductTable } from "@/views/products/hooks/useProductTable"; +import { useCursorPagination } from "../shared/useCursorPagination"; -const PAGE_SIZE_OPTIONS = [10, 50, 100, 250]; +const previewColumns = createCustomerListColumns() + .filter((col) => col.id !== "actions") + .map((column) => { + if (column.id !== "name") return column; + return { + ...column, + cell: ({ row }: { row: Row }) => { + const customer = row.original; + const customerId = customer.id || customer.internal_id; + return ( + event.stopPropagation()} + className="group/link inline-flex max-w-full items-center gap-1.5 text-foreground hover:text-primary" + > + + {customer.name || customerId} + + + + ); + }, + } satisfies ColumnDef; + }) as ColumnDef[]; export function CustomerPreview({ filter }: { filter: CustomerFilter }) { const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 10, + const deferredSearch = useDeferredValue(search.trim()); + const [pageSize, setPageSize] = useState(DEFAULT_CUSTOMER_LIST_PAGE_SIZE); + const { + currentCursor, + currentPage, + pagination, + canPrev, + pushCursor, + popCursor, + } = useCursorPagination({ + pageSize, + resetKey: JSON.stringify({ filter, pageSize, search: search.trim() }), }); - const debouncedSetSearch = useMemo( - () => debounce((q: string) => setDebouncedSearch(q), 350), - [], - ); - - useEffect(() => () => debouncedSetSearch.cancel(), [debouncedSetSearch]); - - const handleSearchChange = useCallback( - (e: React.ChangeEvent) => { - setSearch(e.target.value); - setPagination((p) => ({ ...p, pageIndex: 0 })); - debouncedSetSearch(e.target.value.trim()); - }, - [debouncedSetSearch], - ); - - const filterKey = useMemo(() => JSON.stringify(filter), [filter]); - useEffect(() => { - setPagination((p) => ({ ...p, pageIndex: 0 })); - }, [filterKey]); - - const { count, customers, isLoading } = useMigrationFilterPreview({ + const { count, customers, nextCursor, isLoading } = useMigrationFilterPreview({ filter, - search: debouncedSearch, - page: pagination.pageIndex, - pageSize: pagination.pageSize, + search: deferredSearch, + cursor: currentCursor, + pageSize, }); const pageCount = - count !== null ? Math.max(Math.ceil(count / pagination.pageSize), 1) : 1; - const columns = useMemo( - () => createCustomerListColumns().filter((col) => col.id !== "actions"), - [], - ); + count !== null ? Math.max(Math.ceil(count / pageSize), 1) : 1; const table = useProductTable({ data: customers, - columns, + columns: previewColumns, options: { manualPagination: true, pageCount, state: { pagination }, - onPaginationChange: setPagination, }, }); - - const currentPage = pagination.pageIndex + 1; - const canPrev = pagination.pageIndex > 0; - const canNext = count !== null && currentPage < pageCount; + const canGoNext = Boolean(nextCursor); + const isDisabled = isLoading; return (
- - - - Filtered Customers - - - - {count !== null - ? `${count} ${count === 1 ? "match" : "matches"}` - : ""} - - -
setSearch(e.target.value)} className="pl-8! text-sm" placeholder={`Search ${count ?? 0} customers`} /> @@ -118,11 +121,11 @@ export function CustomerPreview({ filter }: { filter: CustomerFilter }) { variant="secondary" size="default" icon={} - onClick={() => - setPagination((p) => ({ ...p, pageIndex: p.pageIndex - 1 })) - } - disabled={!canPrev} - className={cn(!canPrev && "pointer-events-none opacity-50")} + onClick={popCursor} + disabled={isDisabled || !canPrev} + className={cn( + (isDisabled || !canPrev) && "pointer-events-none opacity-50", + )} /> {currentPage} / {pageCount} @@ -131,26 +134,29 @@ export function CustomerPreview({ filter }: { filter: CustomerFilter }) { variant="secondary" size="default" icon={} - onClick={() => - setPagination((p) => ({ ...p, pageIndex: p.pageIndex + 1 })) - } - disabled={!canNext} - className={cn(!canNext && "pointer-events-none opacity-50")} + onClick={() => nextCursor && pushCursor(nextCursor)} + disabled={isDisabled || !canGoNext} + className={cn( + (isDisabled || !canGoNext) && "pointer-events-none opacity-50", + )} /> - onChange({ - ...rule, - values: e.target.value - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - }) - } - /> - ); + if (isMulti) return ; return ( ); } + +function CommaSeparatedInput({ + rule, + onChange, +}: { + rule: FilterRule; + onChange: (rule: FilterRule) => void; +}) { + const [text, setText] = useState(() => rule.values.join(", ")); + + useEffect(() => { + setText(rule.values.join(", ")); + }, [rule.values.join(",")]); + + const commit = (raw: string) => { + const values = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + onChange({ ...rule, values }); + }; + + return ( + setText(e.target.value)} + onBlur={(e) => commit(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commit(e.currentTarget.value); + }} + /> + ); +} diff --git a/vite/src/views/migrations/migration/filters/filterRowTypes.ts b/vite/src/views/migrations/migration/filters/filterRowTypes.ts index 1bdcd23a3..53fc111ea 100644 --- a/vite/src/views/migrations/migration/filters/filterRowTypes.ts +++ b/vite/src/views/migrations/migration/filters/filterRowTypes.ts @@ -4,14 +4,12 @@ export type FilterField = | "customer_id" | "plan_id" | "version" + | "custom" | "paid" | "recurring" | "price" | "item_feature_id" - | "item_unlimited" - | "item_price" - | "item_billing_method" - | "item_mode"; + | "item_unlimited"; export type FilterOperator = | "is" @@ -22,6 +20,7 @@ export type FilterOperator = | "starts_with" | "exists" | "not_exists" + | "none" | "gt" | "gte" | "lt" @@ -44,14 +43,12 @@ export const FILTER_FIELD_OPTIONS: { { value: "customer_id", label: "Customer" }, { value: "plan_id", label: "Plan" }, { value: "version", label: "Version" }, + { value: "custom", label: "Custom" }, { value: "paid", label: "Paid" }, { value: "recurring", label: "Recurring" }, { value: "price", label: "Base Price" }, { value: "item_feature_id", label: "Feature" }, { value: "item_unlimited", label: "Unlimited" }, - { value: "item_price", label: "Item Price" }, - { value: "item_billing_method", label: "Billing Method" }, - { value: "item_mode", label: "Match Mode" }, ]; type OperatorOption = { value: FilterOperator; label: string }; @@ -69,6 +66,13 @@ const STRING_OPERATORS: OperatorOption[] = [ { value: "starts_with", label: "starts with" }, ]; +// Plan adds "has none" — selects customers with no active plans at all +// (compiles to the `$none` quantifier, not a per-plan matcher). +const PLAN_OPERATORS: OperatorOption[] = [ + ...STRING_OPERATORS, + { value: "none", label: "has none" }, +]; + const STRING_MATCH_OPERATORS: OperatorOption[] = [ { value: "is", label: "is" }, { value: "is_not", label: "is not" }, @@ -102,22 +106,14 @@ const NULLABLE_ONLY: FieldConfig = { export const FIELD_CONFIGS: Record = { customer_id: { operators: STRING_MATCH_OPERATORS, valueType: "string" }, - plan_id: { operators: STRING_OPERATORS, valueType: "string" }, + plan_id: { operators: PLAN_OPERATORS, valueType: "string" }, version: { operators: NUMBER_OPERATORS, valueType: "number" }, + custom: BOOLEAN_ONLY, paid: BOOLEAN_ONLY, recurring: BOOLEAN_ONLY, price: NULLABLE_ONLY, item_feature_id: { operators: STRING_MATCH_OPERATORS, valueType: "string" }, item_unlimited: BOOLEAN_ONLY, - item_price: NULLABLE_ONLY, - item_billing_method: { - operators: STRING_MATCH_OPERATORS, - valueType: "string", - }, - item_mode: { - operators: [{ value: "is", label: "is" }], - valueType: "string", - }, }; function stringMatcherToRule( @@ -284,19 +280,6 @@ function nullableToRule(field: FilterField, value: unknown): FilterRule | null { return { field, operator: "exists", values: [] }; } -type ArrayFilterMode = "$some" | "$every" | "$none"; - -function detectArrayFilterMode(item: Record): { - mode: ArrayFilterMode; - inner: Record; -} { - for (const key of ["$some", "$every", "$none"] as const) { - if (key in item && item[key] && typeof item[key] === "object") - return { mode: key, inner: item[key] as Record }; - } - return { mode: "$some", inner: item }; -} - export function planFilterToGroups(filter: PlanFilter): FilterGroupData[] { const mainRules: FilterRule[] = []; @@ -305,6 +288,9 @@ export function planFilterToGroups(filter: PlanFilter): FilterGroupData[] { mainRules.push(...numberMatcherToRules("version", filter.version)); + if (filter.custom !== undefined) + mainRules.push(booleanRule("custom", filter.custom)); + if (filter.paid !== undefined) mainRules.push(booleanRule("paid", filter.paid)); @@ -315,21 +301,10 @@ export function planFilterToGroups(filter: PlanFilter): FilterGroupData[] { if (priceRule) mainRules.push(priceRule); if (filter.item !== undefined) { - const item = + const inner = typeof filter.item === "object" && filter.item !== null ? filter.item : {}; - const { mode, inner } = detectArrayFilterMode( - item as Record, - ); - - if (mode !== "$some") { - mainRules.push({ - field: "item_mode", - operator: "is", - values: [mode.slice(1)], - }); - } const featureRule = stringMatcherToRule( "item_feature_id", @@ -339,27 +314,10 @@ export function planFilterToGroups(filter: PlanFilter): FilterGroupData[] { if (inner.unlimited !== undefined) mainRules.push(booleanRule("item_unlimited", Boolean(inner.unlimited))); - - const itemPriceRule = nullableToRule("item_price", inner.price); - if (itemPriceRule) mainRules.push(itemPriceRule); - - if ( - inner.price && - typeof inner.price === "object" && - inner.price !== null - ) { - const priceObj = inner.price as Record; - if (priceObj.billing_method !== undefined) { - const bmRule = stringMatcherToRule( - "item_billing_method", - priceObj.billing_method as StringMatcher | undefined, - ); - if (bmRule) mainRules.push(bmRule); - } - } } - const groups: FilterGroupData[] = [{ rules: mainRules }]; + const groups: FilterGroupData[] = + mainRules.length > 0 ? [{ rules: mainRules }] : []; if (filter.$or) { for (const orFilter of filter.$or) { @@ -368,22 +326,21 @@ export function planFilterToGroups(filter: PlanFilter): FilterGroupData[] { } } - return groups; + return groups.length > 0 ? groups : [{ rules: [] }]; } -export function groupsToPlanFilter(groups: FilterGroupData[]): PlanFilter { - const main = groups[0]; - if (!main) return {}; - +function groupToPlanFilter(group: FilterGroupData): PlanFilter { const filter: PlanFilter = {}; let hasItemFields = false; const itemInner: Record = {}; - let itemMode: ArrayFilterMode = "$some"; const versionFragments: Record[] = []; + const hasStringValue = (rule: FilterRule) => + rule.values.some((value) => value.trim().length > 0); - for (const rule of main.rules) { + for (const rule of group.rules) { switch (rule.field) { case "plan_id": + if (!hasStringValue(rule)) break; filter.plan_id = ruleToStringMatcher(rule); break; case "version": { @@ -391,6 +348,9 @@ export function groupsToPlanFilter(groups: FilterGroupData[]): PlanFilter { if (fragment) versionFragments.push(fragment); break; } + case "custom": + filter.custom = rule.values[0] === "true"; + break; case "paid": filter.paid = rule.values[0] === "true"; break; @@ -401,6 +361,7 @@ export function groupsToPlanFilter(groups: FilterGroupData[]): PlanFilter { filter.price = rule.operator === "exists" ? { $ne: null } : null; break; case "item_feature_id": + if (!hasStringValue(rule)) break; hasItemFields = true; itemInner.feature_id = ruleToStringMatcher(rule); break; @@ -408,45 +369,29 @@ export function groupsToPlanFilter(groups: FilterGroupData[]): PlanFilter { hasItemFields = true; itemInner.unlimited = rule.values[0] === "true"; break; - case "item_price": - hasItemFields = true; - itemInner.price = rule.operator === "exists" ? { $ne: null } : null; - break; - case "item_billing_method": { - hasItemFields = true; - const existingPrice = - itemInner.price && typeof itemInner.price === "object" - ? (itemInner.price as Record) - : {}; - itemInner.price = { - ...existingPrice, - billing_method: ruleToStringMatcher(rule), - }; - break; - } - case "item_mode": - itemMode = `$${rule.values[0] ?? "some"}` as ArrayFilterMode; - break; } } if (hasItemFields) { - filter.item = - itemMode === "$some" - ? (itemInner as PlanFilter["item"]) - : ({ [itemMode]: itemInner } as PlanFilter["item"]); + filter.item = itemInner as PlanFilter["item"]; } const versionMatcher = mergeNumberFragments(versionFragments); if (versionMatcher !== undefined) filter.version = versionMatcher; - if (groups.length > 1) { - filter.$or = groups.slice(1).map((group) => groupsToPlanFilter([group])); - } - return filter; } +export function groupsToPlanFilter(groups: FilterGroupData[]): PlanFilter { + const branches = groups + .map(groupToPlanFilter) + .filter((filter) => Object.keys(filter).length > 0); + + if (branches.length === 0) return {}; + if (branches.length === 1) return branches[0]; + return { $or: branches }; +} + export function customerIdToStrings( matcher: StringMatcher | undefined, ): string[] { diff --git a/vite/src/views/migrations/migration/hooks/useGuardedStepNavigation.ts b/vite/src/views/migrations/migration/hooks/useGuardedStepNavigation.ts index 284bc5820..1d1768324 100644 --- a/vite/src/views/migrations/migration/hooks/useGuardedStepNavigation.ts +++ b/vite/src/views/migrations/migration/hooks/useGuardedStepNavigation.ts @@ -8,6 +8,7 @@ const STEP_ORDER: StepId[] = ["filter", "operations", "live"]; export function useGuardedStepNavigation({ step, hasCustomers, + hasRuns, operations, saveError, enableErrorDisplay, @@ -15,6 +16,7 @@ export function useGuardedStepNavigation({ }: { step: StepId; hasCustomers: boolean; + hasRuns: boolean; operations: Operations; saveError: string | null; enableErrorDisplay: () => void; @@ -25,12 +27,12 @@ export function useGuardedStepNavigation({ const currentIndex = STEP_ORDER.indexOf(step); const targetIndex = STEP_ORDER.indexOf(target); if (targetIndex <= currentIndex) return setStep(target); - if (targetIndex >= 1 && !hasCustomers) return; + if (targetIndex >= 1 && !hasCustomers && !hasRuns) return; if (targetIndex >= 2 && (!hasValidOperations(operations) || !!saveError)) return; if (targetIndex === 2) enableErrorDisplay(); setStep(target); }, - [step, hasCustomers, operations, saveError, enableErrorDisplay, setStep], + [step, hasCustomers, hasRuns, operations, saveError, enableErrorDisplay, setStep], ); } diff --git a/vite/src/views/migrations/migration/hooks/useRealtimeSubscriptions.ts b/vite/src/views/migrations/migration/hooks/useRealtimeSubscriptions.ts index 148c30c76..5d9fd39f0 100644 --- a/vite/src/views/migrations/migration/hooks/useRealtimeSubscriptions.ts +++ b/vite/src/views/migrations/migration/hooks/useRealtimeSubscriptions.ts @@ -1,10 +1,15 @@ import type { AxiosError } from "axios"; import { useCallback, useState } from "react"; import { toast } from "sonner"; -import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; +import { + type RetryableMigrationItemRunStatus, + useMigrationsQuery, +} from "@/hooks/queries/useMigrationsQuery"; import { getBackendErr } from "@/utils/genUtils"; import type { RealtimeRunSubscription } from "./useMigrationRunRealtime"; +const SETTLE_WINDOW_MS = 15000; + export function useRealtimeSubscriptions({ migrationId, invalidateRuns, @@ -16,12 +21,15 @@ export function useRealtimeSubscriptions({ const [subscriptions, setSubscriptions] = useState( [], ); + const [isSettling, setIsSettling] = useState(false); const handleComplete = useCallback( (triggerRunId: string) => { setSubscriptions((prev) => prev.filter((s) => s.triggerRunId !== triggerRunId), ); + setIsSettling(true); + window.setTimeout(() => setIsSettling(false), SETTLE_WINDOW_MS); invalidateRuns(); }, [invalidateRuns], @@ -31,18 +39,32 @@ export function useRealtimeSubscriptions({ dryRun, limit, only, + lazyRun, + concurrency, + retryItemStatuses, }: { dryRun: boolean; limit?: number; only?: string[]; + lazyRun?: boolean; + concurrency?: number; + retryItemStatuses?: RetryableMigrationItemRunStatus[]; }) => { try { + const isTargetedRun = only !== undefined && only.length > 0; + const retryStatuses = + retryItemStatuses && retryItemStatuses.length > 0 + ? retryItemStatuses + : undefined; const result = await runMigration({ id: migrationId, dry_run: dryRun, limit, only, - lazy_run: true, + lazy_run: isTargetedRun ? false : (lazyRun ?? true), + concurrency, + retry_item_statuses: + retryStatuses ?? (isTargetedRun ? ["failed"] : undefined), }); if (result.trigger_run_id && result.public_access_token) { setSubscriptions((prev) => [ @@ -67,6 +89,7 @@ export function useRealtimeSubscriptions({ return { subscriptions, hasActive: subscriptions.length > 0, + isSettling, handleComplete, triggerRun, isRunning, diff --git a/vite/src/views/migrations/migration/live/CustomerRunSheet.tsx b/vite/src/views/migrations/migration/live/CustomerRunSheet.tsx index c0422556d..b57e05e41 100644 --- a/vite/src/views/migrations/migration/live/CustomerRunSheet.tsx +++ b/vite/src/views/migrations/migration/live/CustomerRunSheet.tsx @@ -1,5 +1,6 @@ -import type { CustomerWithProducts, Operations } from "@autumn/shared"; +import type { Operations } from "@autumn/shared"; import { + ArrowSquareOutIcon, CalendarBlankIcon, EyeIcon, LightningIcon, @@ -8,8 +9,10 @@ import { } from "@phosphor-icons/react"; import { format } from "date-fns"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router"; import { Badge } from "@/components/v2/badges/Badge"; import { Button } from "@/components/v2/buttons/Button"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; import { Dialog, DialogContent, @@ -20,49 +23,68 @@ import { } from "@/components/v2/dialogs/Dialog"; import { InfoRow } from "@/components/v2/InfoRow"; import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; +import type { MigrationPreviewCustomer } from "@/hooks/queries/useMigrationFilterPreview"; import type { MigrationItemEvent } from "@/hooks/queries/useMigrationRunsQuery"; +import { navigateTo } from "@/utils/genUtils"; import { ActiveRunDot, ItemEventStatusBadge } from "../runs/RunStatusBadge"; +import { OperationsPreview } from "../shared/OperationsPreview"; import { RunSummaryRows } from "../shared/RunSummaryRows"; import { EventResultDetail } from "./EventResultDetail"; +import { resolveMigrationItemStatus } from "./migrationItemStatus"; function formatEventTimestamp(timestamp: string): string { return format(new Date(timestamp), "MMM d, HH:mm:ss"); } function StatusValue({ + itemRun, latestDryEvent, latestLiveEvent, isActive, activeRunDryRun, }: { + itemRun: MigrationPreviewCustomer["migration_item_run"]; latestDryEvent: MigrationItemEvent | undefined; latestLiveEvent: MigrationItemEvent | undefined; isActive: boolean; activeRunDryRun: boolean | null; }) { - if (isActive) + const status = resolveMigrationItemStatus({ + event: latestLiveEvent ?? latestDryEvent, + itemRun, + activeStatus: isActive ? "running" : null, + }); + + if (status.kind === "running" || status.kind === "queued") return (
- {activeRunDryRun ? "Dry run in progress" : "Running"} + {status.kind === "running" && activeRunDryRun + ? "Dry run in progress" + : status.kind === "queued" + ? "Queued" + : "Running"}
); - const event = latestLiveEvent ?? latestDryEvent; - if (event) + + if (status.kind === "result") return (
- {event.dry_run && ( - Dry Run: + {status.dryRun && ( + + Dry Run: + )}
); + return Not Run; } @@ -74,21 +96,24 @@ export function CustomerRunSheet({ isActive, activeRunDryRun, isRunning, + isRunInProgress, onTriggerRun, operations, noBillingChanges, }: { - customer: CustomerWithProducts; + customer: MigrationPreviewCustomer; latestDryEvent: MigrationItemEvent | undefined; latestLiveEvent: MigrationItemEvent | undefined; allEvents: MigrationItemEvent[]; isActive: boolean; activeRunDryRun: boolean | null; isRunning: boolean; + isRunInProgress: boolean; onTriggerRun: (opts: { dryRun: boolean; only?: string[] }) => void; operations: Operations; noBillingChanges: boolean; }) { + const navigate = useNavigate(); const customerId = customer.id ?? customer.internal_id; const [isRunDialogOpen, setIsRunDialogOpen] = useState(false); const lastActionRef = useRef<"dry" | "live" | null>(null); @@ -104,11 +129,13 @@ export function CustomerRunSheet({ ); const handleDryRun = () => { + if (isRunInProgress) return; lastActionRef.current = "dry"; onTriggerRun({ dryRun: true, only: [customerId] }); }; const handleLiveRun = () => { + if (isRunInProgress) return; setIsRunDialogOpen(false); lastActionRef.current = "live"; onTriggerRun({ dryRun: false, only: [customerId] }); @@ -131,7 +158,18 @@ export function CustomerRunSheet({ - {customer.name || customerId} + {isActive && }
} @@ -145,6 +183,7 @@ export function CustomerRunSheet({ label="Status" value={ - + Live Run @@ -185,7 +228,11 @@ export function CustomerRunSheet({ title={
- + Preview @@ -220,29 +267,30 @@ export function CustomerRunSheet({ )} -
+
- +
@@ -261,6 +309,7 @@ export function CustomerRunSheet({ operations={operations} noBillingChanges={noBillingChanges} /> + - + diff --git a/vite/src/views/migrations/migration/live/EventResultDetail.tsx b/vite/src/views/migrations/migration/live/EventResultDetail.tsx index 89fabc7de..56feeb556 100644 --- a/vite/src/views/migrations/migration/live/EventResultDetail.tsx +++ b/vite/src/views/migrations/migration/live/EventResultDetail.tsx @@ -1,20 +1,39 @@ import type { Feature } from "@autumn/shared"; +import type { + CustomerPlanChange, + CustomerPlanItemChange, +} from "@autumn/shared/api/billing/common/customerPlanChange"; import { PackageIcon } from "@phosphor-icons/react"; +import { SubscriptionItemRow } from "@/components/forms/update-subscription-v2/components/SubscriptionItemRow"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/v2/tooltips/Tooltip"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import type { MigrationItemEvent } from "@/hooks/queries/useMigrationRunsQuery"; import { cn } from "@/lib/utils"; import { getFeatureIconConfig } from "@/views/products/features/utils/getFeatureIcon"; +import { migrationItemToProductItem } from "../shared/migrationItemUtils"; -type ItemChange = { action?: string; feature_id?: string }; -type PlanChange = { - action?: string; +type ItemChange = Partial; +type PlanChange = Partial & { plan_id?: string; entity_id?: string | null; item_changes?: ItemChange[]; }; +type BalanceSnapshot = { + granted?: number; + remaining?: number; + usage?: number; + unlimited?: boolean; + next_reset_at?: number | null; +}; type BalanceChange = { feature_id?: string; - before?: { granted?: number; remaining?: number; usage?: number }; + balance?: BalanceSnapshot; + previous_attributes?: BalanceSnapshot; + before?: BalanceSnapshot; granted?: number; }; type FlagChange = { action?: string; feature_id?: string }; @@ -23,6 +42,12 @@ type MigrationPreview = { balance_changes?: (string | BalanceChange)[]; flag_changes?: (string | FlagChange)[]; }; +type ErrorPayload = { + message?: unknown; + error?: unknown; + code?: unknown; + path?: unknown; +}; function parseJson(raw: string | T): T | null { if (typeof raw !== "string") return raw; @@ -39,16 +64,46 @@ function parseList(raw: (string | T)[] | undefined): T[] { .filter((c): c is T => c !== null); } +function formatUnknownError(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") + return String(value); + if (Array.isArray(value)) + return value.map(formatUnknownError).filter(Boolean).join("\n"); + + if (typeof value === "object") { + const payload = value as ErrorPayload; + const message = formatUnknownError(payload.message ?? payload.error); + const prefix = [payload.code, payload.path].filter(Boolean).join(" "); + if (message) return prefix ? `${prefix}: ${message}` : message; + + try { + return JSON.stringify(value, null, 2); + } catch { + return "Unknown error"; + } + } + + return "Unknown error"; +} + const DOT_COLORS: Record = { + activated: "bg-green-500", + scheduled: "bg-blue-500", updated: "bg-amber-500", created: "bg-green-500", + expired: "bg-red-500", removed: "bg-red-500", deleted: "bg-red-500", }; const ACTION_LABELS: Record = { + activated: "New", + scheduled: "Scheduled", updated: "Changed", created: "New", + expired: "Removed", removed: "Removed", deleted: "Removed", }; @@ -60,32 +115,19 @@ function StatusDot({ action }: { action: string }) { "size-2 rounded-full shrink-0", DOT_COLORS[action] ?? "bg-tertiary-foreground", )} - title={ACTION_LABELS[action] ?? action} /> ); } -function FeatureIcon({ - featureId, - features, -}: { - featureId: string | undefined; - features: Feature[]; -}) { - const feature = features.find((f) => f.id === featureId); - const config = feature - ? getFeatureIconConfig(feature.type, feature.config?.usage_type, 14) - : getFeatureIconConfig(null, null, 14); - return {config.icon}; +function getPlanId(change: PlanChange): string | undefined { + return change.subscription?.plan_id ?? change.purchase?.plan_id ?? change.plan_id; +} + +function getPlanStatus(change: PlanChange): string | undefined { + return change.subscription?.status ?? change.purchase?.status; } -const ROW_TINTS: Record = { - created: "border-green-500/20 bg-green-500/5", - updated: "border-amber-500/20 bg-amber-500/5", - removed: "border-red-500/20 bg-red-500/5", - deleted: "border-red-500/20 bg-red-500/5", -}; function ChangeRow({ action, @@ -99,8 +141,7 @@ function ChangeRow({ return (
@@ -109,15 +150,118 @@ function ChangeRow({ ); } -function PlanChangeRows({ - change, - features, +function buildItemTooltipLines( + apiItem: Record, + feature: Feature | undefined, +): string[] { + const lines: string[] = []; + if (feature?.name) lines.push(feature.name); + if (apiItem.unlimited === true) lines.push("Unlimited"); + else if (typeof apiItem.included === "number") + lines.push(`Included: ${(apiItem.included as number).toLocaleString()}`); + + const reset = apiItem.reset as { interval?: string } | undefined; + if (reset?.interval) lines.push(`Resets: ${reset.interval}`); + + const price = apiItem.price as { + amount?: number; + interval?: string; + billing_method?: string; + } | null; + if (price) { + const parts: string[] = []; + if (price.billing_method) parts.push(price.billing_method.replaceAll("_", " ")); + if (typeof price.amount === "number") parts.push(`$${price.amount}`); + if (price.interval) parts.push(`per ${price.interval}`); + if (parts.length > 0) lines.push(parts.join(" · ")); + } + return lines; +} + +function ItemChangeRow({ + item, }: { - change: PlanChange; - features: Feature[]; + item: ItemChange; }) { + const { features } = useFeaturesQuery(); + const action = item.action ?? "unknown"; + + const apiItem = item.item as Record | undefined; + const productItem = apiItem + ? migrationItemToProductItem(apiItem, features) + : null; + + const feature = features.find((f) => f.id === item.feature_id); + const isDeleted = action === "deleted"; + const isCreated = action === "created"; + + const tooltipLines = apiItem + ? buildItemTooltipLines(apiItem, feature) + : []; + + const row = productItem ? ( +
+ +
+ ) : ( + + + + + {feature?.name ?? item.feature_id} + + + ); + + if (tooltipLines.length === 0) return row; + + return ( + + {row} + + {tooltipLines.map((line) => ( +
{line}
+ ))} +
+
+ ); +} + +function FeatureIconByFeatureId({ featureId }: { featureId: string | undefined }) { + const { features } = useFeaturesQuery(); + const feature = features.find((f) => f.id === featureId); + const config = feature + ? getFeatureIconConfig(feature.type, feature.config?.usage_type, 14) + : getFeatureIconConfig(null, null, 14); + return {config.icon}; +} + + +function balanceToItemChange(bc: BalanceChange, action = "updated"): ItemChange { + const balance = bc.balance ?? {}; + const item: Record = { feature_id: bc.feature_id }; + if (balance.unlimited) item.unlimited = true; + else if (balance.granted !== undefined) item.included = balance.granted; + else if (bc.granted !== undefined) item.included = bc.granted; + return { action, feature_id: bc.feature_id, item }; +} + +function flagToItemChange(fc: FlagChange, action?: string): ItemChange { + return { action: action ?? fc.action ?? "updated", feature_id: fc.feature_id, item: { feature_id: fc.feature_id } }; +} + +function PlanChangeRows({ change, absorbedBalances, absorbedFlags }: { change: PlanChange; absorbedBalances?: BalanceChange[]; absorbedFlags?: FlagChange[] }) { const action = change.action ?? "unknown"; const items = change.item_changes ?? []; + const planId = getPlanId(change); + const status = getPlanStatus(change); + const hasAbsorbed = (absorbedBalances?.length ?? 0) > 0 || (absorbedFlags?.length ?? 0) > 0; return ( <> @@ -128,27 +272,26 @@ function PlanChangeRows({ - {change.plan_id ?? "Unknown plan"} + {planId ?? "Unknown plan"} + {status && ( + {status} + )} {items.map((item, i) => ( - - - - {ACTION_LABELS[item.action ?? "unknown"] ?? item.action} - - - - {features.find((f) => f.id === item.feature_id)?.name ?? - item.feature_id} - - + ))} - {items.length === 0 && action === "updated" && ( + {items.length === 0 && hasAbsorbed && ( + <> + {absorbedFlags?.map((fc, i) => ( + + ))} + {absorbedBalances?.map((bc) => ( + + ))} + + )} + {items.length === 0 && !hasAbsorbed && action === "updated" && (
Price, version, or settings changed @@ -159,85 +302,58 @@ function PlanChangeRows({ ); } -function BalanceChangeRow({ - change, - features, -}: { - change: BalanceChange; - features: Feature[]; -}) { - const feature = features.find((f) => f.id === change.feature_id); - - return ( - - - Updated - - - {feature?.name ?? change.feature_id} - - - {change.before ? ( - <> - {change.before.granted ?? 0} - - {change.granted ?? 0} - - ) : ( - {change.granted ?? 0} - )} - - - ); -} - -function FlagChangeRow({ - change, - features, -}: { - change: FlagChange; - features: Feature[]; -}) { - const feature = features.find((f) => f.id === change.feature_id); - - const action = change.action ?? "unknown"; - return ( - - - - {ACTION_LABELS[action] ?? action} - - - - {feature?.name ?? change.feature_id} - - - ); -} - function PreviewSummary({ preview }: { preview: MigrationPreview }) { - const { features } = useFeaturesQuery(); const planChanges = parseList(preview.plan_changes); - const balanceChanges = parseList(preview.balance_changes); - const flagChanges = parseList(preview.flag_changes); + const allBalanceChanges = parseList(preview.balance_changes); + const allFlagChanges = parseList(preview.flag_changes); - if (planChanges.length + balanceChanges.length + flagChanges.length === 0) + const itemChangeFeatureIds = new Set(); + for (const pc of planChanges) { + for (const ic of pc.item_changes ?? []) { + if (ic.feature_id) itemChangeFeatureIds.add(ic.feature_id); + } + } + + const standaloneBalanceChanges = allBalanceChanges.filter( + (bc) => bc.feature_id && !itemChangeFeatureIds.has(bc.feature_id), + ); + const standaloneFlagChanges = allFlagChanges.filter( + (fc) => fc.feature_id && !itemChangeFeatureIds.has(fc.feature_id), + ); + + // New plans without item_changes absorb standalone balance/flag changes as children + const newPlanIndex = planChanges.findIndex( + (pc) => + (pc.action === "activated" || pc.action === "created") && + !(pc.item_changes?.length), + ); + const absorbed = + newPlanIndex >= 0 && + (standaloneBalanceChanges.length > 0 || standaloneFlagChanges.length > 0); + + const total = + planChanges.length + + standaloneBalanceChanges.length + + standaloneFlagChanges.length; + + if (total === 0) return No changes; return (
{planChanges.map((c, i) => ( - - ))} - {balanceChanges.map((c, i) => ( - ))} - {flagChanges.map((c, i) => ( - + {!absorbed && standaloneBalanceChanges.map((c) => ( + + ))} + {!absorbed && standaloneFlagChanges.map((c, i) => ( + ))}
); @@ -248,12 +364,15 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) { if (!response) return null; if (event.status === "failed") { - const error = response.error as { message?: string } | undefined; - if (!error?.message) return null; + const error = response.error as ErrorPayload | undefined; + const message = formatUnknownError(error?.message ?? error); + if (!message) return null; return ( -
- - {error.message} +
+ + + {message} +
); } @@ -262,9 +381,9 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) { if (preview) return ; if (event.status === "skipped") { - const skipped = response.skipped as { reason?: string } | undefined; - const guard = response.guard as { reason?: string } | undefined; - const reason = skipped?.reason ?? guard?.reason; + const skipped = response.skipped as { reason?: unknown } | undefined; + const guard = response.guard as { reason?: unknown } | undefined; + const reason = formatUnknownError(skipped?.reason ?? guard?.reason); if (reason) return {reason}; } diff --git a/vite/src/views/migrations/migration/live/ExecutionStatusSubMenu.tsx b/vite/src/views/migrations/migration/live/ExecutionStatusSubMenu.tsx index 8ce83c670..6747f75fa 100644 --- a/vite/src/views/migrations/migration/live/ExecutionStatusSubMenu.tsx +++ b/vite/src/views/migrations/migration/live/ExecutionStatusSubMenu.tsx @@ -6,14 +6,25 @@ import { DropdownMenuSubTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; -const EXECUTION_STATUSES = [ - { value: "not_run", label: "Not Run" }, - { value: "succeeded", label: "Succeeded" }, - { value: "skipped", label: "Skipped" }, - { value: "failed", label: "Failed" }, +export const EXECUTION_STATUS_VALUES = [ + "queued", + "running", + "not_run", + "succeeded", + "skipped", + "failed", ] as const; -export type ExecutionStatus = (typeof EXECUTION_STATUSES)[number]["value"]; +export type ExecutionStatus = (typeof EXECUTION_STATUS_VALUES)[number]; + +const EXECUTION_STATUS_LABELS: Record = { + queued: "Queued", + running: "Running", + not_run: "Not Run", + succeeded: "Succeeded", + skipped: "Skipped", + failed: "Failed", +}; export function hasActiveExecutionFilters( statuses: ExecutionStatus[], @@ -49,20 +60,20 @@ export function ExecutionStatusSubMenu({ )} - {EXECUTION_STATUSES.map(({ value, label }) => { - const isActive = selected.includes(value); + {EXECUTION_STATUS_VALUES.map((status) => { + const isActive = selected.includes(status); return ( { e.preventDefault(); - toggle(value); + toggle(status); }} onSelect={(e) => e.preventDefault()} className="flex items-center gap-2 cursor-pointer text-sm" > - {label} + {EXECUTION_STATUS_LABELS[status]} ); })} diff --git a/vite/src/views/migrations/migration/live/MigrationCustomerSheet.tsx b/vite/src/views/migrations/migration/live/MigrationCustomerSheet.tsx index 67cb606e3..24733ba27 100644 --- a/vite/src/views/migrations/migration/live/MigrationCustomerSheet.tsx +++ b/vite/src/views/migrations/migration/live/MigrationCustomerSheet.tsx @@ -1,5 +1,6 @@ -import type { CustomerWithProducts, Operations } from "@autumn/shared"; +import type { Operations } from "@autumn/shared"; import { useMemo } from "react"; +import type { MigrationPreviewCustomer } from "@/hooks/queries/useMigrationFilterPreview"; import { useMigrationRunsQuery } from "@/hooks/queries/useMigrationRunsQuery"; import { useRealtimeSubscriptions } from "../hooks/useRealtimeSubscriptions"; import { CustomerRunSheet } from "./CustomerRunSheet"; @@ -12,7 +13,7 @@ export function MigrationCustomerSheet({ noBillingChanges, }: { migrationId: string; - customer: CustomerWithProducts; + customer: MigrationPreviewCustomer; operations: Operations; noBillingChanges: boolean; }) { @@ -26,6 +27,7 @@ export function MigrationCustomerSheet({ const { subscriptions: realtimeSubscriptions, hasActive: hasRealtimeActive, + isSettling, handleComplete: handleRealtimeComplete, triggerRun, isRunning, @@ -55,6 +57,12 @@ export function MigrationCustomerSheet({ ); }, [customerEvents]); + const runIsActive = isActive || hasRealtimeActive || isSettling; + const customerHasResult = + (customer.migration_item_run?.status != null && + customer.migration_item_run.status !== "running") || + latestLiveEvent !== undefined; + return ( <> {realtimeSubscriptions.map((sub) => ( @@ -69,9 +77,10 @@ export function MigrationCustomerSheet({ latestDryEvent={latestDryEvent} latestLiveEvent={latestLiveEvent} allEvents={customerEvents} - isActive={isActive || hasRealtimeActive} + isActive={runIsActive && !customerHasResult} activeRunDryRun={activeRunDryRun} isRunning={isRunning} + isRunInProgress={isRunning || isActive || hasRealtimeActive} onTriggerRun={triggerRun} operations={operations} noBillingChanges={noBillingChanges} diff --git a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx index bbb5d1cf2..fc77235f4 100644 --- a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx +++ b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx @@ -1,10 +1,6 @@ -import type { - CustomerWithProducts, - MigrationFilter, - Operations, -} from "@autumn/shared"; +import { AppEnv, type MigrationFilter, type Operations } from "@autumn/shared"; import { - ArrowLeftIcon, + ArrowSquareOutIcon, CaretDownIcon, CaretLeftIcon, CaretRightIcon, @@ -17,10 +13,21 @@ import { WarningIcon, XIcon, } from "@phosphor-icons/react"; -import type { ColumnDef, PaginationState, Row } from "@tanstack/react-table"; -import { debounce } from "lodash"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsString, + parseAsStringLiteral, + useQueryState, + useQueryStates, +} from "nuqs"; +import { useCallback, useDeferredValue, useMemo, useState } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; import { Table } from "@/components/general/table"; +import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/v2/badges/Badge"; import { Button } from "@/components/v2/buttons/Button"; import { IconButton } from "@/components/v2/buttons/IconButton"; @@ -47,14 +54,26 @@ import { SelectTrigger, SelectValue, } from "@/components/v2/selects/Select"; -import { useMigrationFilterPreview } from "@/hooks/queries/useMigrationFilterPreview"; -import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; -import { toast } from "sonner"; +import { Separator } from "@/components/v2/separator"; +import { + type MigrationPreviewCustomer, + useMigrationFilterPreview, +} from "@/hooks/queries/useMigrationFilterPreview"; import { type MigrationItemEvent, useMigrationRunsQuery, } from "@/hooks/queries/useMigrationRunsQuery"; +import { + type RetryableMigrationItemRunStatus, + useMigrationsQuery, +} from "@/hooks/queries/useMigrationsQuery"; import { cn } from "@/lib/utils"; +import { + CUSTOMER_LIST_PAGE_SIZE_OPTIONS, + DEFAULT_CUSTOMER_LIST_PAGE_SIZE, +} from "@/utils/constants/customerListPagination"; +import { useEnv } from "@/utils/envUtils"; +import { pushPage } from "@/utils/genUtils"; import { useCustomerFilters } from "@/views/customers/hooks/useCustomerFilters"; import { createCustomerListColumns } from "@/views/customers2/components/table/customer-list/CustomerListColumns"; import { CustomerListFilterButton } from "@/views/customers2/components/table/customer-list/CustomerListFilterButton"; @@ -62,35 +81,57 @@ import { useProductTable } from "@/views/products/hooks/useProductTable"; import { useRealtimeSubscriptions } from "../hooks/useRealtimeSubscriptions"; import { ItemEventStatusBadge } from "../runs/RunStatusBadge"; import { type StepId, StepIndicator } from "../StepIndicator"; +import { OperationsPreview } from "../shared/OperationsPreview"; import { RunSummaryRows } from "../shared/RunSummaryRows"; +import { useCursorPagination } from "../shared/useCursorPagination"; import { ActiveDot } from "./ActiveDot"; import { + EXECUTION_STATUS_VALUES, type ExecutionStatus, ExecutionStatusSubMenu, hasActiveExecutionFilters, } from "./ExecutionStatusSubMenu"; +import { + type ActiveRunStatus, + buildEventsByCustomer, + resolveMigrationItemStatus, +} from "./migrationItemStatus"; import { RealtimeRunWatcher } from "./RealtimeRunWatcher"; import { useMigrationSheetStore } from "./useMigrationSheetStore"; -const PAGE_SIZE_OPTIONS = [10, 50, 100, 250]; +type AdminRunControls = { + lazyRun: boolean; + retryErrored: boolean; + retrySkipped: boolean; + concurrency: string; +}; -type ActiveRunStatus = "queued" | "running" | null; +const MIN_CONCURRENCY = 1; +const MAX_CONCURRENCY = 5; -type CustomerRow = CustomerWithProducts & { +function parseConcurrency(value: string): number | undefined { + const trimmed = value.trim(); + if (trimmed === "") return undefined; + const parsed = Number(trimmed); + if (!Number.isInteger(parsed)) return undefined; + if (parsed < MIN_CONCURRENCY || parsed > MAX_CONCURRENCY) return undefined; + return parsed; +} + +type CustomerRow = MigrationPreviewCustomer & { _event?: MigrationItemEvent; _activeStatus?: ActiveRunStatus; _activeRunId?: string; }; -function buildEventsByCustomer(itemEvents: MigrationItemEvent[]) { - const map = new Map(); - for (const event of itemEvents) { - if (event.item_kind !== "customer") continue; - const existing = map.get(event.item_id); - if (!existing || event.timestamp > existing.timestamp) - map.set(event.item_id, event); - } - return map; +function buildRetryItemStatuses({ + retryErrored, + retrySkipped, +}: Pick) { + const statuses: RetryableMigrationItemRunStatus[] = []; + if (retryErrored) statuses.push("failed"); + if (retrySkipped) statuses.push("skipped"); + return statuses.length > 0 ? statuses : undefined; } const statusColumn: ColumnDef = { @@ -98,29 +139,28 @@ const statusColumn: ColumnDef = { header: "Status", size: 140, cell: ({ row }: { row: Row }) => { - const event = row.original._event; - const activeStatus = row.original._activeStatus; - const activeRunId = row.original._activeRunId; - const processedInCurrentRun = - event && activeRunId && event.migration_run_id === activeRunId; + const status = resolveMigrationItemStatus({ + event: row.original._event, + itemRun: row.original.migration_item_run, + activeStatus: row.original._activeStatus ?? null, + }); - if (activeStatus && !processedInCurrentRun) { - const color = activeStatus === "running" ? "green" : "orange"; - const label = activeStatus === "running" ? "Running" : "Queued"; + if (status.kind === "running" || status.kind === "queued") { + const isQueued = status.kind === "queued"; return ( - - {label} + + {isQueued ? "Queued" : "Running"} ); } - if (event) + if (status.kind === "result") return ( ); @@ -132,8 +172,40 @@ const baseColumns = createCustomerListColumns().filter( (col) => col.id !== "actions", ) as ColumnDef[]; +const executionCustomerColumns = baseColumns.map((column) => { + if (column.id !== "name") return column; + + return { + ...column, + cell: ({ row }: { row: Row }) => { + const customer = row.original; + const customerId = customer.id || customer.internal_id; + + return ( + event.stopPropagation()} + className="group/link inline-flex max-w-full items-center gap-1.5 text-foreground hover:text-primary" + > + + {customer.name || customerId} + + + + ); + }, + } satisfies ColumnDef; +}); + const columns: ColumnDef[] = [ - ...baseColumns, + ...executionCustomerColumns, statusColumn, ]; @@ -144,7 +216,6 @@ export function MigrationLiveView({ noBillingChanges, step, onStepChange, - onPrevious, }: { migrationId: string; filter: MigrationFilter; @@ -152,21 +223,71 @@ export function MigrationLiveView({ noBillingChanges: boolean; step: StepId; onStepChange: (step: StepId) => void; - onPrevious?: () => void; }) { const { queryStates: customerFilters } = useCustomerFilters(); - const [executionStatuses, setExecutionStatuses] = useState( - [], + const env = useEnv(); + const tableContainerHeight = + env === AppEnv.Sandbox ? "calc(100vh - 260px)" : "calc(100vh - 220px)"; + const [executionQuery, setExecutionQuery] = useQueryStates( + { + execution_status: parseAsArrayOf( + parseAsStringLiteral(EXECUTION_STATUS_VALUES), + ).withDefault([]), + q: parseAsString.withDefault(""), + pageSize: parseAsInteger.withDefault(DEFAULT_CUSTOMER_LIST_PAGE_SIZE), + }, + { history: "replace" }, ); - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 50, + const executionStatuses = executionQuery.execution_status; + const search = executionQuery.q; + const deferredSearch = useDeferredValue(search.trim()); + const pageSize = CUSTOMER_LIST_PAGE_SIZE_OPTIONS.includes( + executionQuery.pageSize, + ) + ? executionQuery.pageSize + : DEFAULT_CUSTOMER_LIST_PAGE_SIZE; + const previewCustomerFilters = useMemo( + () => ({ + status: customerFilters.status, + version: customerFilters.version, + none: customerFilters.none, + processor: customerFilters.processor, + }), + [ + customerFilters.status, + customerFilters.version, + customerFilters.none, + customerFilters.processor, + ], + ); + const { + currentCursor, + currentPage, + pagination, + canPrev, + pushCursor, + popCursor, + } = useCursorPagination({ + pageSize, + resetKey: JSON.stringify({ + executionStatuses, + pageSize, + search: search.trim(), + customerFilters: previewCustomerFilters, + }), }); const [dismissedError, setDismissedError] = useState(null); - const [isRunDialogOpen, setIsRunDialogOpen] = useState(false); + const [isRunDialogOpen, setIsRunDialogOpen] = useQueryState( + "run", + parseAsBoolean.withDefault(false), + ); const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); + const [runControls, setRunControls] = useState({ + lazyRun: true, + retryErrored: false, + retrySkipped: false, + concurrency: String(MAX_CONCURRENCY), + }); const [sample, setSample] = useState({ open: false, mode: "limit" as "limit" | "select", @@ -176,45 +297,63 @@ export function MigrationLiveView({ }); const { cancelRun, isCanceling } = useMigrationsQuery(); - const debouncedSetSearch = useMemo( - () => debounce((q: string) => setDebouncedSearch(q), 350), - [], - ); - useEffect(() => () => debouncedSetSearch.cancel(), [debouncedSetSearch]); + const resolvedRunControls = { + lazyRun: runControls.lazyRun, + retryItemStatuses: buildRetryItemStatuses(runControls), + concurrency: parseConcurrency(runControls.concurrency), + }; + const invalidConcurrency = + runControls.concurrency.trim() !== "" && + parseConcurrency(runControls.concurrency) === undefined; const handleSearchChange = useCallback( (e: React.ChangeEvent) => { - setSearch(e.target.value); - setPagination((p) => ({ ...p, pageIndex: 0 })); - debouncedSetSearch(e.target.value.trim()); + setExecutionQuery({ q: e.target.value }); }, - [debouncedSetSearch], + [setExecutionQuery], ); - const { - customers, - count, - isLoading: isLoadingCustomers, - } = useMigrationFilterPreview({ - filter: filter.customer ?? {}, - search: debouncedSearch, - page: pagination.pageIndex, - pageSize: pagination.pageSize, - }); + const handleExecutionStatusesChange = useCallback( + (statuses: ExecutionStatus[]) => { + setExecutionQuery({ execution_status: statuses }); + }, + [setExecutionQuery], + ); const { itemEvents, runs, + isActive: hasActiveRun, invalidate: invalidateRuns, } = useMigrationRunsQuery({ migrationId }); + const latestRun = runs[0]; + const { subscriptions: realtimeSubscriptions, hasActive: hasRealtimeActive, handleComplete: handleRealtimeComplete, + isSettling, triggerRun, isRunning, } = useRealtimeSubscriptions({ migrationId, invalidateRuns }); + const isRunInProgress = isRunning || hasActiveRun || hasRealtimeActive; + + const { + customers, + count, + nextCursor, + isLoading: isLoadingCustomers, + } = useMigrationFilterPreview({ + filter: filter.customer ?? {}, + search: deferredSearch, + customerFilters: previewCustomerFilters, + cursor: currentCursor, + pageSize, + migrationId, + executionStatuses, + isActive: hasActiveRun || hasRealtimeActive, + }); const setSelectedCustomer = useMigrationSheetStore( (s) => s.setSelectedCustomer, @@ -228,19 +367,24 @@ export function MigrationLiveView({ const activeRun = runs.find( (r) => r.status === "queued" || r.status === "running", ); - const activeRunStatus: ActiveRunStatus = hasRealtimeActive - ? "running" - : ((activeRun?.status as ActiveRunStatus) ?? null); - const activeRunId = activeRun?.internal_id ?? null; + const progressRun = activeRun ?? (isSettling ? latestRun : undefined); + const progressCounts = (progressRun ?? latestRun)?.item_run_counts; + const canShowPendingStatus = + executionStatuses.length === 0 || executionStatuses.includes("queued"); + const pendingRunStatus: ActiveRunStatus = + canShowPendingStatus && (hasRealtimeActive || isSettling || activeRun) + ? "queued" + : null; + const activeRunId = progressRun?.internal_id ?? null; const activeRunOnlyIds = useMemo( () => - activeRun?.only_ids && activeRun.only_ids.length > 0 - ? new Set(activeRun.only_ids) + progressRun?.only_ids && progressRun.only_ids.length > 0 + ? new Set(progressRun.only_ids) : null, - [activeRun?.only_ids], + [progressRun?.only_ids], ); const isActiveRunScoped = - !!activeRunOnlyIds || !!(activeRun?.target_limit as number | null); + !!activeRunOnlyIds || !!(progressRun?.target_limit as number | null); const enrichedCustomers = useMemo( (): CustomerRow[] => @@ -254,88 +398,58 @@ export function MigrationLiveView({ : isActiveRunScoped ? !!hasEventInActiveRun : true; + const isClaimedInActiveRun = + !!activeRunId && + c.migration_item_run?.migration_run_id === activeRunId; + const hasResultForRun = + !!hasEventInActiveRun || + (isClaimedInActiveRun && + c.migration_item_run?.status !== "running"); + const hasPersistedResult = + !!event || + (!!c.migration_item_run && + c.migration_item_run.status !== "running"); + const showPending = + !!pendingRunStatus && + isTargeted && + !hasResultForRun && + !hasPersistedResult; + let activeStatus: ActiveRunStatus = null; + if (showPending) { + activeStatus = isClaimedInActiveRun ? "running" : pendingRunStatus; + } return { ...c, _event: event, - _activeStatus: isTargeted ? activeRunStatus : null, + _activeStatus: activeStatus, _activeRunId: activeRunId ?? undefined, }; }), [ customers, eventsByCustomer, - activeRunStatus, + pendingRunStatus, activeRunId, activeRunOnlyIds, isActiveRunScoped, ], ); - const filteredCustomers = useMemo(() => { - const hasExecution = executionStatuses.length > 0; - const hasStatus = customerFilters.status.length > 0; - const hasVersion = customerFilters.version.length > 0; - const hasProcessor = customerFilters.processor.length > 0; - const hasNone = customerFilters.none; - if (!hasExecution && !hasStatus && !hasVersion && !hasProcessor && !hasNone) - return enrichedCustomers; - return enrichedCustomers.filter((c) => { - if (hasExecution) { - const status = c._event?.status; - if (!status && !executionStatuses.includes("not_run")) return false; - if (status && !executionStatuses.includes(status as ExecutionStatus)) - return false; - } - const cusProducts = c.customer_products ?? []; - if (hasNone && cusProducts.length === 0) return true; - if (hasStatus) { - if ( - !cusProducts.some((cp) => customerFilters.status.includes(cp.status)) - ) - return false; - } - if (hasVersion) { - if ( - !cusProducts.some((cp) => - customerFilters.version.includes( - `${cp.product?.id}:${cp.product?.version ?? 1}`, - ), - ) - ) - return false; - } - if (hasProcessor) { - const processors = c.processors ?? {}; - if ( - !customerFilters.processor.some( - (p) => processors[p as keyof typeof processors] != null, - ) - ) - return false; - } - return true; - }); - }, [enrichedCustomers, executionStatuses, customerFilters]); - const pageCount = count !== null ? Math.max(Math.ceil(count / pagination.pageSize), 1) : 1; const table = useProductTable({ - data: filteredCustomers, + data: enrichedCustomers, columns, options: { manualPagination: true, pageCount, state: { pagination }, - onPaginationChange: setPagination, }, }); + const canGoNext = Boolean(nextCursor); + const isDisabled = isLoadingCustomers; - const currentPage = pagination.pageIndex + 1; - const canPrev = pagination.pageIndex > 0; - const canNext = count !== null && currentPage < pageCount; - - const latestRun = runs[0]; const latestFailedRun = latestRun?.status === "failed" && latestRun.error_message ? latestRun @@ -367,17 +481,6 @@ export function MigrationLiveView({ )} - {count !== null && ( - - {count} {count === 1 ? "customer" : "customers"} - - )} - {onPrevious && ( - - )} {activeRun && ( - triggerRun({ dryRun: true })}> + triggerRun({ dryRun: true })} + > Dry Run All setSample((s) => ({ ...s, open: true }))} > @@ -425,10 +534,7 @@ export function MigrationLiveView({
- + Cancel running migration? @@ -467,7 +573,10 @@ export function MigrationLiveView({ - + setIsRunDialogOpen(open)} + > Run Migration @@ -485,29 +594,35 @@ export function MigrationLiveView({ } customerLabel={ count !== null - ? `${count} ${count === 1 ? "customer" : "customers"}` + ? `${count.toLocaleString()} ${count === 1 ? "customer" : "customers"}` : "All matched customers" } operations={operations} noBillingChanges={noBillingChanges} /> + + 0} + hasSkippedItems={(progressCounts?.skipped ?? 0) > 0} + /> - - + @@ -527,9 +642,7 @@ export function MigrationLiveView({
@@ -754,19 +891,144 @@ export function MigrationLiveView({ onRowClick: setSelectedCustomer, rowClassName: "h-10", emptyStateText: "No customers match this filter", + flexibleTableColumns: true, + virtualization: { + containerHeight: tableContainerHeight, + }, }} > - - - - + + +
); } +function ExecutionProgressBadge({ + completed, + running, +}: { + completed: number; + running: number; +}) { + if (completed === 0 && running === 0) return null; + + return ( + + {completed.toLocaleString()} run + {running > 0 && `, ${running.toLocaleString()} running`} + + ); +} + +function MigrationRunControls({ + value, + onChange, + invalidConcurrency = false, + lazyDisabled = false, + hasFailedItems = false, + hasSkippedItems = false, +}: { + value: AdminRunControls; + onChange: (value: AdminRunControls) => void; + invalidConcurrency?: boolean; + lazyDisabled?: boolean; + hasFailedItems?: boolean; + hasSkippedItems?: boolean; +}) { + return ( +
+ +
+
+ Lazy run + + Remaining customers migrate when queried. + +
+ + onChange({ ...value, lazyRun: checked === true }) + } + /> +
+
+
+ + Concurrency + + + Customers processed in parallel. Max {MAX_CONCURRENCY}. + +
+ onChange({ ...value, concurrency: e.target.value })} + placeholder="Auto" + className={cn( + "w-20 text-sm", + invalidConcurrency && "border-red-500 focus-visible:ring-red-500", + )} + /> +
+ {invalidConcurrency && ( + + Concurrency must be less than {MAX_CONCURRENCY}. + + )} + {hasFailedItems && ( +
+
+ + Retry failed + + + Re-run customers that previously errored. + +
+ + onChange({ ...value, retryErrored: checked === true }) + } + /> +
+ )} + {hasSkippedItems && ( +
+
+ + Retry skipped + + + Re-run customers that were skipped. + +
+ + onChange({ ...value, retrySkipped: checked === true }) + } + /> +
+ )} +
+ ); +} + function SampleCustomerPreview({ customers, limit, @@ -774,7 +1036,7 @@ function SampleCustomerPreview({ customers: CustomerRow[]; limit: number; }) { - const unrun = customers.filter((c) => !c._event); + const unrun = customers.filter((c) => !c.migration_item_run); const previewed = unrun.slice(0, limit); if (limit === 0) return null; return ( @@ -824,23 +1086,56 @@ function SampleCustomerPicker({ c.email?.toLowerCase().includes(q), ); }, [customers, search]); + const selectedIdSet = new Set(selectedIds); + const filteredIds = filtered.map((c) => c.id ?? c.internal_id); + const allFilteredSelected = + filteredIds.length > 0 && filteredIds.every((id) => selectedIdSet.has(id)); const toggle = (id: string) => { - onChange( - selectedIds.includes(id) - ? selectedIds.filter((v) => v !== id) - : [...selectedIds, id], - ); + const nextIds = new Set(selectedIds); + if (nextIds.has(id)) { + nextIds.delete(id); + } else { + nextIds.add(id); + } + onChange(Array.from(nextIds)); + }; + + const toggleFiltered = () => { + const filteredIdSet = new Set(filteredIds); + if (allFilteredSelected) { + onChange(selectedIds.filter((id) => !filteredIdSet.has(id))); + return; + } + + const nextIds = new Set(selectedIds); + for (const id of filteredIds) nextIds.add(id); + onChange(Array.from(nextIds)); }; return (
- setSearch(e.target.value)} - placeholder="Search customers..." - className="text-sm" - /> +
+ setSearch(e.target.value)} + placeholder="Search customers..." + className="text-sm" + /> + +
{filtered.length === 0 ? (
@@ -848,7 +1143,7 @@ function SampleCustomerPicker({
) : ( filtered.map((c) => { - const isSelected = selectedIds.includes(c.id ?? c.internal_id); + const isSelected = selectedIdSet.has(c.id ?? c.internal_id); return (
{c.name || c.id || c.internal_id} diff --git a/vite/src/views/migrations/migration/live/migrationItemStatus.ts b/vite/src/views/migrations/migration/live/migrationItemStatus.ts new file mode 100644 index 000000000..4e8d4bfd3 --- /dev/null +++ b/vite/src/views/migrations/migration/live/migrationItemStatus.ts @@ -0,0 +1,70 @@ +import type { MigrationItemRun } from "@autumn/shared"; +import type { + MigrationItemEvent, + MigrationItemEventStatus, +} from "@/hooks/queries/useMigrationRunsQuery"; + +export type ActiveRunStatus = "queued" | "running" | null; + +export type MigrationItemStatus = + | { kind: "running" } + | { kind: "queued" } + | { + kind: "result"; + status: MigrationItemEventStatus; + dryRun: boolean; + response: Record | null; + } + | { kind: "none" }; + +export function isPreferredEvent( + candidate: MigrationItemEvent, + existing: MigrationItemEvent, +) { + if (candidate.dry_run !== existing.dry_run) return !candidate.dry_run; + return candidate.timestamp > existing.timestamp; +} + +export function buildEventsByCustomer(itemEvents: MigrationItemEvent[]) { + const map = new Map(); + for (const event of itemEvents) { + if (event.item_kind !== "customer") continue; + const existing = map.get(event.item_id); + if (!existing || isPreferredEvent(event, existing)) + map.set(event.item_id, event); + } + return map; +} + +export function resolveMigrationItemStatus({ + event, + itemRun, + activeStatus, +}: { + event: MigrationItemEvent | undefined; + itemRun: MigrationItemRun | null | undefined; + activeStatus: ActiveRunStatus; +}): MigrationItemStatus { + if (activeStatus === "running") return { kind: "running" }; + if (activeStatus === "queued") return { kind: "queued" }; + + if (itemRun?.status === "running") return { kind: "running" }; + + if (event) + return { + kind: "result", + status: event.status, + dryRun: event.dry_run, + response: event.response, + }; + + if (itemRun?.status && itemRun.status !== "running") + return { + kind: "result", + status: itemRun.status, + dryRun: false, + response: null, + }; + + return { kind: "none" }; +} diff --git a/vite/src/views/migrations/migration/live/useMigrationSheetStore.ts b/vite/src/views/migrations/migration/live/useMigrationSheetStore.ts index 1897a5f85..7ed089ca1 100644 --- a/vite/src/views/migrations/migration/live/useMigrationSheetStore.ts +++ b/vite/src/views/migrations/migration/live/useMigrationSheetStore.ts @@ -1,16 +1,20 @@ -import type { CustomerWithProducts, Operations } from "@autumn/shared"; +import type { Operations } from "@autumn/shared"; import { create } from "zustand"; +import type { MigrationPreviewCustomer } from "@/hooks/queries/useMigrationFilterPreview"; interface MigrationSheetState { - selectedCustomer: CustomerWithProducts | null; - setSelectedCustomer: (customer: CustomerWithProducts | null) => void; + selectedCustomer: MigrationPreviewCustomer | null; + setSelectedCustomer: (customer: MigrationPreviewCustomer | null) => void; liveFormState: { operations: Operations; noBillingChanges: boolean }; - setLiveFormState: (state: { operations: Operations; noBillingChanges: boolean }) => void; + setLiveFormState: (state: { + operations: Operations; + noBillingChanges: boolean; + }) => void; } export const useMigrationSheetStore = create((set) => ({ selectedCustomer: null, setSelectedCustomer: (customer) => set({ selectedCustomer: customer }), - liveFormState: { operations: {}, noBillingChanges: false }, + liveFormState: { operations: {}, noBillingChanges: true }, setLiveFormState: (liveFormState) => set({ liveFormState }), })); diff --git a/vite/src/views/migrations/migration/operations/ItemSummaryRow.tsx b/vite/src/views/migrations/migration/operations/ItemSummaryRow.tsx index 778a4d254..e939d9107 100644 --- a/vite/src/views/migrations/migration/operations/ItemSummaryRow.tsx +++ b/vite/src/views/migrations/migration/operations/ItemSummaryRow.tsx @@ -11,7 +11,7 @@ export function ItemSummaryRow({ onClick, }: { item: Record; - onClick: () => void; + onClick?: () => void; }) { const { features } = useFeaturesQuery(); const { org } = useOrg(); @@ -28,15 +28,8 @@ export function ItemSummaryRow({ const feature = features.find((f) => f.id === productItem.feature_id); const hasFeatureName = feature?.name && feature.name.trim() !== ""; - return ( - ); } diff --git a/vite/src/views/migrations/migration/operations/MigrationOperationSheet.tsx b/vite/src/views/migrations/migration/operations/MigrationOperationSheet.tsx index 887be64eb..a21ccff99 100644 --- a/vite/src/views/migrations/migration/operations/MigrationOperationSheet.tsx +++ b/vite/src/views/migrations/migration/operations/MigrationOperationSheet.tsx @@ -1,7 +1,11 @@ import type { FrontendProduct, ProductItem } from "@autumn/shared"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Button } from "@/components/v2/buttons/Button"; -import { ProductProvider } from "@/components/v2/inline-custom-plan-editor/PlanEditorContext"; +import { + ProductProvider, + useCurrentItem, + useSetCurrentItem, +} from "@/components/v2/inline-custom-plan-editor/PlanEditorContext"; import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet"; import { disabledItemDraftController } from "@/hooks/inline-editor/useItemDraftController"; import { getItemId } from "@/utils/product/productItemUtils"; @@ -116,9 +120,14 @@ function MigrationOperationSheetContent({ }; const [sheetType, setSheetType] = useState(MODE_TO_SHEET[mode]); - const [itemId, setItemId] = useState( - mode === "edit-feature" && editItem ? "item-0" : null, + const editItemId = useMemo( + () => + mode === "edit-feature" && editItem + ? getItemId({ item: editItem, itemIndex: 0 }) + : null, + [mode, editItem], ); + const [itemId, setItemId] = useState(editItemId); const [initialItem, setInitialItem] = useState( editItem ? structuredClone(editItem) : null, ); @@ -143,27 +152,6 @@ function MigrationOperationSheetContent({ onSave(latestProduct.current); }; - const handleFeatureCommit = async () => { - onSave(latestProduct.current); - return null; - }; - - const currentItem = - product.items?.find( - (item, i) => getItemId({ item, itemIndex: i }) === itemId, - ) ?? null; - - const setCurrentItem = (updatedItem: ProductItem) => { - if (!product.items || !itemId) return; - const index = product.items.findIndex( - (item, i) => getItemId({ item, itemIndex: i }) === itemId, - ); - if (index === -1) return; - const updatedItems = [...product.items]; - updatedItems[index] = updatedItem; - wrappedSetProduct((prev) => ({ ...prev, items: updatedItems })); - }; - return ( -
-
- {sheetType === "select-feature" && } - {sheetType === "edit-plan-price" && } - {sheetType === "edit-feature" && currentItem && ( - {}, - isUpdate: !!editItem, - handleUpdateProductItem: handleFeatureCommit, - }} - > - - - )} -
- {sheetType === "edit-plan-price" && ( -
- - -
- )} -
+
); } + +function MigrationSheetInner({ + sheetType, + isUpdate, + onApply, + onCancel, +}: { + sheetType: string; + isUpdate: boolean; + onApply: () => void; + onCancel: () => void; +}) { + const currentItem = useCurrentItem(); + const setCurrentItem = useSetCurrentItem(); + + const handleFeatureCommit = async () => { + onApply(); + return null; + }; + + return ( +
+
+ {sheetType === "select-feature" && } + {sheetType === "edit-plan-price" && } + {sheetType === "edit-feature" && currentItem && ( + {}, + isUpdate, + handleUpdateProductItem: handleFeatureCommit, + }} + > + + + )} +
+ {sheetType === "edit-plan-price" && ( +
+ + +
+ )} +
+ ); +} diff --git a/vite/src/views/migrations/migration/operations/OperationsForm.tsx b/vite/src/views/migrations/migration/operations/OperationsForm.tsx index 3c4dc12fe..d18d39463 100644 --- a/vite/src/views/migrations/migration/operations/OperationsForm.tsx +++ b/vite/src/views/migrations/migration/operations/OperationsForm.tsx @@ -190,7 +190,7 @@ export function OperationsForm({ - Add Operation + Update or add a different plan void; }) { const { features } = useFeaturesQuery(); - const featureId = (item.feature_id as string) || null; + const [sheetOpen, setSheetOpen] = useState(false); + + const filter = item as ItemFilter; + const hasFeature = !!filter.feature_id; return ( -
- Remove - onChange({ ...item, feature_id: v })} - placeholder="Select feature to remove..." - triggerClassName={cn( - featureId && "!border-destructive/50 hover:!border-destructive/60", - )} + <> +
+ + Remove + + + +
+ + { + onChange(updated); + setSheetOpen(false); + }} /> - + + ); +} + +function RemoveItemSheet({ + open, + onOpenChange, + item, + onSave, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + item: ItemFilter; + onSave: (item: ItemFilter) => void; +}) { + const [key, setKey] = useState(0); + + return ( + { + if (isOpen) setKey((k) => k + 1); + onOpenChange(isOpen); + }} + > + + {open && ( + onOpenChange(false)} + /> + )} + + + ); +} + +function RemoveItemSheetContent({ + item, + onSave, + onCancel, +}: { + item: ItemFilter; + onSave: (item: ItemFilter) => void; + onCancel: () => void; +}) { + const { features } = useFeaturesQuery(); + const [draft, setDraft] = useState(() => + structuredClone(item), + ); + + const canSave = !!draft.feature_id; + + return ( +
+
+
+

+ Remove Item +

+

+ Select a feature to remove from the plan. Use interval + and billing method to narrow the match. +

+
+ +
+
+ + + setDraft({ ...draft, feature_id: v }) + } + placeholder="Select feature..." + /> +
+ +
+ + + + + + + {draft.interval && ( + + setDraft({ ...draft, interval: undefined }) + } + className="py-1.5 px-2 text-muted-foreground" + > + Any interval + + )} + {INTERVAL_OPTIONS.map((o) => ( + + setDraft({ ...draft, interval: o.value }) + } + className="py-1.5 px-2" + > + {o.label} + + ))} + + +

+ Narrow the match when the same feature appears at + multiple intervals. +

+
+ +
+ + + setDraft({ ...draft, billing_method: v }) + } + /> +
+
+
+ +
+ + +
); } diff --git a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx index a0cc6d2d0..764778784 100644 --- a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx +++ b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx @@ -111,6 +111,7 @@ export function UpdatePlanOpForm({ const customize = value.customize; const addItems = customize?.add_items ?? []; + const planVersionActionLabel = getPlanVersionActionLabel(value); const openSheet = (mode: OperationSheetMode, itemIndex?: number) => { setSheetMode(mode); @@ -119,7 +120,7 @@ export function UpdatePlanOpForm({ }; const editItem: ProductItem | undefined = - editingItemIndex !== null + editingItemIndex !== null && addItems[editingItemIndex] ? migrationItemToProductItem(addItems[editingItemIndex], features) : undefined; @@ -270,7 +271,7 @@ export function UpdatePlanOpForm({ {addItems.map((item, index) => (
- Add + Add openSheet("edit-feature", index)} @@ -305,7 +306,7 @@ export function UpdatePlanOpForm({ - Add modification + Add a modification to this plan {value.version === undefined && ( @@ -313,7 +314,7 @@ export function UpdatePlanOpForm({ closeOnClick onClick={() => update({ version: 1 })} > - Version + {planVersionActionLabel} )} {(!customize || customize.price === undefined) && ( @@ -361,7 +362,7 @@ export function UpdatePlanOpForm({ ); } -function extractPlanIds( +export function extractPlanIds( planId: UpdatePlanOp["plan_filter"]["plan_id"], ): string[] { if (!planId) return []; @@ -372,6 +373,21 @@ function extractPlanIds( return []; } +export function isSameVersionReset(value: UpdatePlanOp): boolean { + const filteredVersion = value.plan_filter.version; + const selectedVersion = value.version ?? 1; + + return ( + typeof filteredVersion === "number" && filteredVersion === selectedVersion + ); +} + +export function getPlanVersionActionLabel(value: UpdatePlanOp): string { + return isSameVersionReset(value) + ? "Reset to Plan Version" + : "Set Plan Version"; +} + function toPlanIdMatcher( ids: string[], ): UpdatePlanOp["plan_filter"]["plan_id"] { diff --git a/vite/src/views/migrations/migration/operations/operationItemUtils.tsx b/vite/src/views/migrations/migration/operations/operationItemUtils.tsx new file mode 100644 index 000000000..f23627f0a --- /dev/null +++ b/vite/src/views/migrations/migration/operations/operationItemUtils.tsx @@ -0,0 +1,150 @@ +import type { Feature, ProductItem } from "@autumn/shared"; +import { BillingInterval, EntInterval, UsageModel } from "@autumn/shared"; +import { + BoxArrowDownIcon, + CaretDownIcon, + MoneyWavyIcon, + WalletIcon, +} from "@phosphor-icons/react"; +import type React from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/v2/dropdowns/DropdownMenu"; +import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; + +const LABEL_OVERRIDES: Record = { + [BillingInterval.SemiAnnual]: "Semi-annual", + [BillingInterval.OneOff]: "One-off", +}; + +const billingSet = new Set(Object.values(BillingInterval)); +const allIntervals = [ + ...Object.values(BillingInterval), + ...Object.values(EntInterval).filter((v) => !billingSet.has(v)), +]; + +export const INTERVAL_OPTIONS: { value: string; label: string }[] = + allIntervals.map((v) => ({ value: v, label: LABEL_OVERRIDES[v] ?? keyToTitle(v) })); + +export const CLEAR_VALUE = "__clear__"; + +const BILLING_METHOD_OPTIONS: { + value: string; + label: string; + icon: React.ReactNode; + color: string; +}[] = [ + { + value: "included", + label: "Included", + icon: , + color: "text-green-500", + }, + { + value: "usage_based", + label: "Usage-based", + icon: , + color: "text-yellow-500", + }, + { + value: "prepaid", + label: "Prepaid", + icon: , + color: "text-orange-500", + }, +]; + +export function BillingMethodDropdown({ + value, + onChange, +}: { + value: string | null; + onChange: (value: string | undefined) => void; +}) { + const selected = BILLING_METHOD_OPTIONS.find((o) => o.value === value); + + return ( + + + + + + {selected && ( + onChange(undefined)} + className="py-1.5 px-2 text-muted-foreground" + > + Any method + + )} + {BILLING_METHOD_OPTIONS.map((o) => ( + + onChange( + o.value === "included" ? undefined : o.value, + ) + } + className="py-1.5 px-2" + > + {o.icon} + {o.label} + + ))} + + + ); +} + +export interface ItemFilter { + feature_id?: string; + interval?: string; + billing_method?: string; +} + +export function filterToProductItem(filter: ItemFilter): ProductItem { + return { + feature_id: filter.feature_id, + interval: filter.interval, + usage_model: + filter.billing_method === "prepaid" + ? UsageModel.Prepaid + : filter.billing_method === "usage_based" + ? UsageModel.PayPerUse + : undefined, + tiers: + filter.billing_method === "usage_based" + ? [{ to: "inf", amount: 0 }] + : undefined, + } as ProductItem; +} + +export function getFilterSummary( + filter: ItemFilter, + features: Feature[], +): string { + const feature = features.find((f) => f.id === filter.feature_id); + const name = feature?.name || filter.feature_id || "Unconfigured"; + const parts: string[] = [name]; + if (filter.interval) parts.push(filter.interval); + return parts.join(" · "); +} diff --git a/vite/src/views/migrations/migration/runs/RunStatusBadge.tsx b/vite/src/views/migrations/migration/runs/RunStatusBadge.tsx index 75ef2044a..43bb487fa 100644 --- a/vite/src/views/migrations/migration/runs/RunStatusBadge.tsx +++ b/vite/src/views/migrations/migration/runs/RunStatusBadge.tsx @@ -1,3 +1,9 @@ +import { + CheckCircleIcon, + type Icon, + MinusCircleIcon, + XCircleIcon, +} from "@phosphor-icons/react"; import { Badge } from "@/components/v2/badges/Badge"; import type { MigrationItemEventStatus } from "@/hooks/queries/useMigrationRunsQuery"; import { cn } from "@/lib/utils"; @@ -29,6 +35,12 @@ const STATUS_LABELS: Record = { failed: "Failed", }; +const STATUS_ICONS: Record = { + succeeded: CheckCircleIcon, + skipped: MinusCircleIcon, + failed: XCircleIcon, +}; + function isNoOpResponse(response: Record | null): boolean { if (!response) return false; const preview = response.preview as @@ -60,19 +72,23 @@ export function ItemEventStatusBadge({ + No Changes ); + const StatusIcon = STATUS_ICONS[status]; + return ( + {STATUS_LABELS[status]} ); diff --git a/vite/src/views/migrations/migration/shared/OperationsPreview.tsx b/vite/src/views/migrations/migration/shared/OperationsPreview.tsx new file mode 100644 index 000000000..957cb1d96 --- /dev/null +++ b/vite/src/views/migrations/migration/shared/OperationsPreview.tsx @@ -0,0 +1,143 @@ +import { + type AddPlanOp, + formatAmount, + formatInterval, + type Operations, + type UpdatePlanOp, +} from "@autumn/shared"; +import { + CurrencyCircleDollarIcon, + GitBranchIcon, +} from "@phosphor-icons/react"; +import type { ReactNode } from "react"; +import { DeletedItemRow } from "@/components/forms/shared/plan-items/DeletedItemRow"; +import { SubscriptionItemRow } from "@/components/forms/update-subscription-v2/components/SubscriptionItemRow"; +import { Separator } from "@/components/v2/separator"; +import { useOrg } from "@/hooks/common/useOrg"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { filterToProductItem, type ItemFilter } from "../operations/operationItemUtils"; +import { extractPlanIds } from "../operations/UpdatePlanOpForm"; +import { migrationItemToProductItem } from "./migrationItemUtils"; + +/** Full-width row matching SubscriptionItemRow, with an amber dot for an edited value. */ +function EditedRow({ icon, text }: { icon: ReactNode; text: ReactNode }) { + return ( +
+
+ {icon} +

+ {text} +

+
+ +
+ ); +} + +export function OperationsPreview({ operations }: { operations: Operations }) { + const { products } = useProductsQuery({ allVersions: true }); + const { features } = useFeaturesQuery(); + const { org } = useOrg(); + const currency = org?.default_currency ?? "USD"; + const ops = operations.customer ?? []; + + if (ops.length === 0) return null; + + const planName = (id: string) => + products.find((p) => p.id === id)?.name ?? id; + + return ( +
+ + {ops.map((op, index) => { + if (op.type === "add_plan") { + const addOp = op as AddPlanOp; + return ( +
+ + Add plan + + + {planName(addOp.plan_id)} + +
+ ); + } + + const updateOp = op as UpdatePlanOp; + const planIds = extractPlanIds(updateOp.plan_filter.plan_id); + const customize = updateOp.customize; + const addItems = customize?.add_items ?? []; + const removeItems = customize?.remove_items ?? []; + + return ( +
+
+ + {planIds.length > 1 ? "Update plans" : "Update plan"} + + {planIds.length > 0 && ( + + {planIds.map(planName).join(", ")} + + )} +
+ + {updateOp.version !== undefined && ( + + } + text={`v${updateOp.version}`} + /> + )} + + {customize?.price !== undefined && ( + + } + text={`${formatAmount({ + currency, + amount: customize.price?.amount ?? 0, + amountFormatOptions: { + style: "currency", + currencyDisplay: "narrowSymbol", + }, + })} ${formatInterval({ + interval: customize.price?.interval ?? "month", + intervalCount: 1, + })}`} + /> + )} + + {addItems.map((item, idx) => ( + + ))} + + {removeItems.map((item, idx) => ( + + ))} +
+ ); + })} +
+ ); +} diff --git a/vite/src/views/migrations/migration/shared/migrationItemUtils.ts b/vite/src/views/migrations/migration/shared/migrationItemUtils.ts index 3d9574464..b93f402b0 100644 --- a/vite/src/views/migrations/migration/shared/migrationItemUtils.ts +++ b/vite/src/views/migrations/migration/shared/migrationItemUtils.ts @@ -2,10 +2,55 @@ import type { Feature, ProductItem, ProductItemInterval, + UsageTier, +} from "@autumn/shared"; +import { + BillingMethod, + Infinite, + ProductItemFeatureType, + TierBehavior, UsageModel, } from "@autumn/shared"; import { getDefaultItem } from "@/views/products/plan/utils/getDefaultItem"; +const BOOLEAN_TYPES = new Set([ + ProductItemFeatureType.Static, + ProductItemFeatureType.Boolean, +]); + +type MigrationPrice = { + amount?: number; + tiers?: UsageTier[]; + tier_behavior?: TierBehavior; + interval?: string; + interval_count?: number; + billing_units?: number; + billing_method?: BillingMethod; + max_purchase?: number | null; +}; + +const usageModelToBillingMethod = (usageModel?: UsageModel | null) => + usageModel === UsageModel.Prepaid + ? BillingMethod.Prepaid + : BillingMethod.UsageBased; + +const billingMethodToUsageModel = (billingMethod?: BillingMethod) => + billingMethod === BillingMethod.Prepaid + ? UsageModel.Prepaid + : UsageModel.PayPerUse; + +const shiftTierIncluded = ( + tier: UsageTier, + included: number, + direction: 1 | -1, +) => ({ + ...tier, + to: + typeof tier.to === "number" && tier.to > 0 + ? tier.to + included * direction + : tier.to, +}); + export function migrationItemToProductItem( migItem: Record, features: Feature[], @@ -16,16 +61,42 @@ export function migrationItemToProductItem( ? (getDefaultItem({ feature }) as ProductItem) : ({ feature_id: featureId } as ProductItem); - if (migItem.included !== undefined) { - base.included_usage = migItem.included as number; + const isBooleanItem = BOOLEAN_TYPES.has(base.feature_type as string); + + const price = migItem.price as MigrationPrice | undefined; + const hasPrice = !!price; + const included = + migItem.included !== undefined ? Number(migItem.included) : 0; + + if (!isBooleanItem) { + if (migItem.unlimited === true) { + base.included_usage = Infinite; + base.interval = null; + } else if (migItem.included !== undefined) { + base.included_usage = migItem.included as number; + } } - const price = migItem.price as Record | undefined; - if (price) { - base.tiers = [{ to: "inf", amount: Number(price.amount ?? 0) }]; - if (price.interval) base.interval = price.interval as ProductItemInterval; - if (price.billing_method) - base.usage_model = price.billing_method as UsageModel; - base.billing_units = 1; + + if (hasPrice) { + base.tiers = price.tiers?.length + ? price.tiers.map((tier) => shiftTierIncluded(tier, included, -1)) + : [{ to: "inf", amount: Number(price.amount ?? 0) }]; + base.interval = + price.interval && price.interval !== "one_off" + ? (price.interval as ProductItemInterval) + : null; + base.interval_count = price.interval_count; + base.usage_model = billingMethodToUsageModel(price.billing_method); + base.billing_units = price.billing_units ?? 1; + base.tier_behavior = price.tier_behavior ?? TierBehavior.Graduated; + base.usage_limit = + price.max_purchase == null ? null : included + price.max_purchase; + } else { + const reset = migItem.reset as Record | undefined; + if (reset?.interval) { + base.interval = reset.interval as ProductItemInterval; + base.interval_count = reset.interval_count as number | undefined; + } } return base; } @@ -35,14 +106,35 @@ export function productItemToMigrationItem( ): Record { const result: Record = { feature_id: item.feature_id }; if (item.included_usage !== null && item.included_usage !== undefined) { - result.included = item.included_usage; + if (item.included_usage === Infinite) { + result.unlimited = true; + } else { + result.included = Number(item.included_usage); + } } - if (item.tiers && item.tiers.length > 0) { + const included = result.included ? Number(result.included) : 0; + if (item.tiers?.length) { + const tiers = item.tiers.map((tier) => + shiftTierIncluded(tier, included, 1), + ); result.price = { - amount: item.tiers[0].amount ?? 0, - interval: item.interval ?? undefined, - billing_method: item.usage_model ?? undefined, + ...(tiers.length > 1 + ? { + tiers, + tier_behavior: item.tier_behavior ?? TierBehavior.Graduated, + } + : { amount: tiers[0].amount ?? 0 }), + interval: item.interval ?? "one_off", + ...(item.interval_count && item.interval_count !== 1 + ? { interval_count: item.interval_count } + : {}), + billing_units: item.billing_units ?? 1, + billing_method: usageModelToBillingMethod(item.usage_model), + max_purchase: + item.usage_limit == null ? null : item.usage_limit - included, }; + } else if (item.interval) { + result.reset = { interval: item.interval }; } return result; } diff --git a/vite/src/views/migrations/migration/shared/operationUtils.ts b/vite/src/views/migrations/migration/shared/operationUtils.ts index 77bdd217e..75016a9be 100644 --- a/vite/src/views/migrations/migration/shared/operationUtils.ts +++ b/vite/src/views/migrations/migration/shared/operationUtils.ts @@ -1,18 +1,30 @@ -import type { Operations } from "@autumn/shared"; +import type { Operations, UpdatePlanOp } from "@autumn/shared"; + +export function migrationUid(): string { + return Date.now().toString(36).slice(-3); +} export function hasValidOperations(operations: Operations): boolean { const ops = operations.customer ?? []; if (ops.length === 0) return false; return ops.every((op) => { if (op.type === "update_plan") - return ( - op.version !== undefined || (op.customize && op.customize.length > 0) - ); + return op.version !== undefined || hasCustomizations(op.customize); if (op.type === "add_plan") return !!op.plan_id; return false; }); } +function hasCustomizations( + customize: UpdatePlanOp["customize"], +): boolean { + if (!customize) return false; + if ((customize.add_items?.length ?? 0) > 0) return true; + if ((customize.remove_items?.length ?? 0) > 0) return true; + if (customize.price !== undefined) return true; + return false; +} + export function getOperationsSummaryText(operations: Operations): string { const ops = operations.customer ?? []; const updateCount = ops.filter((op) => op.type === "update_plan").length; diff --git a/vite/src/views/migrations/migration/shared/useCursorPagination.ts b/vite/src/views/migrations/migration/shared/useCursorPagination.ts new file mode 100644 index 000000000..00c4c9f94 --- /dev/null +++ b/vite/src/views/migrations/migration/shared/useCursorPagination.ts @@ -0,0 +1,52 @@ +import { useCallback, useMemo, useState } from "react"; + +type CursorState = { + resetKey: string; + stack: string[]; +}; + +export function useCursorPagination({ + pageSize, + resetKey = "", +}: { + pageSize: number; + resetKey?: string; +}) { + const [state, setState] = useState({ + resetKey, + stack: [""], + }); + const stack = state.resetKey === resetKey ? state.stack : [""]; + const currentPage = stack.length; + const currentCursor = stack[stack.length - 1] ?? ""; + const pagination = useMemo( + () => ({ pageIndex: currentPage - 1, pageSize }), + [currentPage, pageSize], + ); + + return { + currentCursor, + currentPage, + pagination, + canPrev: currentPage > 1, + pushCursor: useCallback( + (cursor: string) => + setState((prev) => ({ + resetKey, + stack: [...(prev.resetKey === resetKey ? prev.stack : [""]), cursor], + })), + [resetKey], + ), + popCursor: useCallback( + () => + setState((prev) => { + const stack = prev.resetKey === resetKey ? prev.stack : [""]; + return { + resetKey, + stack: stack.length > 1 ? stack.slice(0, -1) : stack, + }; + }), + [resetKey], + ), + }; +} diff --git a/vite/src/views/migrations/migration/useMigrationEditorForm.ts b/vite/src/views/migrations/migration/useMigrationEditorForm.ts index d3a9a4c23..b80b9bae3 100644 --- a/vite/src/views/migrations/migration/useMigrationEditorForm.ts +++ b/vite/src/views/migrations/migration/useMigrationEditorForm.ts @@ -44,7 +44,7 @@ export function useMigrationEditorForm({ defaultValues: { filter: (migration.filter ?? {}) as MigrationFilter, operations: (migration.operations ?? {}) as Operations, - noBillingChanges: migration.no_billing_changes ?? false, + noBillingChanges: migration.no_billing_changes ?? true, }, onSubmit: async ({ value }) => { try { diff --git a/vite/src/views/products/features/credit-systems/components/AiCreditSchema.tsx b/vite/src/views/products/features/credit-systems/components/AiCreditSchema.tsx index edaf869d8..b2cd1d3ef 100644 --- a/vite/src/views/products/features/credit-systems/components/AiCreditSchema.tsx +++ b/vite/src/views/products/features/credit-systems/components/AiCreditSchema.tsx @@ -1,7 +1,12 @@ -import { PlusIcon } from "lucide-react"; +import { InfoIcon } from "lucide-react"; import { FormLabel } from "@/components/v2/form/FormLabel"; import { Input } from "@/components/v2/inputs/Input"; import { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/v2/tooltips/Tooltip"; import { useAiProviders } from "../hooks/useAiProviders"; import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; import { AiCreditSchemaTable } from "./AiCreditSchemaTable"; @@ -43,39 +48,51 @@ export function AiCreditSchema({ form }: AiCreditSchemaProps) { />
-
- {activeProviderKeys.map((providerKey) => { - const provider = providers[providerKey]; - const modelFullIds = providerGroups[providerKey] ?? []; - const providerName = - provider?.name ?? - providerKey.charAt(0).toUpperCase() + providerKey.slice(1); + {activeProviderKeys.length > 0 && ( +
+ {activeProviderKeys.map((providerKey) => { + const provider = providers[providerKey]; + const modelFullIds = providerGroups[providerKey] ?? []; + const providerName = + provider?.name ?? + providerKey.charAt(0).toUpperCase() + providerKey.slice(1); - return ( - - ); - })} -
+ return ( + + ); + })} +
+ )}
e.stopPropagation()} > - Add Provider + + Add Provider Override + + + + + + Add specific markup overrides for certain providers/models. + + + Markup %
)} diff --git a/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx b/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx index 4e3c544b0..d1abcb963 100644 --- a/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx +++ b/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx @@ -1,59 +1,14 @@ -import { - FeatureType, - isAiCreditSystem, - joinModelId, - type ModelsDevProvider, -} from "@autumn/shared"; +import { FeatureType, isAiCreditSystem } from "@autumn/shared"; import { useStore } from "@tanstack/react-form"; import { useMemo } from "react"; import { GroupedTabButton } from "@/components/v2/buttons/GroupedTabButton"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; -import { useModelsDevPricing } from "@/hooks/queries/useAiModelsQuery"; import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; import { AiCreditSchema } from "./AiCreditSchema"; import { ClassicCreditSchema } from "./ClassicCreditSchema"; type CreditSchemaMode = "classic" | "ai"; -const DEFAULT_AI_MODEL_COMPANIES = ["anthropic", "google", "openai"] as const; - -const getReleaseDateMs = (releaseDate?: string) => { - if (!releaseDate) return -1; - const timestamp = Date.parse(releaseDate); - return Number.isNaN(timestamp) ? -1 : timestamp; -}; - -function getDefaultModelMarkups( - providers: Record, -): Record { - const result: Record = {}; - const preferredProvider = - providers["openrouter"] ?? Object.values(providers)[0]; - if (!preferredProvider) return result; - - const providerKey = preferredProvider.id; - for (const company of DEFAULT_AI_MODEL_COMPANIES) { - const companyModels = Object.entries(preferredProvider.models).filter( - ([key]) => key.startsWith(company), - ); - - const latestModel = companyModels.reduce< - [string, ModelsDevProvider["models"][string]] | null - >((currentLatest, candidate) => { - if (!currentLatest) return candidate; - const currentRelease = getReleaseDateMs(currentLatest[1].release_date); - const candidateRelease = getReleaseDateMs(candidate[1].release_date); - return candidateRelease > currentRelease ? candidate : currentLatest; - }, null); - - if (!latestModel) continue; - - const [modelKey] = latestModel; - result[joinModelId(providerKey, modelKey)] = {}; - } - return result; -} - interface CreditSystemSchemaProps { form: CreditSystemFormInstance; disableModeSwitch?: boolean; @@ -63,20 +18,16 @@ export function CreditSystemSchema({ form, disableModeSwitch = false, }: CreditSystemSchemaProps) { - const { providers } = useModelsDevPricing(); const type = useStore(form.store, (s) => s.values.type); const mode: CreditSchemaMode = isAiCreditSystem(type) ? "ai" : "classic"; const handleModeChange = (newMode: string) => { if (newMode === "ai") { - const modelMarkups = getDefaultModelMarkups(providers); form.setFieldValue("type", FeatureType.AiCreditSystem); form.setFieldValue("config", { ...form.state.values.config, schema: [] }); - form.setFieldValue( - "model_markups", - Object.keys(modelMarkups).length > 0 ? modelMarkups : {}, - ); + form.setFieldValue("model_markups", {}); + form.setFieldValue("provider_markups", {}); } else { form.setFieldValue("type", FeatureType.CreditSystem); form.setFieldValue("config", { @@ -86,6 +37,7 @@ export function CreditSystemSchema({ ], }); form.setFieldValue("model_markups", {}); + form.setFieldValue("provider_markups", {}); } }; diff --git a/vite/src/views/products/features/feature-list/CreditListColumns.tsx b/vite/src/views/products/features/feature-list/CreditListColumns.tsx index bbf35d616..0a7abf6a1 100644 --- a/vite/src/views/products/features/feature-list/CreditListColumns.tsx +++ b/vite/src/views/products/features/feature-list/CreditListColumns.tsx @@ -56,7 +56,7 @@ export const createCreditListColumns = ( }, { header: "Type", - size: 120, + size: 160, accessorKey: "type", cell: ({ row }: { row: Row }) => { const isAi = isAiCreditSystem(row.original.type); @@ -65,12 +65,12 @@ export const createCreditListColumns = ( {isAi ? ( <> - AI + AI Credit System ) : ( <> - Standard + Credit System )}
diff --git a/vite/src/views/products/plan/PlanEditorView.tsx b/vite/src/views/products/plan/PlanEditorView.tsx index 00625a86b..b8dea72b6 100644 --- a/vite/src/views/products/plan/PlanEditorView.tsx +++ b/vite/src/views/products/plan/PlanEditorView.tsx @@ -15,7 +15,7 @@ import { useProductQuery } from "../product/hooks/useProductQuery"; import { ProductContext } from "../product/ProductContext"; import { PlanEditor } from "./components/PlanEditor"; import { useOpenAddFeatureSheet } from "./hooks/useOpenAddFeatureSheet"; -import ConfirmNewVersionDialog from "./versioning/ConfirmNewVersionDialog"; +import PlanChangeDialog from "./versioning/PlanChangeDialog"; export default function PlanEditorView() { const { product_id } = useParams(); @@ -80,7 +80,7 @@ export default function PlanEditorView() { refetch, }} > - diff --git a/vite/src/views/products/plan/ProductSheets.tsx b/vite/src/views/products/plan/ProductSheets.tsx index 52ad52d74..359203b4c 100644 --- a/vite/src/views/products/plan/ProductSheets.tsx +++ b/vite/src/views/products/plan/ProductSheets.tsx @@ -1,14 +1,11 @@ import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared"; -import { AnimatePresence, motion } from "motion/react"; import { useEffect, useRef } from "react"; import { useDiscardItemAndClose, useProduct, useSheet, } from "@/components/v2/inline-custom-plan-editor/PlanEditorContext"; -import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; -import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; -import { useIsMobile } from "@/hooks/useIsMobile"; +import { InlineSheetPanel } from "@/components/v2/sheets/InlineSheetPanel"; import { getItemId } from "@/utils/product/productItemUtils"; import { ProductItemContext } from "../product/product-item/ProductItemContext"; @@ -20,13 +17,13 @@ import { SelectFeatureSheet } from "./components/SelectFeatureSheet"; import { SHEET_ANIMATION } from "./planAnimations"; export const ProductSheets = () => { - const isMobile = useIsMobile(); const { product, setProduct } = useProduct(); const { sheetType, itemId, initialItem, setInitialItem, + updateItemId, closeSheet, itemDraft, } = useSheet(); @@ -43,13 +40,35 @@ export const ProductSheets = () => { const featureItems = productV2ToFeatureItems({ items: product.items }); - const isCurrentItem = (item: ProductItem) => { - const actualIndex = product.items?.indexOf(item) ?? -1; - const currentItemId = getItemId({ item, itemIndex: actualIndex }); - return itemId === currentItemId; - }; + const matchedItemIndex = product.items + ? product.items.findIndex( + (item, index) => + !!item && + featureItems.includes(item) && + getItemId({ item, itemIndex: index }) === itemId, + ) + : -1; - const currentItem = featureItems.find(isCurrentItem); + const editingIndexRef = useRef(null); + + useEffect(() => { + if (matchedItemIndex !== -1) { + editingIndexRef.current = matchedItemIndex; + } else if (itemId === null) { + editingIndexRef.current = null; + } + }, [matchedItemIndex, itemId]); + + const resolvedItemIndex = + matchedItemIndex !== -1 + ? matchedItemIndex + : editingIndexRef.current !== null && + editingIndexRef.current < (product.items?.length ?? 0) + ? editingIndexRef.current + : -1; + + const currentItem = + resolvedItemIndex !== -1 ? product.items?.[resolvedItemIndex] : undefined; const lastItemIdRef = useRef(null); @@ -101,14 +120,19 @@ export const ProductSheets = () => { return; } - if (!product || !product.items) return; + if (!product || !product.items || resolvedItemIndex === -1) return; - const currentItemIndex = product.items.findIndex(isCurrentItem); - - if (currentItemIndex === -1) return; + const newItemId = getItemId({ + item: updatedItem, + itemIndex: resolvedItemIndex, + }); + if (newItemId !== itemId) { + updateItemId(newItemId); + lastItemIdRef.current = newItemId; + } const updatedItems = [...product.items]; - updatedItems[currentItemIndex] = updatedItem; + updatedItems[resolvedItemIndex] = updatedItem; setProduct({ ...product, items: updatedItems }); }; @@ -162,22 +186,12 @@ export const ProductSheets = () => { }; return ( - - {sheetType && ( - - - - {renderSheet()} - - - )} - + + {renderSheet()} + ); }; diff --git a/vite/src/views/products/plan/components/ConfirmMigrationDialog.tsx b/vite/src/views/products/plan/components/ConfirmMigrationDialog.tsx deleted file mode 100644 index 8d42f2730..000000000 --- a/vite/src/views/products/plan/components/ConfirmMigrationDialog.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/v2/buttons/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/v2/dialogs/Dialog"; -import { Input } from "@/components/v2/inputs/Input"; -import { useProductStore } from "@/hooks/stores/useProductStore"; -import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; - -export const ConfirmMigrationDialog = ({ - open, - setOpen, - startMigration, - version, -}: { - open: boolean; - setOpen: (open: boolean) => void; - startMigration: () => Promise; - version: number; -}) => { - const product = useProductStore((s) => s.product); - const [confirmText, setConfirmText] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - const handleMigrate = async () => { - if (confirmText !== product.id) { - toast.error("Confirmation text is incorrect"); - return; - } - - setIsLoading(true); - try { - await startMigration(); - setOpen(false); - setConfirmText(""); - } catch (_error) { - // Error handling is done in startMigration - } finally { - setIsLoading(false); - } - }; - - const handleOpenChange = (newOpen: boolean) => { - if (!isLoading) { - setOpen(newOpen); - if (!newOpen) { - setConfirmText(""); - } - } - }; - - return ( - - e.stopPropagation()}> - - - Migrate customers? - - -

- This will migrate all customers on {product.name} (version{" "} - {version}) to the latest version. -

- - Features and balances will be immediately migrated. Pricing - changes will take effect from the next billing cycle. Custom plans - and cancelled plans will not be migrated. - -

- Type {product.id}{" "} - to continue. -

-
-
- - setConfirmText(e.target.value)} - type="text" - placeholder={product.id} - className="w-full" - /> - - - - - -
-
- ); -}; diff --git a/vite/src/views/products/plan/components/EditPlanHeader.tsx b/vite/src/views/products/plan/components/EditPlanHeader.tsx index 2f840da67..20f94f2dc 100644 --- a/vite/src/views/products/plan/components/EditPlanHeader.tsx +++ b/vite/src/views/products/plan/components/EditPlanHeader.tsx @@ -1,8 +1,14 @@ -import { TriangleIcon, UserIcon } from "@phosphor-icons/react"; +import { + ArrowsClockwiseIcon, + TriangleIcon, + UserIcon, +} from "@phosphor-icons/react"; +import { IconButton } from "@/components/v2/buttons/IconButton"; import { parseAsString, useQueryStates } from "nuqs"; -import { useState } from "react"; -import { toast } from "sonner"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router"; import { AdminHover } from "@/components/general/AdminHover"; +import SmallSpinner from "@/components/general/SmallSpinner"; import { IconBadge } from "@/components/v2/badges/IconBadge"; import V2Breadcrumb from "@/components/v2/breadcrumb"; import { Button } from "@/components/v2/buttons/Button"; @@ -27,36 +33,54 @@ import { useIsCusPlanEditor, useProductStore, } from "@/hooks/stores/useProductStore.ts"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useEnv } from "@/utils/envUtils"; -import { getBackendErr } from "@/utils/genUtils"; -import { isOneOffProduct } from "@/utils/product/priceUtils"; +import { pushPage } from "@/utils/genUtils"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery.tsx"; import { useCusProductQuery } from "@/views/customers/customer/product/hooks/useCusProductQuery.tsx"; -import { useMigrationsQuery } from "../../product/hooks/queries/useMigrationsQuery.tsx.tsx"; import { useProductCountsQuery } from "../../product/hooks/queries/useProductCountsQuery"; import { useProductQuery, useProductQueryState, } from "../../product/hooks/useProductQuery"; -import { ConfirmMigrationDialog } from "./ConfirmMigrationDialog"; +import { + MigrateCustomersDialog, + useMigratableVersions, +} from "../versioning/MigrateCustomersDialog"; import { PlanToolbar } from "./PlanToolbar.tsx"; export const EditPlanHeader = () => { - const { numVersions } = useProductQuery(); + const { numVersions, versionCounts, isLoading } = useProductQuery(); const product = useProductStore((s) => s.product); const { counts } = useProductCountsQuery( product.version ? { version: product.version } : {}, ); - const { refetch: refetchMigrations } = useMigrationsQuery(); const { queryStates, setQueryStates } = useProductQueryState(); - const axiosInstance = useAxiosInstance(); + const navigate = useNavigate(); const isCusPlanEditor = useIsCusPlanEditor(); - const [confirmMigrateOpen, setConfirmMigrateOpen] = useState(false); const flags = useAutumnFlags(); const { mappings } = useRCMappings(); const { org } = useOrg(); const env = useEnv(); + const currency = org?.default_currency ?? "USD"; + const [migrateDialogOpen, setMigrateDialogOpen] = useState(false); + + const pastVersionsWithCustomers = useMemo(() => { + if (!numVersions || numVersions <= 1) return []; + return Object.entries(versionCounts) + .filter(([version, counts]) => { + const v = Number(version); + if (v >= numVersions) return false; + const nonCustomActive = (counts.active ?? 0) - (counts.custom ?? 0); + return nonCustomActive > 0; + }) + .map(([version]) => Number(version)); + }, [numVersions, versionCounts]); + const migratableVersions = useMigratableVersions({ + productId: product.id, + latestVersion: numVersions, + pastVersions: pastVersionsWithCustomers, + currency, + }); const hasRCMapping = flags.revenuecat && @@ -92,23 +116,6 @@ export const EditPlanHeader = () => { } }; - const migrateCustomers = async () => { - try { - const { data } = await axiosInstance.post("/v1/migrations", { - from_product_id: product.id, - from_version: product.version, - to_product_id: product.id, - to_version: numVersions, - }); - - await refetchMigrations(); - - toast.success(`Migration started. ID: ${data.id}`); - } catch (error) { - toast.error(getBackendErr(error, "Something went wrong with migration")); - } - }; - const getProductAdminHover = () => { return [ { @@ -126,27 +133,28 @@ export const EditPlanHeader = () => { ]; }; - // Determine if migration button should be shown - const fromIsOneOff = isOneOffProduct(product.items); - const migrateCount = - (counts?.active || 0) - (counts?.canceled || 0) - (counts?.custom || 0); - const version = product.version; + const handleCustomerCountClick = () => { + const activeCount = counts?.active || 0; + if (activeCount === 0) return; - const canMigrate = - counts && - migrateCount > 0 && - !fromIsOneOff && - version && - version < numVersions && - !isCusPlanEditor; + const versionKey = `${product.id}:${product.version}`; + const path = pushPage({ + path: `/customers`, + queryParams: { version: versionKey }, + preserveParams: false, + }); + navigate(path, { state: { preAppliedFilters: true } }); + }; return ( <> -
{isCusPlanEditor ? ( @@ -174,7 +182,9 @@ export const EditPlanHeader = () => { {product.name} - v{product.version} + + v{product.version} +
@@ -195,9 +205,15 @@ export const EditPlanHeader = () => { { key: "custom", value: counts?.custom?.toString() || "0" }, ]} > - }> - {counts?.active || 0} - + {hasRCMapping && ( @@ -230,31 +246,53 @@ export const EditPlanHeader = () => {
- {canMigrate && ( - + )} - {numVersions && numVersions > 1 && ( - [ + version.toString(), + `Version ${version}`, + ]), + )} + > - {versionOptions.map((version) => ( - - Version {version} - - ))} + {versionOptions.map((version) => { + const count = versionCounts[version]?.active || 0; + const hasLoaded = Object.keys(versionCounts).length > 0; + return ( + +
+ Version {version} + {hasLoaded ? ( + }> + {count} + + ) : ( + + )} +
+
+ ); + })}
)} diff --git a/vite/src/views/products/plan/components/PlanEditor.tsx b/vite/src/views/products/plan/components/PlanEditor.tsx index bfc67e404..7f9987070 100644 --- a/vite/src/views/products/plan/components/PlanEditor.tsx +++ b/vite/src/views/products/plan/components/PlanEditor.tsx @@ -45,9 +45,10 @@ export const PlanEditor = () => { )}
- + +
); diff --git a/vite/src/views/products/plan/components/SaveChangesBar.tsx b/vite/src/views/products/plan/components/SaveChangesBar.tsx index 0a9f575a3..a6f517c31 100644 --- a/vite/src/views/products/plan/components/SaveChangesBar.tsx +++ b/vite/src/views/products/plan/components/SaveChangesBar.tsx @@ -1,4 +1,4 @@ -import { isFeaturePriceItem, productV2ToBasePrice } from "@autumn/shared"; +import { isFeaturePriceItem } from "@autumn/shared"; import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/v2/buttons/Button"; @@ -8,15 +8,14 @@ import { useHasChanges, useIsCusPlanEditor, useProductStore, - useWillVersion, } from "@/hooks/stores/useProductStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useProductCountsQuery } from "../../product/hooks/queries/useProductCountsQuery"; import { useProductQuery } from "../../product/hooks/useProductQuery"; import { useProductContext } from "../../product/ProductContext"; import { updateProduct } from "../../product/utils/updateProduct"; import { useProductChangedAlert } from "../hooks/useProductChangedAlert"; +import { useProductCountsQuery } from "../../product/hooks/queries/useProductCountsQuery"; import { PlanEditorBar } from "./PlanEditorBar"; interface SaveChangesBarProps { @@ -34,17 +33,14 @@ export const SaveChangesBar = ({ const setProduct = useProductStore((s) => s.setProduct); const { type: sheetType } = useSheetStore(); const hasChanges = useHasChanges(); - const willVersion = useWillVersion(); const [saving, setSaving] = useState(false); const { invalidate: invalidateProducts } = useProductsQuery(); - const { counts, isLoading } = useProductCountsQuery(); const { refetch: queryRefetch } = useProductQuery(); - - // const { } - - const basePrice = productV2ToBasePrice({ product }); + const { counts, isLoading: isCountsLoading } = useProductCountsQuery( + product.version ? { version: product.version } : {}, + ); const isCusPlanEditor = useIsCusPlanEditor(); const saveButtonText = isCusPlanEditor ? "Save and Return" : "Save"; @@ -65,16 +61,15 @@ export const SaveChangesBar = ({ // return; // } - if (!isOnboarding && isLoading) { - toast.error("Plan counts are loading"); - return; - } - - // If changes require versioning and we can't confirm there are 0 customers, show dialog - // This errs on the side of caution when counts data is unavailable - if (!isOnboarding && willVersion && (!counts || counts.all !== 0)) { - setShowNewVersionDialog(true); - return; + if (!isOnboarding) { + if (isCountsLoading) { + toast.error("Plan counts are loading"); + return; + } + if ((counts?.all ?? 0) > 0) { + setShowNewVersionDialog(true); + return; + } } setSaving(true); @@ -91,6 +86,7 @@ export const SaveChangesBar = ({ axiosInstance, productId: product.id, product, + version: product.version, onSuccess: async () => { await queryRefetch(); invalidateProducts(); diff --git a/vite/src/views/products/plan/components/edit-plan-feature/AdvancedSettings.tsx b/vite/src/views/products/plan/components/edit-plan-feature/AdvancedSettings.tsx index 9bc36596a..e57705f1d 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/AdvancedSettings.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/AdvancedSettings.tsx @@ -4,13 +4,11 @@ import { isFeaturePriceItem, UsageModel, } from "@autumn/shared"; -import { AreaCheckbox } from "@/components/v2/checkboxes/AreaCheckbox"; import { SheetAccordion, SheetAccordionItem, } from "@/components/v2/sheets/SheetAccordion"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; -import { notNullish } from "@/utils/genUtils"; import { getFeatureCreditSystem, getFeatureUsageType, @@ -24,7 +22,7 @@ import { UsageLimit } from "./advanced-settings/UsageLimit"; export function AdvancedSettings() { const { features } = useFeaturesQuery(); - const { item, setItem } = useProductItemContext(); + const { item } = useProductItemContext(); const { hasEntityFeatureId } = useHasEntityFeatureId(); if (!item) return null; @@ -41,7 +39,6 @@ export function AdvancedSettings() { ); // Determine what will show in Advanced section - const showResetUsage = usageType === FeatureUsageType.Single; const showUsageLimits = isPriced; const showRollover = hasCreditSystem || usageType === FeatureUsageType.Single; const showEntityFeature = hasEntityFeatureId && hasOtherContinuousFeatures; @@ -53,7 +50,6 @@ export function AdvancedSettings() { // Hide Advanced section if nothing will render inside it const hasAnyContent = - showResetUsage || showUsageLimits || showRollover || showEntityFeature || @@ -69,22 +65,6 @@ export function AdvancedSettings() { // description="Additional configuration options for this feature" >
- {/* Reset existing usage when plan is enabled */} - {showResetUsage && ( - - setItem({ - ...item, - reset_usage_when_enabled: checked, - }) - } - /> - )} - {/* Usage Limits */} {showUsageLimits && } diff --git a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx index 98f901f99..135ef106e 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx @@ -134,12 +134,16 @@ export function EditPlanFeatureSheet({ + <> Define how customers on plan{" "} - {product.name} can - use feature{" "} - {feature?.name} -

+ + {product.name} + {" "} + can use feature{" "} + + {feature?.name} + + } action={ { if (includedUsage === Infinite) { - return "Unlimited"; + return ""; } if (includedUsage === null || includedUsage === undefined) { return ""; @@ -48,18 +48,29 @@ export function IncludedUsage() {
- {isAiCreditSystem(feature?.type) - ? `USD budget ${isFeaturePrice ? "granted before billing" : "allocated to this plan"}` - : <>Quantity of {getFeatureName({ feature, plural: true })}{isFeaturePrice ? " granted before billing" : " that can be used"} - } + {isAiCreditSystem(feature?.type) ? ( + `USD budget ${isFeaturePrice ? "granted before billing" : "allocated to this plan"}` + ) : ( + <> + Quantity of  + + {getFeatureName({ feature, plural: true })} + + {isFeaturePrice + ? " granted before billing" + : " that can be used"} + + )}
- {isAiCreditSystem(feature?.type) ? ( - + {isAiCreditSystem(feature?.type) ? ( + $ { const value = e.target.value.trim(); diff --git a/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx b/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx index 58f9ff1fc..676374515 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx @@ -223,7 +223,7 @@ export function PriceTiers({ const amountValue = isFlatMode ? (tier.flat_amount ?? 0) : tier.amount; return ( -
+
{Number(includedUsage) === 0 && index === 0 ? "first" diff --git a/vite/src/views/products/plan/components/edit-plan-feature/SheetFooterActions.tsx b/vite/src/views/products/plan/components/edit-plan-feature/SheetFooterActions.tsx index 395a5e35d..5e653c8b4 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/SheetFooterActions.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/SheetFooterActions.tsx @@ -1,6 +1,9 @@ import { Button } from "@/components/v2/buttons/Button"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; -import { useSheet } from "@/components/v2/inline-custom-plan-editor/PlanEditorContext"; +import { + useSetCurrentItem, + useSheet, +} from "@/components/v2/inline-custom-plan-editor/PlanEditorContext"; import { cn } from "@/lib/utils"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; @@ -11,12 +14,17 @@ export function SheetFooterActions({ hasChanges: boolean; onBeforeCommit?: () => void; }) { - const { setItem, handleUpdateProductItem } = useProductItemContext(); - const { initialItem } = useSheet(); + const { handleUpdateProductItem } = useProductItemContext(); + const { initialItem, itemDraft } = useSheet(); + const setCurrentItem = useSetCurrentItem(); const handleDiscard = () => { + if (itemDraft.session) { + itemDraft.discardItem(); + return; + } if (initialItem) { - setItem(initialItem); + setCurrentItem(initialItem); } }; diff --git a/vite/src/views/products/plan/components/new-feature/NewFeatureType.tsx b/vite/src/views/products/plan/components/new-feature/NewFeatureType.tsx index 3e06d28c3..f046fe991 100644 --- a/vite/src/views/products/plan/components/new-feature/NewFeatureType.tsx +++ b/vite/src/views/products/plan/components/new-feature/NewFeatureType.tsx @@ -2,6 +2,7 @@ import { FeatureType as APIFeatureType, type CreateFeature, FeatureUsageType, + isAnyCreditSystem, } from "@autumn/shared"; import { BarcodeIcon, CoinsIcon } from "@phosphor-icons/react"; import { PanelButton } from "@/components/v2/buttons/PanelButton"; @@ -57,7 +58,7 @@ export function NewFeatureType({
{ setFeature({ ...feature, diff --git a/vite/src/views/products/plan/components/plan-card/PlanFeatureList.tsx b/vite/src/views/products/plan/components/plan-card/PlanFeatureList.tsx index 6d1b064fa..915d1c4d3 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanFeatureList.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanFeatureList.tsx @@ -96,7 +96,7 @@ export const PlanFeatureList = ({ const itemIndex = product.items?.indexOf(item) ?? -1; return (
{prepaidQuantity && ( - + x{parseFloat(Number(prepaidQuantity).toFixed(2))} )} diff --git a/vite/src/views/products/plan/versioning/ConfirmNewVersionDialog.tsx b/vite/src/views/products/plan/versioning/ConfirmNewVersionDialog.tsx deleted file mode 100644 index 5c8a8a034..000000000 --- a/vite/src/views/products/plan/versioning/ConfirmNewVersionDialog.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/v2/buttons/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/v2/dialogs/Dialog"; -import { Input } from "@/components/v2/inputs/Input"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { useProductStore } from "@/hooks/stores/useProductStore"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useProductQuery } from "../../product/hooks/useProductQuery"; -import { updateProduct } from "../../product/utils/updateProduct"; - -export default function ConfirmNewVersionDialog({ - open, - setOpen, - onVersionCreated, -}: { - open: boolean; - setOpen: (open: boolean) => void; - onVersionCreated?: () => void; -}) { - const axiosInstance = useAxiosInstance(); - const product = useProductStore((s) => s.product); - const { refetch } = useProductQuery(); - const { invalidate: invalidateProducts } = useProductsQuery(); - - const [confirmText, setConfirmText] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - const onClick = async () => { - if (confirmText !== product.id) { - toast.error("Confirmation text is incorrect"); - return; - } - - setIsLoading(true); - await updateProduct({ - axiosInstance, - productId: product.id, - product, - onSuccess: async () => { - await refetch(); - invalidateProducts(); - onVersionCreated?.(); - }, - }); - setIsLoading(false); - setOpen(false); - // toast.success("New version created successfully"); - }; - - return ( - - - - Create new version? - -

- After creating a new version, it will be{" "} - - active immediately for new customers - - . You can migrate existing customers to the new version after. -

-

- Type {product.id} to continue. -

- setConfirmText(e.target.value)} - type="text" - placeholder={product.id} - className="w-full text-black" - /> -
-
- - - -
-
- ); -} diff --git a/vite/src/views/products/plan/versioning/MigrateCustomersDialog.tsx b/vite/src/views/products/plan/versioning/MigrateCustomersDialog.tsx new file mode 100644 index 000000000..befea1da3 --- /dev/null +++ b/vite/src/views/products/plan/versioning/MigrateCustomersDialog.tsx @@ -0,0 +1,315 @@ +import type { FrontendProduct } from "@autumn/shared"; +import { productV2ToFrontendProduct } from "@autumn/shared"; +import { UserIcon } from "@phosphor-icons/react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { PlanItemsSection } from "@/components/forms/shared"; +import { IconBadge } from "@/components/v2/badges/IconBadge"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { RadioGroup } from "@/components/v2/radio-groups/RadioGroup"; +import { AreaRadioGroupItem } from "@/components/v2/radio-groups/AreaRadioGroupItem"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/v2/selects/Select"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useOrg } from "@/hooks/common/useOrg"; +import { getBackendErr, navigateTo } from "@/utils/genUtils"; +import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; +import { + buildVersionMigrationDraft, + type VersionMigrateScope, +} from "./buildMigrationDraft"; +import { getPlanPriceChange, hasPlanMigrationDiff } from "./planMigrationDiff"; + +interface MigrateCustomersDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + productId: string; + latestVersion: number; + migratableVersions: number[]; + versionCounts: Record< + number, + { active: number; canceled: number; custom: number; trialing: number } + >; +} + +export function useMigratableVersions({ + productId, + latestVersion, + pastVersions, + currency, +}: { + productId: string; + latestVersion: number; + pastVersions: number[]; + currency: string; +}) { + const { products } = useProductsQuery({ allVersions: true }); + + return useMemo(() => { + const latest = products.find( + (p) => p.id === productId && p.version === latestVersion, + ); + if (!latest) return []; + + const latestProduct = productV2ToFrontendProduct({ product: latest }); + const versions: number[] = []; + for (const p of products) { + if (p.id !== productId) continue; + if (!pastVersions.includes(p.version)) continue; + if ( + hasPlanMigrationDiff({ + baseProduct: productV2ToFrontendProduct({ product: p }), + product: latestProduct, + currency, + }) + ) { + versions.push(p.version); + } + } + return versions.sort((a, b) => b - a); + }, [products, productId, latestVersion, pastVersions, currency]); +} + +function useVersionProducts(productId: string, versions: number[]) { + const { products } = useProductsQuery({ allVersions: true }); + + return useMemo(() => { + const map = new Map(); + for (const p of products) { + if (p.id !== productId) continue; + if (!versions.includes(p.version)) continue; + map.set(p.version, productV2ToFrontendProduct({ product: p })); + } + return map; + }, [products, productId, versions]); +} + +function useLatestProduct(productId: string, latestVersion: number) { + const { products } = useProductsQuery({ allVersions: true }); + + return useMemo(() => { + const p = products.find( + (p) => p.id === productId && p.version === latestVersion, + ); + return p ? productV2ToFrontendProduct({ product: p }) : undefined; + }, [products, productId, latestVersion]); +} + +function VersionDiff({ + fromProduct, + toProduct, + currency, +}: { + fromProduct: FrontendProduct; + toProduct: FrontendProduct; + currency: string; +}) { + const { features = [] } = useFeaturesQuery(); + const priceChange = getPlanPriceChange({ + baseProduct: fromProduct, + product: toProduct, + currency, + }); + + return ( + {}} + priceChange={priceChange} + readOnly + /> + ); +} + +export function MigrateCustomersDialog({ + open, + onOpenChange, + productId, + latestVersion, + migratableVersions, + versionCounts, +}: MigrateCustomersDialogProps) { + const navigate = useNavigate(); + const { createMigration, isCreating } = useMigrationsQuery(); + const { org } = useOrg(); + const currency = org?.default_currency ?? "USD"; + + const [scope, setScope] = useState("all"); + const [selectedVersion, setSelectedVersion] = useState(null); + + const effectiveVersion = + selectedVersion && migratableVersions.includes(selectedVersion) + ? selectedVersion + : (migratableVersions[0] ?? null); + + const versionProducts = useVersionProducts(productId, migratableVersions); + const latestProduct = useLatestProduct(productId, latestVersion); + + const selectedFromProduct = + effectiveVersion !== null + ? (versionProducts.get(effectiveVersion) ?? null) + : null; + + const versionSelectItems = Object.fromEntries( + migratableVersions.map((v) => [String(v), `Version ${v}`]), + ); + + const handleCreate = async () => { + if (migratableVersions.length === 0) return; + + const draft = buildVersionMigrationDraft({ + productId, + latestVersion, + scope, + pastVersions: migratableVersions, + }); + + try { + const migration = await createMigration(draft); + + toast.success("Migration created"); + onOpenChange(false); + navigateTo(`/migrations/${migration.id}?step=live&run=true`, navigate); + } catch (error) { + toast.error(getBackendErr(error, "Failed to create migration")); + } + }; + + if (migratableVersions.length === 0) return null; + + return ( + !isCreating && onOpenChange(next)} + > + + + Migrate customers to v{latestVersion} + + +
+ +
+ {migratableVersions.length > 1 && ( + { + if (val === "all") { + setScope("all"); + } else { + setScope(selectedVersion); + } + }} + > + + + + )} + +
+ + Version + + +
+ + {selectedFromProduct && latestProduct && ( +
+ + Changes from v{effectiveVersion} → v{latestVersion} + + +
+ )} + + + Customers on custom plans will not be migrated. + +
+
+
+ + + + Preview Migration + + +
+
+ ); +} diff --git a/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx b/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx new file mode 100644 index 000000000..ad1678368 --- /dev/null +++ b/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx @@ -0,0 +1,418 @@ +import type { FrontendProduct } from "@autumn/shared"; +import { productsAreSame } from "@autumn/shared"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { PlanItemsSection } from "@/components/forms/shared"; +import { Switch } from "@/components/ui/switch"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; +import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; +import { Input } from "@/components/v2/inputs/Input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { RadioGroup } from "@/components/v2/radio-groups/RadioGroup"; +import { AreaRadioGroupItem } from "@/components/v2/radio-groups/AreaRadioGroupItem"; +import { useOrg } from "@/hooks/common/useOrg"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useProductStore } from "@/hooks/stores/useProductStore"; +import { ProductService } from "@/services/products/ProductService"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr, navigateTo } from "@/utils/genUtils"; +import { + useProductQuery, + useProductQueryState, +} from "../../product/hooks/useProductQuery"; +import { updateProduct } from "../../product/utils/updateProduct"; +import { + buildInPlaceUpdatePlanParams, + buildMigrationDraft, + type MigrationScope, +} from "./buildMigrationDraft"; +import { getPlanPriceChange, hasPlanMigrationDiff } from "./planMigrationDiff"; + +type VersionChoice = "new" | "update"; + +function ConfirmInput({ + productId, + value, + onChange, +}: { + productId: string; + value: string; + onChange: (value: string) => void; +}) { + return ( +
+
+ Type + + to continue. +
+ onChange(e.target.value)} + type="text" + placeholder={productId} + className="w-full" + /> +
+ ); +} + +export default function PlanChangeDialog({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) { + const axiosInstance = useAxiosInstance(); + const navigate = useNavigate(); + const product = useProductStore((s) => s.product); + const baseProduct = useProductStore((s) => s.baseProduct); + const setBaseProduct = useProductStore((s) => s.setBaseProduct); + const { features = [] } = useFeaturesQuery(); + const { refetch, numVersions, versionCounts } = useProductQuery(); + const { setQueryStates } = useProductQueryState(); + const { invalidate: invalidateProducts } = useProductsQuery(); + const { createMigration, invalidate: invalidateMigrations } = + useMigrationsQuery(); + const { org } = useOrg(); + + const [step, setStep] = useState<1 | 2>(1); + const [versionChoice, setVersionChoice] = useState("new"); + const [migrationScope, setMigrationScope] = + useState("all_customers"); + const [migrationBaseProduct, setMigrationBaseProduct] = + useState(null); + const [includeCustom, setIncludeCustom] = useState(false); + const [confirmText, setConfirmText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const confirmed = confirmText === product.id; + + const currency = org?.default_currency ?? "USD"; + const priceChange = useMemo( + () => getPlanPriceChange({ baseProduct, product, currency }), + [baseProduct, product, currency], + ); + const hasMultipleVersions = (numVersions ?? 1) > 1; + + const customCount = useMemo(() => { + return Object.values(versionCounts).reduce( + (sum, vc) => sum + (vc.custom ?? 0), + 0, + ); + }, [versionCounts]); + + const hasChanges = useMemo(() => { + if (!baseProduct || features.length === 0) return false; + const { same } = productsAreSame({ + curProductV2: baseProduct, + newProductV2: product, + features, + }); + return !same; + }, [baseProduct, product, features]); + const hasMigrationDiff = useMemo(() => { + return hasPlanMigrationDiff({ baseProduct, product, currency }); + }, [baseProduct, product, currency]); + + const resetState = () => { + setStep(1); + setVersionChoice("new"); + setMigrationScope("all_customers"); + setMigrationBaseProduct(null); + setIncludeCustom(false); + setConfirmText(""); + }; + + const syncToLatestVersion = async () => { + await setQueryStates({ version: null }); + await refetch(); + invalidateProducts(); + }; + + const markSaved = () => { + setBaseProduct(product as FrontendProduct); + }; + + const handleStep1Action = async () => { + if (!confirmed) { + toast.error("Confirmation text is incorrect"); + return; + } + + if (versionChoice === "update") { + if (!baseProduct) return; + if (product.id !== baseProduct.id) { + toast.error( + "Plan IDs cannot be changed when updating the current version", + ); + return; + } + + setIsLoading(true); + try { + await ProductService.updatePlan( + axiosInstance, + buildInPlaceUpdatePlanParams({ + baseProduct, + editedProduct: product, + features, + }), + ); + markSaved(); + toast.success("Plan updated"); + if (hasMigrationDiff) { + setMigrationBaseProduct(baseProduct); + setStep(2); + } else { + setOpen(false); + resetState(); + void refetch(); + } + void invalidateProducts(); + } catch (error) { + toast.error(getBackendErr(error, "Failed to update plan")); + } finally { + setIsLoading(false); + } + return; + } + + setIsLoading(true); + try { + const result = await updateProduct({ + axiosInstance, + productId: product.id, + product, + version: product.version, + onSuccess: async () => { + invalidateProducts(); + }, + }); + + if (!result) return; + markSaved(); + toast.success("New version created"); + setOpen(false); + resetState(); + syncToLatestVersion(); + } catch (error) { + toast.error(getBackendErr(error, "Failed to save plan")); + } finally { + setIsLoading(false); + } + }; + + const handleStep2Action = async () => { + const draftBaseProduct = migrationBaseProduct ?? baseProduct; + if (!draftBaseProduct) return; + if ( + !hasPlanMigrationDiff({ + baseProduct: draftBaseProduct, + product, + currency, + }) + ) { + setOpen(false); + resetState(); + void refetch(); + void invalidateProducts(); + return; + } + + setIsLoading(true); + try { + const scope = hasMultipleVersions ? migrationScope : "this_version"; + + const draft = buildMigrationDraft({ + baseProduct: draftBaseProduct, + editedProduct: product, + features, + scope, + includeCustom, + }); + + const migration = await createMigration({ + id: draft.id, + filter: draft.filter, + operations: draft.operations, + no_billing_changes: draft.no_billing_changes, + }); + + await invalidateMigrations(); + toast.success("Migration created"); + setOpen(false); + resetState(); + navigateTo(`/migrations/${migration.id}?step=live&run=true`, navigate); + void refetch(); + void invalidateProducts(); + } catch (error) { + toast.error(getBackendErr(error, "Failed to create migration")); + } finally { + setIsLoading(false); + } + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!isLoading) { + setOpen(nextOpen); + if (!nextOpen) resetState(); + } + }; + + const buttonText = + step === 1 + ? versionChoice === "new" + ? "Create new version" + : "Update plan" + : "Preview migration"; + + return ( + + + + + {step === 1 ? "Save plan changes" : "Create migration"} + + + +
+ +
+ {step === 1 && ( + <> + {hasChanges && ( + {}} + priceChange={priceChange} + readOnly + /> + )} + + + setVersionChoice(val as VersionChoice) + } + > + + + + + + + )} + + {step === 2 && ( + <> +

+ Autumn updated the current version of this plan directly. + New customers will get these changes immediately. Now create + a migration so you can review and apply the same changes to + current users. +

+ + {hasMultipleVersions && ( + + setMigrationScope(val as MigrationScope) + } + > + + + + )} + + {customCount > 0 && ( +
+
+ + Apply to custom plans + + + There {customCount === 1 ? "is" : "are"} {customCount}{" "} + user + {customCount !== 1 ? "s" : ""} on custom versions of + this plan + +
+ +
+ )} + + {!hasMultipleVersions && customCount === 0 && ( +

+ Preview a migration for current users on this plan. +

+ )} + + )} +
+
+
+ + + + {buttonText} + + +
+
+ ); +} diff --git a/vite/src/views/products/plan/versioning/buildMigrationDraft.ts b/vite/src/views/products/plan/versioning/buildMigrationDraft.ts new file mode 100644 index 000000000..c1dae5b9e --- /dev/null +++ b/vite/src/views/products/plan/versioning/buildMigrationDraft.ts @@ -0,0 +1,261 @@ +import type { + ApiPlanV1, + Feature, + FrontendProduct, + UpdatePlanOp, + UpdatePlanParamsV2Input, +} from "@autumn/shared"; +import { + diffPlanV1, + itemToBillingInterval, + productItemsToPlanItemsV1, + productV2ToBasePrice, + productV2ToFeatureItems, + sortProductItems, +} from "@autumn/shared"; +import type { DiffedCustomizePlanV1 } from "@autumn/shared/utils/planV1Utils/diff/diffPlanV1.js"; +import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; +import { migrationUid } from "@/views/migrations/migration/shared/operationUtils"; + +export interface MigrationDraft { + id: string; + filter: MigrationFilter; + operations: Operations; + no_billing_changes: boolean; +} + +export function frontendProductToApiPlanV1( + product: FrontendProduct, + features: Feature[], +): ApiPlanV1 { + const sorted = sortProductItems(product.items, features); + const basePriceItem = productV2ToBasePrice({ product: product as any }); + const featureItems = productV2ToFeatureItems({ + items: sorted, + withBasePrice: false, + }); + const planItems = productItemsToPlanItemsV1({ + items: featureItems, + features, + }); + + const basePrice: ApiPlanV1["price"] = basePriceItem + ? { + amount: basePriceItem.price, + interval: itemToBillingInterval({ item: basePriceItem }), + ...(basePriceItem.interval_count !== 1 && + typeof basePriceItem.interval_count === "number" + ? { interval_count: basePriceItem.interval_count } + : {}), + } + : null; + + const freeTrial: ApiPlanV1["free_trial"] = product.free_trial + ? { + duration_type: product.free_trial.duration, + duration_length: product.free_trial.length, + card_required: product.free_trial.card_required ?? false, + ...(product.free_trial.on_end + ? { on_end: product.free_trial.on_end } + : {}), + } + : undefined; + + return { + id: product.id, + name: product.name || "", + description: product.description || null, + group: product.group || null, + version: product.version, + add_on: product.is_add_on, + auto_enable: product.is_default, + price: basePrice, + items: planItems, + free_trial: freeTrial, + created_at: product.created_at, + env: product.env, + archived: product.archived ?? false, + base_variant_id: null, + config: product.config ?? { ignore_past_due: false }, + } satisfies ApiPlanV1; +} + +function planItemsToUpdateParams( + items: ApiPlanV1["items"], +): NonNullable { + return items.map(({ feature, display, reset, price, proration, rollover, ...item }) => ({ + ...item, + ...(reset ? { reset } : {}), + ...(price ? { price } : {}), + ...(proration ? { proration } : {}), + ...(rollover + ? { + rollover: { + expiry_duration_type: rollover.expiry_duration_type, + expiry_duration_length: rollover.expiry_duration_length, + ...(rollover.max != null ? { max: rollover.max } : {}), + ...(rollover.max_percentage != null + ? { max_percentage: rollover.max_percentage } + : {}), + }, + } + : {}), + })); +} + +export function buildInPlaceUpdatePlanParams({ + baseProduct, + editedProduct, + features, +}: { + baseProduct: FrontendProduct; + editedProduct: FrontendProduct; + features: Feature[]; +}): UpdatePlanParamsV2Input { + const plan = frontendProductToApiPlanV1(editedProduct, features); + + return { + plan_id: baseProduct.id, + version: baseProduct.version, + name: plan.name, + description: plan.description ?? "", + group: plan.group ?? "", + add_on: plan.add_on, + auto_enable: plan.auto_enable, + price: plan.price, + items: planItemsToUpdateParams(plan.items), + free_trial: plan.free_trial ?? null, + config: plan.config, + disable_version: true, + } satisfies UpdatePlanParamsV2Input; +} + +function diffHasBillingChanges(diff: DiffedCustomizePlanV1): boolean { + if (diff.price !== undefined) return true; + if (diff.add_items?.some((i) => i.price != null)) return true; + return false; +} + +function getMigratablePlanDiff( + diff: DiffedCustomizePlanV1, +): DiffedCustomizePlanV1 { + return { + ...(diff.price !== undefined ? { price: diff.price } : {}), + ...(diff.add_items !== undefined ? { add_items: diff.add_items } : {}), + ...(diff.remove_items !== undefined + ? { remove_items: diff.remove_items } + : {}), + ...(diff.update_items !== undefined + ? { update_items: diff.update_items } + : {}), + }; +} + +export type MigrationScope = "this_version" | "all_customers"; + +export type VersionMigrateScope = "all" | number; + +export function buildVersionMigrationDraft({ + productId, + latestVersion, + scope, + pastVersions, + includeCustom = false, +}: { + productId: string; + latestVersion: number; + scope: VersionMigrateScope; + pastVersions: number[]; + includeCustom?: boolean; +}): MigrationDraft { + const versions = scope === "all" ? pastVersions : [scope]; + const versionMatcher = + versions.length === 1 ? versions[0] : { $in: versions }; + const basePlanFilter = { + plan_id: productId, + version: versionMatcher, + }; + const planFilter = includeCustom + ? basePlanFilter + : { ...basePlanFilter, custom: false }; + const versionOp = (custom: boolean): UpdatePlanOp => ({ + type: "update_plan", + plan_filter: { ...basePlanFilter, custom }, + version: latestVersion, + }); + + const filter: MigrationFilter = { + customer: { plan: planFilter }, + }; + + const operations: Operations = { + customer: includeCustom + ? [versionOp(false), versionOp(true)] + : [versionOp(false)], + }; + + const suffix = scope === "all" ? "migrate-all" : `migrate-v${scope}`; + + return { + id: `${productId}-${suffix}-to-v${latestVersion}-${migrationUid()}`, + filter, + operations, + no_billing_changes: true, + }; +} + +export function buildMigrationDraft({ + baseProduct, + editedProduct, + features, + scope, + includeCustom = false, +}: { + baseProduct: FrontendProduct; + editedProduct: FrontendProduct; + features: Feature[]; + scope: MigrationScope; + includeCustom?: boolean; +}): MigrationDraft { + const from = frontendProductToApiPlanV1(baseProduct, features); + const to = frontendProductToApiPlanV1(editedProduct, features); + const diff = diffPlanV1({ from, to }); + const migrationDiff = getMigratablePlanDiff(diff); + + const hasCustomize = Object.keys(migrationDiff).length > 0; + const customize = hasCustomize ? migrationDiff : undefined; + + const basePlanFilter = { + plan_id: baseProduct.id, + ...(scope === "this_version" + ? { version: baseProduct.version } + : {}), + }; + const planFilter = includeCustom + ? basePlanFilter + : { ...basePlanFilter, custom: false }; + const updatePlanOp = (custom: boolean): UpdatePlanOp => ({ + type: "update_plan", + plan_filter: { ...basePlanFilter, custom }, + ...(customize ? { customize } : {}), + }); + + const filter: MigrationFilter = { + customer: { plan: planFilter }, + }; + + const suffix = + scope === "all_customers" ? "update-all" : "update"; + + return { + id: `${baseProduct.id}-${suffix}-${migrationUid()}`, + filter, + operations: { + customer: includeCustom + ? [updatePlanOp(false), updatePlanOp(true)] + : [updatePlanOp(false)], + }, + no_billing_changes: diffHasBillingChanges(migrationDiff) === false, + }; +} diff --git a/vite/src/views/products/plan/versioning/planMigrationDiff.ts b/vite/src/views/products/plan/versioning/planMigrationDiff.ts new file mode 100644 index 000000000..1241422ba --- /dev/null +++ b/vite/src/views/products/plan/versioning/planMigrationDiff.ts @@ -0,0 +1,60 @@ +import type { FrontendProduct } from "@autumn/shared"; +import { isPriceItem } from "@autumn/shared"; +import { getPlanItemsDiff } from "@/components/forms/shared"; +import { getProductPriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay"; + +export function getPlanPriceChange({ + baseProduct, + product, + currency, +}: { + baseProduct: FrontendProduct | null | undefined; + product: FrontendProduct; + currency: string; +}) { + if (!baseProduct) return null; + + const oldDisplay = getProductPriceDisplay({ product: baseProduct, currency }); + const newDisplay = getProductPriceDisplay({ product, currency }); + const oldPrice = + oldDisplay.type === "price" ? oldDisplay.formattedPrice : "Free"; + const newPrice = + newDisplay.type === "price" ? newDisplay.formattedPrice : "Free"; + const oldInterval = + oldDisplay.type === "price" ? oldDisplay.intervalText : null; + const newInterval = + newDisplay.type === "price" ? newDisplay.intervalText : null; + + if (oldPrice === newPrice && oldInterval === newInterval) return null; + + const originalPriceItem = baseProduct.items?.find((i) => isPriceItem(i)); + const currentPriceItem = product.items?.find((i) => isPriceItem(i)); + + return { + oldPrice, + newPrice, + oldIntervalText: oldInterval !== newInterval ? oldInterval : null, + newIntervalText: newInterval, + isUpgrade: (currentPriceItem?.price ?? 0) > (originalPriceItem?.price ?? 0), + }; +} + +export function hasPlanMigrationDiff({ + baseProduct, + product, + currency, +}: { + baseProduct: FrontendProduct | null | undefined; + product: FrontendProduct; + currency: string; +}) { + if (!baseProduct) return false; + return ( + !!getPlanPriceChange({ baseProduct, product, currency }) || + getPlanItemsDiff({ + product, + originalItems: baseProduct.items, + showDiff: true, + }).hasDiffItems + ); +} diff --git a/vite/src/views/products/product/hooks/queries/useMigrationsQuery.tsx.tsx b/vite/src/views/products/product/hooks/queries/useMigrationsQuery.tsx.tsx deleted file mode 100644 index ed64f9632..000000000 --- a/vite/src/views/products/product/hooks/queries/useMigrationsQuery.tsx.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; - -export const useMigrationsQuery = () => { - const axiosInstance = useAxiosInstance(); - const buildKey = useQueryKeyFactory(); - - const fetchProductMigrations = async () => { - const { data } = await axiosInstance.get("/products/migrations"); - return data; - }; - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: buildKey(["migrations"]), - queryFn: fetchProductMigrations, - retry: false, // Don't retry on error - }); - - return { migrations: data?.migrations || [], isLoading, error, refetch }; -}; diff --git a/vite/src/views/products/product/hooks/useProductQuery.tsx b/vite/src/views/products/product/hooks/useProductQuery.tsx index b33d918b7..469162df1 100644 --- a/vite/src/views/products/product/hooks/useProductQuery.tsx +++ b/vite/src/views/products/product/hooks/useProductQuery.tsx @@ -9,7 +9,6 @@ import { useAxiosInstance } from "@/services/useAxiosInstance"; import { throwBackendError } from "@/utils/genUtils"; import { useCachedProduct } from "./getCachedProduct"; -import { useMigrationsQuery } from "./queries/useMigrationsQuery.tsx"; import { useProductCountsQuery } from "./queries/useProductCountsQuery"; // Product query state... @@ -71,7 +70,6 @@ export const useProductQuery = () => { }); const { refetch: refetchCounts } = useProductCountsQuery(); - const { refetch: refetchMigrations } = useMigrationsQuery(); const product = data?.product || cachedProduct; const isLoadingWithCache = cachedProduct ? false : isLoading; @@ -90,10 +88,17 @@ export const useProductQuery = () => { return { product, numVersions: data?.numVersions || cachedProduct?.version || 1, + versionCounts: (data?.versionCounts || {}) as Record< + number, + { active: number; canceled: number; custom: number; trialing: number } + >, isLoading: isLoadingWithCache, refetch: async () => { await refetch(); - await Promise.all([refetchMigrations(), refetchCounts()]); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["migrations"] }), + refetchCounts(), + ]); }, invalidate, error, diff --git a/vite/src/views/products/product/utils/updateProduct.ts b/vite/src/views/products/product/utils/updateProduct.ts index 3e205040c..fdc5445a8 100644 --- a/vite/src/views/products/product/utils/updateProduct.ts +++ b/vite/src/views/products/product/utils/updateProduct.ts @@ -16,11 +16,13 @@ export const updateProduct = async ({ productId, product, onSuccess, + version, }: { axiosInstance: AxiosInstance; productId: string; product: UpdateProductV2Params; onSuccess: () => Promise; + version?: number; }) => { const validated = validateItemsBeforeSave( product.items as FrontendProductItem[], @@ -38,10 +40,13 @@ export const updateProduct = async ({ free_trial: product.free_trial, }); + const options = version ? { version } : undefined; + const updatedProduct = await ProductService.updateProduct( axiosInstance, productId, updateData, + options, ); await onSuccess(); diff --git a/vite/src/views/products/products/components/ProductsPageHeader.tsx b/vite/src/views/products/products/components/ProductsPageHeader.tsx index 164cc2b0b..a84cf94b5 100644 --- a/vite/src/views/products/products/components/ProductsPageHeader.tsx +++ b/vite/src/views/products/products/components/ProductsPageHeader.tsx @@ -1,5 +1,6 @@ import { CubeIcon } from "@phosphor-icons/react"; import type { ReactNode } from "react"; +import { PageHeader } from "@/components/general/PageHeader"; interface ProductsPageHeaderProps { children?: ReactNode; @@ -7,18 +8,14 @@ interface ProductsPageHeaderProps { /** * Shared header for the Products/Plans page. - * Matches Table.Toolbar + Table.Heading styles. */ export function ProductsPageHeader({ children }: ProductsPageHeaderProps) { return ( -
-
-
- - Plans -
-
{children}
-
-
+ } + title="Plans" + > + {children} + ); } diff --git a/vite/src/views/settings/SettingsSection.tsx b/vite/src/views/settings/SettingsSection.tsx index c239ce980..b6498a6d2 100644 --- a/vite/src/views/settings/SettingsSection.tsx +++ b/vite/src/views/settings/SettingsSection.tsx @@ -28,8 +28,12 @@ export const SettingsSection = ({
-

{title}

-

{description}

+

+ {title} +

+

+ {description} +

{actions}
diff --git a/vite/src/views/settings/SettingsView.tsx b/vite/src/views/settings/SettingsView.tsx index 0f3856647..7bc2cc5f5 100644 --- a/vite/src/views/settings/SettingsView.tsx +++ b/vite/src/views/settings/SettingsView.tsx @@ -1,31 +1,39 @@ +import { GearIcon } from "@phosphor-icons/react"; import { BellIcon, + BotIcon, BuildingIcon, - CreditCardIcon, PaletteIcon, + ReceiptIcon, ShieldCheckIcon, + SlidersHorizontalIcon, UserIcon, UsersIcon, } from "lucide-react"; import { useSearchParams } from "react-router"; import { PageContainer } from "@/components/general/PageContainer"; +import { PageHeader } from "@/components/general/PageHeader"; import { cn } from "@/lib/utils"; import { AccountSection } from "./sections/AccountSection"; -import { OrganizationSection } from "./sections/OrganizationSection"; -import { MembersSection } from "./sections/MembersSection"; -import { AuthorizedAppsSection } from "./sections/AuthorizedAppsSection"; +import { AgentSection } from "./sections/AgentSection"; import { AppearanceSection } from "./sections/AppearanceSection"; +import { AuthorizedAppsSection } from "./sections/AuthorizedAppsSection"; import { BillingSettingsSection } from "./sections/BillingSettingsSection"; +import { InvoicesSection } from "./sections/InvoicesSection"; +import { MembersSection } from "./sections/MembersSection"; +import { OrganizationSection } from "./sections/OrganizationSection"; import { UsageAlertsSection } from "./sections/UsageAlertsSection"; type SettingsTab = | "account" | "organization" | "members" - | "billing" - | "usage-alerts" + | "agent" | "appearance" - | "apps"; + | "apps" + | "billing" + | "invoices" + | "usage-alerts"; interface SettingsNavItem { readonly id: SettingsTab; @@ -33,48 +41,79 @@ interface SettingsNavItem { readonly icon: React.ReactNode; } -const SETTINGS_TABS: readonly SettingsNavItem[] = [ - { id: "account", label: "Account", icon: }, +interface SettingsNavGroup { + readonly label: string; + readonly items: readonly SettingsNavItem[]; +} + +const SETTINGS_GROUPS: readonly SettingsNavGroup[] = [ { - id: "organization", label: "Organization", - icon: , + items: [ + { + id: "account", + label: "Account", + icon: , + }, + { + id: "organization", + label: "Organization", + icon: , + }, + { + id: "members", + label: "Members", + icon: , + }, + { + id: "appearance", + label: "Appearance", + icon: , + }, + { + id: "apps", + label: "Authorized Apps", + icon: , + }, + { + id: "agent", + label: "Agent", + icon: , + }, + ], }, { - id: "members", - label: "Members", - icon: , - }, - { - id: "billing", label: "Billing", - icon: , + items: [ + { + id: "billing", + label: "Configuration", + icon: , + }, + { + id: "invoices", + label: "Invoices", + icon: , + }, + { + id: "usage-alerts", + label: "Usage Alerts", + icon: , + }, + ], }, - { - id: "usage-alerts", - label: "Usage Alerts", - icon: , - }, - { - id: "appearance", - label: "Appearance", - icon: , - }, - { - id: "apps", - label: "Authorized Apps", - icon: , - }, -] as const; +]; const SECTION_MAP: Record = { account: AccountSection, organization: OrganizationSection, members: MembersSection, - billing: BillingSettingsSection, - "usage-alerts": UsageAlertsSection, + agent: AgentSection, appearance: AppearanceSection, apps: AuthorizedAppsSection, + billing: BillingSettingsSection, + invoices: InvoicesSection, + "usage-alerts": UsageAlertsSection, }; export const SettingsView = () => { @@ -88,24 +127,34 @@ export const SettingsView = () => { return ( -